From a03278afc8ea515d1a11f829793867bbc19f0aae Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:57:05 -0700 Subject: [PATCH] fix(agents): share readiness config projection Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../src-tauri/src/commands/agent_config.rs | 290 ++------------- .../src/commands/agent_config_readiness.rs | 219 +++++++++++ .../commands/agent_config_readiness_tests.rs | 251 +++++++++++++ .../src/commands/agent_config_tests.rs | 348 +----------------- desktop/src-tauri/src/commands/agents.rs | 36 +- .../src/commands/agents_create_fields.rs | 33 ++ .../src-tauri/src/commands/agents_tests.rs | 22 +- desktop/src-tauri/src/lib.rs | 1 - .../src-tauri/src/managed_agents/discovery.rs | 7 + .../src/managed_agents/readiness/cli_probe.rs | 18 +- desktop/src/features/agents/AGENTS.md | 17 +- desktop/src/shared/api/tauri.test.mjs | 60 +-- desktop/src/shared/api/tauri.ts | 22 +- desktop/src/shared/api/types.ts | 34 +- 14 files changed, 633 insertions(+), 725 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_config_readiness.rs create mode 100644 desktop/src-tauri/src/commands/agent_config_readiness_tests.rs diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index b4ad5d0a78..8f1d182e43 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -1,10 +1,13 @@ -use serde::{Deserialize, Serialize}; +use serde::Serialize; use tauri::{AppHandle, Manager, State}; +#[path = "agent_config_readiness.rs"] +mod readiness_preview; +pub use readiness_preview::{AgentReadinessDraft, AgentReadinessEvaluation}; + use crate::{ app_state::AppState, managed_agents::{ - agent_readiness, config_bridge::{ read_goose_file_config, reader::read_config_surface, @@ -15,10 +18,8 @@ use crate::{ }, current_instance_id, is_reserved_env_key, is_safe_to_reveal, is_well_formed_env_key, known_acp_runtime, load_managed_agents, load_personas, resolve_effective_agent_env, - save_managed_agents, sync_managed_agent_processes, AgentDefinition, AgentReadiness, - BackendKind, GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, Requirement, RespondTo, DEFAULT_ACP_COMMAND, - DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, MAX_ENV_VALUE_BYTES, + save_managed_agents, sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig, + KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES, }, }; @@ -61,237 +62,6 @@ fn non_blank(v: Option<&str>) -> Option { v.filter(|s| !s.trim().is_empty()).map(str::to_owned) } -fn trim_to_option(value: Option) -> Option { - value.and_then(|value| { - let trimmed = value.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) - }) -} - -/// A proposed managed-agent configuration to evaluate before persistence. -/// -/// Every field is an optional draft override of the same persisted field on -/// [`ManagedAgentRecord`]. Edit drafts include `pubkey` and inherit omitted -/// fields from that saved record; create drafts omit `pubkey` and are evaluated -/// from only the submitted draft plus global defaults. JSON `null` for -/// tri-state fields clears that value in the draft only. The resulting record -/// is never written to disk. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentReadinessDraft { - /// Saved agent to use as the baseline for edit drafts. Omit for create - /// drafts, which are evaluated from only the submitted proposed config and - /// global defaults. - #[serde(default)] - pub pubkey: Option, - /// Draft runtime id. `null` clears the instance runtime so a linked agent - /// inherits from its persona. - #[serde(default, deserialize_with = "crate::util::double_option")] - pub runtime: Option>, - /// Draft explicit command pin. `null` clears the pin. Prefer `runtime` for - /// catalog-known harnesses; this exists for Custom command drafts. - #[serde(default, deserialize_with = "crate::util::double_option")] - pub agent_command: Option>, - /// Draft model selection. `null` clears the selection. - #[serde(default, deserialize_with = "crate::util::double_option")] - pub model: Option>, - /// Draft provider selection. `null` clears the selection. - #[serde(default, deserialize_with = "crate::util::double_option")] - pub provider: Option>, - /// Draft agent-local env vars. When present, replaces the record's env var - /// map for evaluation only. - #[serde(default)] - pub env_vars: Option>, -} - -/// Presentation-ready readiness result for a saved or draft managed-agent -/// configuration. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AgentReadinessEvaluation { - /// `true` when all backend-owned requirements are satisfied. - pub ready: bool, - /// Structured missing requirements, including the `surface` discriminator - /// the UI uses to choose the resolution affordance. - pub requirements: Vec, -} - -fn readiness_to_evaluation(readiness: AgentReadiness) -> AgentReadinessEvaluation { - match readiness { - AgentReadiness::Ready => AgentReadinessEvaluation { - ready: true, - requirements: Vec::new(), - }, - AgentReadiness::NotReady { requirements } => AgentReadinessEvaluation { - ready: false, - requirements, - }, - } -} - -fn apply_draft_override(record: &mut ManagedAgentRecord, draft: AgentReadinessDraft) { - let AgentReadinessDraft { - pubkey: _, - runtime, - agent_command, - model, - provider, - env_vars, - } = draft; - let runtime_supplied = runtime.is_some(); - if let Some(runtime) = runtime { - record.runtime = trim_to_option(runtime); - record.agent_command_override = None; - } - if let Some(agent_command) = agent_command { - if !runtime_supplied || record.runtime.is_none() { - record.agent_command_override = trim_to_option(agent_command); - if record.agent_command_override.is_some() { - record.runtime = None; - } - } - } - if let Some(model) = model { - record.model = trim_to_option(model); - } - if let Some(provider) = provider { - record.provider = trim_to_option(provider); - } - if let Some(env_vars) = env_vars { - record.env_vars = env_vars; - } -} - -fn evaluate_agent_record_readiness( - record: &ManagedAgentRecord, - personas: &[AgentDefinition], - global: &GlobalAgentConfig, -) -> AgentReadinessEvaluation { - let command = crate::managed_agents::record_agent_command(record, personas); - let metadata = known_acp_runtime(&command); - let effective = resolve_effective_agent_env(record, personas, metadata, global); - readiness_to_evaluation(agent_readiness(&effective)) -} - -fn build_draft_baseline(draft: &AgentReadinessDraft) -> ManagedAgentRecord { - let command = draft - .agent_command - .as_ref() - .and_then(|value| value.as_ref()) - .and_then(|value| non_blank(Some(value))) - .or_else(|| { - draft - .runtime - .as_ref() - .and_then(|value| value.as_ref()) - .and_then(|id| crate::managed_agents::command_for_runtime_id(id)) - }) - .unwrap_or_else(crate::managed_agents::default_agent_command); - - ManagedAgentRecord { - pubkey: draft.pubkey.clone().unwrap_or_default(), - name: "Draft Agent".to_string(), - description: None, - persona_id: None, - team_id: None, - private_key_nsec: String::new(), - auth_tag: None, - relay_url: String::new(), - avatar_url: None, - acp_command: DEFAULT_ACP_COMMAND.to_string(), - agent_command: command, - agent_command_override: None, - agent_args: Vec::new(), - mcp_command: String::new(), - turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: DEFAULT_AGENT_PARALLELISM, - system_prompt: None, - model: None, - provider: None, - persona_source_version: None, - env_vars: Default::default(), - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: BackendKind::Local, - backend_agent_id: None, - provider_policy_pending: false, - provider_binary_path: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: String::new(), - updated_at: String::new(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to: RespondTo::OwnerOnly, - respond_to_allowlist: Vec::new(), - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - team_catalog_source: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - effort_level: None, - } -} - -fn evaluate_saved_agent_readiness_for_app( - app: &tauri::AppHandle, - state: &AppState, - pubkey: &str, -) -> Result { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let records = load_managed_agents(app)?; - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - let personas = load_personas(app).unwrap_or_default(); - let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - Ok(evaluate_agent_record_readiness(record, &personas, &global)) -} - -fn evaluate_draft_agent_readiness_for_app( - app: &tauri::AppHandle, - state: &AppState, - draft: AgentReadinessDraft, -) -> Result { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let records = load_managed_agents(app)?; - let mut record = draft - .pubkey - .as_deref() - .and_then(|pubkey| records.into_iter().find(|record| record.pubkey == pubkey)) - .unwrap_or_else(|| build_draft_baseline(&draft)); - if let Some(ref env_vars) = draft.env_vars { - crate::managed_agents::validate_user_env_keys(env_vars)?; - } - apply_draft_override(&mut record, draft); - let personas = load_personas(app).unwrap_or_default(); - let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - Ok(evaluate_agent_record_readiness(&record, &personas, &global)) -} - /// Build a sanitized `InheritedConfigTiers` snapshot at the command boundary. /// /// Persona env, global env, and harness definition env are sanitized with @@ -481,32 +251,11 @@ pub fn get_baked_build_env() -> Vec { .collect() } -/// Evaluate the readiness of a saved managed-agent configuration. +/// Evaluate a proposed managed-agent configuration without persisting it. /// -/// This is the structured counterpart to the legacy runtime-status -/// `local_setup` boolean. It returns the exact backend-computed missing -/// requirements instead of forcing the frontend to rederive provider, -/// credential, CLI-login, or missing-binary readiness. -#[tauri::command] -pub async fn evaluate_agent_readiness( - pubkey: String, - app: AppHandle, - _state: State<'_, AppState>, -) -> Result { - tokio::task::spawn_blocking(move || { - let state = app.state::(); - evaluate_saved_agent_readiness_for_app(&app, &state, &pubkey) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? -} - -/// Evaluate a draft managed-agent configuration before it is persisted. -/// -/// The saved agent is used only as a baseline for persona/global inheritance, -/// harness definition env, and omitted fields. Draft overrides are applied to a -/// clone and then passed through the same effective-env resolver and readiness -/// predicate that saved agents use. This command performs no write. +/// The request explicitly distinguishes a complete new-agent proposal from a +/// patch to one exact saved agent. Projection reuses the production create, +/// update, effective-config, harness, and readiness owners. #[tauri::command] pub async fn evaluate_agent_readiness_draft( draft: AgentReadinessDraft, @@ -514,8 +263,21 @@ pub async fn evaluate_agent_readiness_draft( _state: State<'_, AppState>, ) -> Result { tokio::task::spawn_blocking(move || { - let state = app.state::(); - evaluate_draft_agent_readiness_for_app(&app, &state, draft) + // Snapshot persisted inputs while holding the store lock; readiness may + // probe an external CLI and therefore runs only after this scope exits. + let (records, definitions, global) = { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + ( + load_managed_agents(&app)?, + load_personas(&app)?, + crate::managed_agents::load_global_agent_config(&app)?, + ) + }; + readiness_preview::evaluate_draft(draft, &records, &definitions, &global) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? diff --git a/desktop/src-tauri/src/commands/agent_config_readiness.rs b/desktop/src-tauri/src/commands/agent_config_readiness.rs new file mode 100644 index 0000000000..f6364745d8 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_config_readiness.rs @@ -0,0 +1,219 @@ +//! Managed-agent readiness preview contract. +//! +//! A preview is either a complete proposed standalone configuration or a patch +//! to one exact saved agent. Both are projected through the same command/env +//! owners used by create, update, and spawn before the existing readiness +//! predicate runs. Nothing in this module persists the projected record. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::managed_agents::{ + agent_readiness, resolve_effective_harness_descriptor, AgentDefinition, AgentReadiness, + GlobalAgentConfig, ManagedAgentRecord, Requirement, +}; + +/// Complete configuration for a new standalone agent. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NewAgentReadinessDraft { + /// Harness command selected by the client. This is the same authoritative + /// create field consumed by `create_managed_agent`; Custom commands use the + /// same field rather than a parallel runtime identity. + #[serde(default)] + pub agent_command: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub env_vars: BTreeMap, +} + +/// Configuration patch for one exact saved agent. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExistingAgentReadinessDraft { + pub pubkey: String, + /// The same command/sentinel and explicit-override intent accepted by the + /// production update path. Absent means keep the saved harness choice. + #[serde(default)] + pub agent_command: Option, + #[serde(default)] + pub harness_override: bool, + #[serde(default, deserialize_with = "crate::util::double_option")] + pub model: Option>, + #[serde(default, deserialize_with = "crate::util::double_option")] + pub provider: Option>, + /// Absent means keep the saved map; present means replace it, matching + /// `UpdateManagedAgentRequest`. + #[serde(default)] + pub env_vars: Option>, +} + +/// An unsaved readiness proposal, discriminated so a missing edit target can +/// never silently become a create preview. +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AgentReadinessDraft { + New { config: NewAgentReadinessDraft }, + Existing { config: ExistingAgentReadinessDraft }, +} + +/// Presentation-ready readiness result. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgentReadinessEvaluation { + pub ready: bool, + /// Existing surface-discriminated requirements tell the client which + /// affordance can resolve each gap. + pub requirements: Vec, +} + +fn trim_to_option(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) +} + +fn readiness_to_evaluation(readiness: AgentReadiness) -> AgentReadinessEvaluation { + match readiness { + AgentReadiness::Ready => AgentReadinessEvaluation { + ready: true, + requirements: Vec::new(), + }, + AgentReadiness::NotReady { requirements } => AgentReadinessEvaluation { + ready: false, + requirements, + }, + } +} + +fn apply_existing_draft( + record: &mut ManagedAgentRecord, + draft: ExistingAgentReadinessDraft, + definitions: &[AgentDefinition], +) -> Result<(), String> { + crate::commands::managed_agent_definition::apply_model_provider_prompt_update( + record, + draft.model, + draft.provider, + None, + )?; + + let inherit_transition = match draft.agent_command { + Some(command) => crate::managed_agents::apply_agent_command_update( + record, + definitions, + &command, + draft.harness_override, + ), + None => false, + }; + + if let Some(ref env_vars) = draft.env_vars { + crate::managed_agents::validate_user_env_keys(env_vars)?; + } + crate::managed_agents::apply_env_vars_then_effort_transition( + record, + draft.env_vars, + inherit_transition, + ); + Ok(()) +} + +fn build_new_record( + draft: NewAgentReadinessDraft, + definitions: &[AgentDefinition], +) -> Result { + crate::managed_agents::validate_user_env_keys(&draft.env_vars)?; + let picked_command = trim_to_option(draft.agent_command); + let agent_command_override = crate::managed_agents::create_time_agent_command_override( + None, + definitions, + picked_command.as_deref(), + true, + ); + let agent_command = crate::managed_agents::effective_agent_command( + None, + definitions, + agent_command_override.as_deref(), + ); + + let inference = crate::commands::agents::create_fields::resolve_created_inference_config( + draft.provider.as_deref(), + draft.model.as_deref(), + None, + ); + let record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "", + "name": "Readiness preview", + "private_key_nsec": "", + "relay_url": "", + "acp_command": crate::managed_agents::DEFAULT_ACP_COMMAND, + "agent_command": agent_command, + "agent_command_override": agent_command_override, + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": crate::managed_agents::DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + "parallelism": crate::managed_agents::DEFAULT_AGENT_PARALLELISM, + "system_prompt": null, + "model": inference.model, + "provider": inference.provider, + "env_vars": draft.env_vars, + "relay_mesh": inference.relay_mesh, + "created_at": "", + "updated_at": "", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + })) + .map_err(|error| format!("failed to build readiness preview: {error}"))?; + Ok(record) +} + +fn evaluate_record( + record: &ManagedAgentRecord, + definitions: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> Result { + crate::managed_agents::effective_config::resolve_effective_config(record, definitions, global) + .require_resolved()?; + let descriptor = resolve_effective_harness_descriptor(record, definitions, global) + .map_err(|error| crate::managed_agents::user_facing_harness_error(&error))?; + let effective = crate::managed_agents::readiness::EffectiveAgentEnv { + env: descriptor.env, + config_file_path: crate::managed_agents::known_acp_runtime(&descriptor.command) + .and_then(|runtime| runtime.config_file_path), + effective_command: descriptor.command, + }; + Ok(readiness_to_evaluation(agent_readiness(&effective))) +} + +pub(super) fn evaluate_draft( + draft: AgentReadinessDraft, + saved_records: &[ManagedAgentRecord], + definitions: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> Result { + let record = match draft { + AgentReadinessDraft::New { config } => build_new_record(config, definitions)?, + AgentReadinessDraft::Existing { config } => { + let mut record = saved_records + .iter() + .find(|record| record.pubkey == config.pubkey) + .cloned() + .ok_or_else(|| format!("agent {} not found", config.pubkey))?; + apply_existing_draft(&mut record, config, definitions)?; + record + } + }; + evaluate_record(&record, definitions, global) +} + +#[cfg(test)] +#[path = "agent_config_readiness_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/agent_config_readiness_tests.rs b/desktop/src-tauri/src/commands/agent_config_readiness_tests.rs new file mode 100644 index 0000000000..cb45507914 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_config_readiness_tests.rs @@ -0,0 +1,251 @@ +use super::*; +use crate::managed_agents::{BackendKind, RespondTo}; + +fn record() -> ManagedAgentRecord { + serde_json::from_value(serde_json::json!({ + "pubkey": "a".repeat(64), + "name": "Agent", + "private_key_nsec": "", + "relay_url": "", + "acp_command": "buzz-acp", + "agent_command": "buzz-agent", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 300, + "parallelism": 1, + "system_prompt": null, + "model": "claude-opus-4-5", + "provider": "anthropic", + "env_vars": {}, + "created_at": "", + "updated_at": "", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null, + "backend": { "type": "local" }, + "respond_to": "owner-only" + })) + .expect("record fixture") +} + +fn definition() -> AgentDefinition { + AgentDefinition { + id: "persona-1".to_string(), + display_name: "Persona".to_string(), + description: None, + avatar_url: None, + system_prompt: "Instructions".to_string(), + runtime: Some("buzz-agent".to_string()), + model: Some("openrouter/model".to_string()), + provider: Some("openrouter".to_string()), + env_vars: BTreeMap::from([("OPENROUTER_API_KEY".to_string(), "sk-test".to_string())]), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: None, + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: String::new(), + updated_at: String::new(), + } +} + +fn existing(pubkey: String) -> AgentReadinessDraft { + AgentReadinessDraft::Existing { + config: ExistingAgentReadinessDraft { + pubkey, + agent_command: None, + harness_override: false, + model: None, + provider: None, + env_vars: None, + }, + } +} + +#[test] +fn existing_draft_uses_production_update_projection() { + let saved = record(); + let mut config = match existing(saved.pubkey.clone()) { + AgentReadinessDraft::Existing { config } => config, + AgentReadinessDraft::New { .. } => unreachable!(), + }; + config.env_vars = Some(BTreeMap::from([( + "ANTHROPIC_API_KEY".to_string(), + "sk-test".to_string(), + )])); + + let result = evaluate_draft( + AgentReadinessDraft::Existing { config }, + std::slice::from_ref(&saved), + &[], + &GlobalAgentConfig::default(), + ) + .expect("draft evaluates"); + + assert!(result.ready); + assert!( + saved.env_vars.is_empty(), + "saved baseline remains unchanged" + ); +} + +#[test] +fn unknown_existing_target_is_not_reinterpreted_as_create() { + let error = evaluate_draft( + existing("missing".to_string()), + &[], + &[], + &GlobalAgentConfig::default(), + ) + .expect_err("unknown edit target must fail"); + + assert!(error.contains("agent missing not found")); +} + +#[test] +fn new_draft_uses_production_create_harness_projection() { + let draft = AgentReadinessDraft::New { + config: NewAgentReadinessDraft { + agent_command: Some("buzz-agent".to_string()), + model: Some("claude-opus-4-5".to_string()), + provider: Some("anthropic".to_string()), + env_vars: BTreeMap::new(), + }, + }; + + let result = evaluate_draft(draft, &[], &[], &GlobalAgentConfig::default()) + .expect("new draft evaluates"); + assert_eq!( + result.requirements, + vec![Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }] + ); +} + +#[cfg(feature = "mesh-llm")] +#[test] +fn new_relay_mesh_draft_uses_create_default_model_projection() { + let draft = AgentReadinessDraft::New { + config: NewAgentReadinessDraft { + agent_command: Some("buzz-agent".to_string()), + model: None, + provider: Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID.to_string()), + env_vars: BTreeMap::new(), + }, + }; + + let result = evaluate_draft(draft, &[], &[], &GlobalAgentConfig::default()) + .expect("relay mesh draft evaluates"); + assert!( + !result.requirements.contains(&Requirement::NormalizedField { + field: "model".to_string(), + }), + "Create projects an omitted relay-mesh model to auto before readiness" + ); +} + +#[test] +fn missing_custom_command_uses_same_binary_check_as_startup() { + let draft = AgentReadinessDraft::New { + config: NewAgentReadinessDraft { + agent_command: Some("buzz-readiness-missing-command".to_string()), + model: None, + provider: None, + env_vars: BTreeMap::new(), + }, + }; + + let result = evaluate_draft(draft, &[], &[], &GlobalAgentConfig::default()) + .expect("custom command readiness evaluates"); + assert_eq!( + result.requirements, + vec![Requirement::MissingBinary { + command: "buzz-readiness-missing-command".to_string(), + }] + ); +} + +#[test] +fn linked_edit_uses_definition_owned_config() { + let mut saved = record(); + saved.persona_id = Some("persona-1".to_string()); + saved.provider = Some("anthropic".to_string()); + saved.model = Some("stale".to_string()); + let definition = definition(); + + let result = evaluate_draft( + existing(saved.pubkey.clone()), + std::slice::from_ref(&saved), + std::slice::from_ref(&definition), + &GlobalAgentConfig::default(), + ) + .expect("linked draft evaluates"); + + assert!( + result.ready, + "definition provider/model/env are authoritative" + ); +} + +#[test] +fn orphaned_link_is_refused_like_spawn() { + let mut saved = record(); + saved.persona_id = Some("missing-persona".to_string()); + + let error = evaluate_draft( + existing(saved.pubkey.clone()), + std::slice::from_ref(&saved), + &[], + &GlobalAgentConfig::default(), + ) + .expect_err("orphan must not report readiness"); + + assert_eq!( + error, + crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR + ); +} + +#[test] +fn request_shape_preserves_new_vs_existing_and_patch_nulls() { + let new: AgentReadinessDraft = serde_json::from_value(serde_json::json!({ + "kind": "new", + "config": { + "agentCommand": "buzz-agent", + "provider": "anthropic", + "model": "claude-opus-4-5" + } + })) + .expect("new wire shape"); + assert!(matches!(new, AgentReadinessDraft::New { .. })); + + let existing: AgentReadinessDraft = serde_json::from_value(serde_json::json!({ + "kind": "existing", + "config": { + "pubkey": "agent", + "provider": null, + "model": null + } + })) + .expect("existing wire shape"); + let AgentReadinessDraft::Existing { config } = existing else { + panic!("expected existing draft"); + }; + assert_eq!(config.provider, Some(None)); + assert_eq!(config.model, Some(None)); +} + +#[test] +fn fixture_defaults_match_local_managed_agent_shape() { + let saved = record(); + assert_eq!(saved.backend, BackendKind::Local); + assert_eq!(saved.respond_to, RespondTo::OwnerOnly); +} diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index bf6bd92321..aa9d2ea9f3 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -6,7 +6,7 @@ use super::*; use crate::managed_agents::config_bridge::types::ConfigOrigin; -use crate::managed_agents::{save_managed_agents, BackendKind, RespondTo}; +use crate::managed_agents::{BackendKind, RespondTo}; use std::sync::Mutex; @@ -67,68 +67,6 @@ fn goose_runtime() -> &'static KnownAcpRuntime { &RUNTIME } -fn agent_readiness_draft(pubkey: &str) -> AgentReadinessDraft { - AgentReadinessDraft { - pubkey: Some(pubkey.to_string()), - runtime: None, - agent_command: None, - model: None, - provider: None, - env_vars: None, - } -} - -struct EnvVarGuard { - key: &'static str, - prior: Option, -} - -impl EnvVarGuard { - fn set(key: &'static str, value: &std::path::Path) -> Self { - let prior = std::env::var_os(key); - #[allow(deprecated)] - unsafe { - std::env::set_var(key, value) - }; - Self { key, prior } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - #[allow(deprecated)] - unsafe { - match &self.prior { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } -} - -struct MockAppFixture { - app: tauri::App, - _temp: tempfile::TempDir, - _home: EnvVarGuard, - _xdg: EnvVarGuard, -} - -fn mock_app_in_temp_data_dir() -> MockAppFixture { - let temp = tempfile::tempdir().expect("temp app data root"); - let home = EnvVarGuard::set("HOME", temp.path()); - let xdg = EnvVarGuard::set("XDG_DATA_HOME", temp.path()); - let app = tauri::test::mock_builder() - .manage(crate::app_state::build_app_state()) - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app builds headless"); - MockAppFixture { - app, - _temp: temp, - _home: home, - _xdg: xdg, - } -} - fn agent_record() -> ManagedAgentRecord { ManagedAgentRecord { description: None, @@ -224,290 +162,6 @@ fn persona_without_model() -> AgentDefinition { } } -#[test] -fn saved_readiness_evaluation_returns_structured_missing_requirements() { - let mut record = agent_record(); - record.persona_id = None; - record.runtime = Some("buzz-agent".to_string()); - record.agent_command = "buzz-agent".to_string(); - record.provider = Some("anthropic".to_string()); - record.model = Some("claude-opus-4-5".to_string()); - - let evaluation = evaluate_agent_record_readiness( - &record, - &[], - &crate::managed_agents::GlobalAgentConfig::default(), - ); - - assert!( - !evaluation.ready, - "anthropic without ANTHROPIC_API_KEY must be NotReady" - ); - assert_eq!( - evaluation.requirements, - vec![Requirement::EnvKey { - key: "ANTHROPIC_API_KEY".to_string(), - }] - ); -} - -#[test] -fn draft_readiness_override_can_be_ready_without_mutating_record() { - let mut record = agent_record(); - record.persona_id = None; - record.runtime = Some("buzz-agent".to_string()); - record.agent_command = "buzz-agent".to_string(); - record.provider = Some("anthropic".to_string()); - record.model = Some("claude-opus-4-5".to_string()); - record.env_vars.clear(); - let original = record.clone(); - - let mut draft = agent_readiness_draft(&record.pubkey); - draft.env_vars = Some(std::collections::BTreeMap::from([( - "ANTHROPIC_API_KEY".to_string(), - "sk-test".to_string(), - )])); - apply_draft_override(&mut record, draft); - - let evaluation = evaluate_agent_record_readiness( - &record, - &[], - &crate::managed_agents::GlobalAgentConfig::default(), - ); - - assert!( - evaluation.ready, - "draft env var should satisfy the production readiness predicate" - ); - assert!( - original.env_vars.is_empty(), - "draft evaluation must not require mutating the saved baseline" - ); -} - -#[test] -fn draft_readiness_runtime_override_uses_runtime_catalog_env_keys() { - let mut record = agent_record(); - record.persona_id = None; - record.runtime = Some("claude".to_string()); - record.agent_command = "claude-agent-acp".to_string(); - record.provider = None; - record.model = None; - - let mut draft = agent_readiness_draft(&record.pubkey); - draft.runtime = Some(Some("buzz-agent".to_string())); - draft.provider = Some(Some("anthropic".to_string())); - draft.model = Some(Some("claude-opus-4-5".to_string())); - draft.env_vars = Some(std::collections::BTreeMap::new()); - apply_draft_override(&mut record, draft); - - let evaluation = evaluate_agent_record_readiness( - &record, - &[], - &crate::managed_agents::GlobalAgentConfig::default(), - ); - - assert!( - !evaluation.ready, - "draft runtime should be evaluated as buzz-agent, not the saved Claude command" - ); - assert!( - evaluation.requirements.contains(&Requirement::EnvKey { - key: "ANTHROPIC_API_KEY".to_string(), - }), - "missing requirements must come from the selected runtime/provider catalog; got {:?}", - evaluation.requirements - ); - assert!( - !evaluation - .requirements - .iter() - .any(|requirement| matches!(requirement, Requirement::CliLogin { .. })), - "runtime override to buzz-agent must not run the saved Claude CLI-login readiness path" - ); -} - -#[test] -fn draft_readiness_null_provider_clears_to_global_fallback() { - let mut record = agent_record(); - record.persona_id = None; - record.runtime = Some("buzz-agent".to_string()); - record.agent_command = "buzz-agent".to_string(); - record.provider = Some("anthropic".to_string()); - record.model = Some("claude-opus-4-5".to_string()); - - let mut global = crate::managed_agents::GlobalAgentConfig { - provider: Some("openrouter".to_string()), - model: Some("openrouter/model".to_string()), - ..Default::default() - }; - global.env_vars.insert( - "OPENROUTER_API_KEY".to_string(), - "sk-openrouter".to_string(), - ); - - let mut draft = agent_readiness_draft(&record.pubkey); - draft.provider = Some(None); - draft.model = Some(None); - apply_draft_override(&mut record, draft); - - let evaluation = evaluate_agent_record_readiness(&record, &[], &global); - - assert!( - evaluation.ready, - "explicit null draft fields should clear saved values and fall through to global config" - ); -} - -#[test] -fn draft_readiness_preserves_definition_authority_for_linked_instances() { - let mut record = agent_record(); - record.persona_id = Some("persona-1".to_string()); - record.runtime = Some("buzz-agent".to_string()); - record.agent_command = "buzz-agent".to_string(); - record.provider = None; - record.model = None; - - let mut persona = persona_without_model(); - persona.provider = Some("openrouter".to_string()); - persona.model = Some("openrouter/model".to_string()); - persona.env_vars.insert( - "OPENROUTER_API_KEY".to_string(), - "sk-openrouter".to_string(), - ); - - let mut draft = agent_readiness_draft(&record.pubkey); - draft.provider = Some(Some("anthropic".to_string())); - draft.model = Some(Some("claude-opus-4-5".to_string())); - draft.env_vars = Some(std::collections::BTreeMap::new()); - apply_draft_override(&mut record, draft); - - let evaluation = evaluate_agent_record_readiness( - &record, - &[persona], - &crate::managed_agents::GlobalAgentConfig::default(), - ); - - assert!( - evaluation.ready, - "linked instances must still resolve provider/model from the persona, not draft instance fields" - ); -} - -#[tokio::test] -async fn saved_readiness_command_reads_persisted_agent_and_returns_requirements() { - let _guard = crate::managed_agents::lock_env_mutex(); - let fixture = mock_app_in_temp_data_dir(); - let mut record = agent_record(); - record.pubkey = "a".repeat(64); - record.persona_id = None; - record.runtime = Some("buzz-agent".to_string()); - record.agent_command = "buzz-agent".to_string(); - record.provider = Some("anthropic".to_string()); - record.model = Some("claude-opus-4-5".to_string()); - save_managed_agents(fixture.app.handle(), &[record.clone()]).expect("seed saved agent"); - - let evaluation = evaluate_saved_agent_readiness_for_app( - fixture.app.handle(), - &fixture.app.state::(), - &record.pubkey, - ) - .expect("command evaluates saved readiness"); - - assert_eq!( - evaluation.requirements, - vec![Requirement::EnvKey { - key: "ANTHROPIC_API_KEY".to_string(), - }] - ); -} - -#[tokio::test] -async fn draft_readiness_command_uses_overrides_without_writing_them() { - let _guard = crate::managed_agents::lock_env_mutex(); - let fixture = mock_app_in_temp_data_dir(); - let mut record = agent_record(); - record.pubkey = "b".repeat(64); - record.persona_id = None; - record.runtime = Some("buzz-agent".to_string()); - record.agent_command = "buzz-agent".to_string(); - record.provider = Some("anthropic".to_string()); - record.model = Some("claude-opus-4-5".to_string()); - save_managed_agents(fixture.app.handle(), &[record.clone()]).expect("seed saved agent"); - - let mut draft = agent_readiness_draft(&record.pubkey); - draft.env_vars = Some(std::collections::BTreeMap::from([( - "ANTHROPIC_API_KEY".to_string(), - "sk-test".to_string(), - )])); - - let evaluation = evaluate_draft_agent_readiness_for_app( - fixture.app.handle(), - &fixture.app.state::(), - draft, - ) - .expect("command evaluates draft readiness"); - - assert!(evaluation.ready); - let saved = load_managed_agents(fixture.app.handle()).expect("reload saved agents"); - assert!( - saved[0].env_vars.is_empty(), - "draft command must not persist override env vars" - ); -} - -#[tokio::test] -async fn draft_readiness_command_accepts_unsaved_create_baseline() { - let _guard = crate::managed_agents::lock_env_mutex(); - let fixture = mock_app_in_temp_data_dir(); - let mut draft = agent_readiness_draft(&"c".repeat(64)); - draft.pubkey = None; - draft.runtime = Some(Some("buzz-agent".to_string())); - draft.provider = Some(Some("anthropic".to_string())); - draft.model = Some(Some("claude-opus-4-5".to_string())); - draft.env_vars = Some(std::collections::BTreeMap::new()); - - let evaluation = evaluate_draft_agent_readiness_for_app( - fixture.app.handle(), - &fixture.app.state::(), - draft, - ) - .expect("command evaluates create draft readiness"); - - assert_eq!( - evaluation.requirements, - vec![Requirement::EnvKey { - key: "ANTHROPIC_API_KEY".to_string(), - }], - "create drafts should use the runtime catalog without requiring a saved agent" - ); -} - -#[test] -fn draft_readiness_custom_command_reports_missing_binary() { - let mut record = agent_record(); - record.persona_id = None; - let missing_command = "buzz-readiness-draft-missing-harness"; - - let mut draft = agent_readiness_draft(&record.pubkey); - draft.runtime = Some(None); - draft.agent_command = Some(Some(missing_command.to_string())); - apply_draft_override(&mut record, draft); - - let evaluation = evaluate_agent_record_readiness( - &record, - &[], - &crate::managed_agents::GlobalAgentConfig::default(), - ); - - assert_eq!( - evaluation.requirements, - vec![Requirement::MissingBinary { - command: missing_command.to_string(), - }] - ); -} - /// A post-spawn session cache whose live model is `current_model` and whose /// `model_overridden` flag records whether a `SwitchModel` control signal set /// it (the live-switch signal). diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0ad7fd321c..db9465ebd5 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -13,8 +13,8 @@ use crate::{ save_managed_agents, start_managed_agent_process, stop_managed_agent_process, stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, CreateManagedAgentRequest, - CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, - DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, DEFAULT_ACP_COMMAND, + DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::relay_ws_url_with_override, util::now_iso, @@ -56,8 +56,10 @@ pub(super) fn summarize_from_disk( } #[path = "agents_create_fields.rs"] -mod create_fields; -use create_fields::{normalize_relay_mesh, resolve_created_avatar_url, trim_to_optional_string}; +pub(super) mod create_fields; +use create_fields::{ + normalize_relay_mesh, resolve_created_avatar_url, resolve_created_inference_config, +}; #[cfg(feature = "mesh-llm")] async fn ensure_relay_mesh_for_record( @@ -579,15 +581,13 @@ pub async fn create_managed_agent( let snapshot_model = persona_snapshot.as_ref().and_then(|s| s.model.clone()); let snapshot_provider = persona_snapshot.as_ref().and_then(|s| s.provider.clone()); let snapshot_source_version = persona_snapshot.as_ref().map(|s| s.source_version.clone()); - let effective_provider = snapshot_provider - .or_else(|| input.provider.as_deref().and_then(trim_to_optional_string)); - let mut effective_model = - snapshot_model.or_else(|| input.model.as_deref().and_then(trim_to_optional_string)); - if effective_provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) - && effective_model.is_none() - { - effective_model = Some(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID.to_string()); - } + let inference = resolve_created_inference_config( + snapshot_provider.as_deref().or(input.provider.as_deref()), + snapshot_model.as_deref().or(input.model.as_deref()), + relay_mesh.clone(), + ); + let effective_provider = inference.provider; + let effective_model = inference.model; // Mint-time behavioral quad: explicit input wins, then the linked // definition's NIP-AP defaults, then client defaults. The ONLY parse @@ -677,15 +677,7 @@ pub async fn create_managed_agent( definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, - relay_mesh: if effective_provider.as_deref() - == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) - { - effective_model - .clone() - .map(|model_ref| RelayMeshConfig { model_ref }) - } else { - relay_mesh.clone() - }, + relay_mesh: inference.relay_mesh, effort_level: None, }; diff --git a/desktop/src-tauri/src/commands/agents_create_fields.rs b/desktop/src-tauri/src/commands/agents_create_fields.rs index 16f840ba2e..b0313098bb 100644 --- a/desktop/src-tauri/src/commands/agents_create_fields.rs +++ b/desktop/src-tauri/src/commands/agents_create_fields.rs @@ -3,6 +3,39 @@ use crate::managed_agents::{managed_agent_avatar_url, BackendKind, RelayMeshConfig}; +pub(crate) struct CreatedInferenceConfig { + pub provider: Option, + pub model: Option, + pub relay_mesh: Option, +} + +/// Normalize the provider/model fields written by Create. +/// +/// Kept as one pure projection so readiness preview and persistence cannot +/// disagree about implicit provider defaults such as relay-mesh's `auto` model. +pub(crate) fn resolve_created_inference_config( + provider: Option<&str>, + model: Option<&str>, + relay_mesh: Option, +) -> CreatedInferenceConfig { + let provider = provider.and_then(trim_to_optional_string); + let mut model = model.and_then(trim_to_optional_string); + if provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) && model.is_none() + { + model = Some(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID.to_string()); + } + let relay_mesh = if provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) { + model.clone().map(|model_ref| RelayMeshConfig { model_ref }) + } else { + relay_mesh + }; + CreatedInferenceConfig { + provider, + model, + relay_mesh, + } +} + pub(super) fn normalize_relay_mesh( config: Option<&RelayMeshConfig>, backend: &BackendKind, diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 59e04b09ff..f67529dc53 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::managed_agents::AgentDefinition; +use crate::managed_agents::{AgentDefinition, RelayMeshConfig}; fn bare_agent_record( persona_id: Option<&str>, @@ -271,6 +271,26 @@ fn deploy_resolver_returns_none_for_orphaned_instance() { ); } +#[test] +fn created_inference_config_defaults_relay_mesh_model() { + let config = resolve_created_inference_config( + Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID), + None, + None, + ); + + assert_eq!( + config.model.as_deref(), + Some(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID) + ); + assert_eq!( + config.relay_mesh, + Some(RelayMeshConfig { + model_ref: crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID.to_string(), + }) + ); +} + #[test] fn normalize_relay_mesh_rejects_empty_model_ref() { let config = RelayMeshConfig { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2d98f6d9f0..f45ef37a28 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -715,7 +715,6 @@ pub fn run() { discover_agent_models, agent_access_owner_only, get_agent_config_surface, - evaluate_agent_readiness, evaluate_agent_readiness_draft, get_runtime_file_config, get_baked_build_env_keys, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 531ae335ce..b2d8cd02ad 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -706,6 +706,13 @@ pub(crate) fn is_npm_global_install(cmd: &str) -> bool { /// On timeout or spawn failure the child is killed and `Unknown` is returned; /// no orphaned threads or processes are left behind (see /// [`bounded_command::output_with_timeout`]). +pub(crate) fn bounded_probe_output( + command: std::process::Command, + timeout: Duration, +) -> Option { + bounded_command::output_with_timeout(command, timeout) +} + fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { use crate::managed_agents::readiness::cli_probe; diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs index 513da4e2a8..1a48846944 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -1,4 +1,5 @@ use std::path::Path; +use std::time::Duration; use crate::managed_agents::runtime::build_augmented_path; @@ -53,6 +54,11 @@ const CONFIG_PARSE_SIGNALS: &[&str] = &["error loading configuration", "unknown /// bypassed. Injects the same augmented PATH used for launched agents so /// script shims with `/usr/bin/env ` shebangs can find runtimes /// such as node/python when the app was launched with a bare GUI PATH. +/// +/// The shared discovery runner owns the process tree and bounds wall-clock time +/// and captured output. Draft readiness can run while someone is editing, so a +/// broken external CLI must never stall agent operations or grow memory without +/// bound. pub(crate) fn login_probe( binary_path: &Path, probe_args: &[&str], @@ -63,13 +69,13 @@ pub(crate) fn login_probe( if let Some(path) = augmented_path { command.env("PATH", path); } - crate::util::configure_no_window(&mut command); + let Some(output) = + crate::managed_agents::discovery::bounded_probe_output(command, Duration::from_secs(10)) + else { + return ProbeOutcome::LoggedOut; + }; - match command.output() { - Ok(o) if o.status.success() => ProbeOutcome::LoggedIn, - Ok(o) => classify_probe_output(&o.stderr, false), - Err(_) => ProbeOutcome::LoggedOut, - } + classify_probe_output(&output.stderr, output.status.success()) } /// Classify collected probe output into a `ProbeOutcome`. diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 61569f8722..5bfe20396d 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -36,14 +36,15 @@ X" flag): add it to `KnownAcpRuntime` first, expose it on `AcpRuntimeCatalogEntry`, then project it through the core. Do not shortcut with a TypeScript lookup table or an id comparison in a component. -**Draft readiness is a backend question.** Unsaved managed-agent edits are -submitted to `evaluate_agent_readiness_draft`, which applies the draft to a -saved-record clone and then runs the same `resolve_effective_agent_env` → -`agent_readiness` path used by persisted agents. The response is presentation- -ready (`ready` plus structured `Requirement` rows with their `surface` -discriminators). Frontends may use the old provider table only until their -consumer migration lands; no new surface should derive provider/credential/CLI -readiness in TypeScript or add another table. +**Draft readiness is a backend question.** Unsaved managed-agent configuration +is submitted to `evaluate_agent_readiness_draft` as an explicit `new` proposal +or an `existing` agent patch. The backend projects it through the same create / +update helpers and fallible effective-harness resolver used by Save and Start, +then runs `agent_readiness`. The response is presentation-ready (`ready` plus +structured `Requirement` rows with their `surface` discriminators). Frontends +may use the old provider table only until their consumer migration lands; no +new surface should derive provider/credential/CLI readiness in TypeScript or +add another table. ## Rules diff --git a/desktop/src/shared/api/tauri.test.mjs b/desktop/src/shared/api/tauri.test.mjs index 562365bfa0..23943e7239 100644 --- a/desktop/src/shared/api/tauri.test.mjs +++ b/desktop/src/shared/api/tauri.test.mjs @@ -119,11 +119,8 @@ test("relay rate-limited: prefix check is case-sensitive (Rust always emits lowe // arrives as definitionEnv (camelCase), source "custom" is preserved, and the // env round-trips end-to-end so a save-then-edit cycle cannot erase env. -const { - evaluateAgentReadiness, - evaluateAgentReadinessDraft, - fromRawAcpRuntimeCatalogEntry, -} = await import("./tauri.ts"); +const { evaluateAgentReadinessDraft, fromRawAcpRuntimeCatalogEntry } = + await import("./tauri.ts"); test("fromRawAcpRuntimeCatalogEntry maps definition_env to definitionEnv", () => { const raw = { @@ -263,30 +260,6 @@ test("fromRawAcpRuntimeCatalogEntry omits maxParallelism when max_parallelism is // ── Agent readiness command wrappers ────────────────────────────────────────── -test("evaluateAgentReadiness invokes the saved readiness command", async () => { - const calls = []; - const prior = globalThis.window.__TAURI_INTERNALS__; - globalThis.window.__TAURI_INTERNALS__ = { - invoke: async (command, args) => { - calls.push({ command, args }); - return { ready: false, requirements: [] }; - }, - }; - - try { - const result = await evaluateAgentReadiness("agent-pubkey"); - assert.deepStrictEqual(result, { ready: false, requirements: [] }); - assert.deepStrictEqual(calls, [ - { - command: "evaluate_agent_readiness", - args: { pubkey: "agent-pubkey" }, - }, - ]); - } finally { - globalThis.window.__TAURI_INTERNALS__ = prior; - } -}); - test("evaluateAgentReadinessDraft forwards draft overrides without persisting", async () => { const calls = []; const prior = globalThis.window.__TAURI_INTERNALS__; @@ -301,28 +274,25 @@ test("evaluateAgentReadinessDraft forwards draft overrides without persisting", }; try { - const result = await evaluateAgentReadinessDraft({ - pubkey: "agent-pubkey", - runtime: "buzz-agent", - provider: "anthropic", - model: "claude-opus-4-5", - envVars: { ANTHROPIC_API_KEY: "" }, - }); + const draft = { + kind: "existing", + config: { + pubkey: "agent-pubkey", + agentCommand: "buzz-agent", + harnessOverride: true, + provider: "anthropic", + model: "claude-opus-4-5", + envVars: { ANTHROPIC_API_KEY: "" }, + }, + }; + const result = await evaluateAgentReadinessDraft(draft); assert.deepStrictEqual(result.requirements, [ { surface: "env_key", key: "ANTHROPIC_API_KEY" }, ]); assert.deepStrictEqual(calls, [ { command: "evaluate_agent_readiness_draft", - args: { - draft: { - pubkey: "agent-pubkey", - runtime: "buzz-agent", - provider: "anthropic", - model: "claude-opus-4-5", - envVars: { ANTHROPIC_API_KEY: "" }, - }, - }, + args: { draft }, }, ]); } finally { diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 3b3cb9eedd..af5229718b 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -944,31 +944,13 @@ export async function getAgentConfigSurface( }); } -/** Evaluate a saved managed agent's backend-owned readiness requirements. */ -export async function evaluateAgentReadiness( - pubkey: string, -): Promise { - return invokeTauri("evaluate_agent_readiness", { - pubkey, - }); -} - -/** Evaluate unsaved config edits without persisting them. */ +/** Evaluate an unsaved new-agent config or exact saved-agent patch. */ export async function evaluateAgentReadinessDraft( draft: AgentReadinessDraft, ): Promise { - const payload: AgentReadinessDraft = {}; - if (draft.pubkey !== undefined) payload.pubkey = draft.pubkey; - if (draft.runtime !== undefined) payload.runtime = draft.runtime; - if (draft.agentCommand !== undefined) - payload.agentCommand = draft.agentCommand; - if (draft.model !== undefined) payload.model = draft.model; - if (draft.provider !== undefined) payload.provider = draft.provider; - if (draft.envVars !== undefined) payload.envVars = draft.envVars; - return invokeTauri( "evaluate_agent_readiness_draft", - { draft: payload }, + { draft }, ); } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index be99bd0c59..fa34334792 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -309,17 +309,29 @@ export type AgentReadinessEvaluation = { requirements: AgentReadinessRequirement[]; }; -export type AgentReadinessDraft = { - /** Saved agent baseline for edit drafts. Omit for create drafts. */ - pubkey?: string; - /** Runtime id override. null clears to inherited/default resolution. */ - runtime?: string | null; - /** Custom/raw command override. Prefer runtime for catalog-known harnesses. */ - agentCommand?: string | null; - model?: string | null; - provider?: string | null; - envVars?: Record; -}; +export type AgentReadinessDraft = + | { + kind: "new"; + config: { + /** Same authoritative harness command submitted by Create. */ + agentCommand?: string; + model?: string; + provider?: string; + envVars?: Record; + }; + } + | { + kind: "existing"; + config: { + pubkey: string; + /** Same command or inheritance sentinel submitted by Save. */ + agentCommand?: string; + harnessOverride?: boolean; + model?: string | null; + provider?: string | null; + envVars?: Record; + }; + }; export type ManagedAgentRuntimeStatus = { pubkey: string;