diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6814008f0d..75f982242b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -55,10 +55,10 @@ use huddle::{ set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline, }; use managed_agents::{ - backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes, - put_managed_agent_runtime_lifecycle, reconcile_managed_agent_runtimes, - restart_managed_agent_runtime, start_managed_agent_runtime, stop_managed_agent_runtime, - try_regenerate_nest, + backfill_persona_snapshots, ensure_nest, get_managed_agent_readiness, + list_managed_agent_runtimes, put_managed_agent_runtime_lifecycle, + reconcile_managed_agent_runtimes, restart_managed_agent_runtime, start_managed_agent_runtime, + stop_managed_agent_runtime, try_regenerate_nest, }; #[cfg(not(feature = "mesh-llm"))] use mesh_llm_stubs::*; @@ -801,6 +801,7 @@ pub fn run() { resolve_oa_owner, list_relay_agents, list_managed_agents, + get_managed_agent_readiness, list_managed_agent_runtimes, start_managed_agent_runtime, stop_managed_agent_runtime, @@ -948,7 +949,6 @@ pub fn run() { #[cfg(unix)] shutdown::install_signal_handler(app.handle().clone(), Arc::clone(&shutdown_done)); - let run_shutdown_done = Arc::clone(&shutdown_done); let restart_requested = Arc::new(AtomicBool::new(false)); app.run(move |app_handle, event| match event { diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index fa8eb36fa1..64f2d98cb4 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -163,8 +163,14 @@ pub(crate) fn resolve_effective_harness_descriptor( // Env: full layered resolution (same as resolve_effective_agent_env). // Pass harness_def directly to avoid a second lookup. - let effective_env = - resolve_effective_agent_env_with_def(record, personas, runtime_meta, global, harness_def); + let effective_env = resolve_effective_agent_env_with_def( + record, + personas, + runtime_meta, + global, + harness_def, + baked_build_env(), + ); Ok(EffectiveHarnessDescriptor { command: effective_command, @@ -183,11 +189,12 @@ pub(crate) fn resolve_effective_harness_descriptor( /// * `runtime` — the `KnownAcpRuntime` for the effective command, if any /// * `global` — global agent config defaults (lowest user layer; pass /// `&GlobalAgentConfig::default()` in tests that don't need global config) -pub(crate) fn resolve_effective_agent_env( +fn effective_agent_env_with_floor( record: &ManagedAgentRecord, personas: &[AgentDefinition], runtime: Option<&KnownAcpRuntime>, global: &GlobalAgentConfig, + env: BTreeMap, ) -> EffectiveAgentEnv { // Look up the harness definition for definition-level env (preset/custom). // Same resolution logic as spawn_agent_child: record runtime id first, then @@ -208,7 +215,16 @@ pub(crate) fn resolve_effective_agent_env( crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) }; - resolve_effective_agent_env_with_def(record, personas, runtime, global, harness_def) + resolve_effective_agent_env_with_def(record, personas, runtime, global, harness_def, env) +} + +pub(crate) fn resolve_effective_agent_env( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + runtime: Option<&KnownAcpRuntime>, + global: &GlobalAgentConfig, +) -> EffectiveAgentEnv { + effective_agent_env_with_floor(record, personas, runtime, global, baked_build_env()) } /// Inner implementation that accepts a pre-fetched `harness_def` to avoid a @@ -220,11 +236,12 @@ fn resolve_effective_agent_env_with_def( runtime: Option<&KnownAcpRuntime>, global: &GlobalAgentConfig, harness_def: Option>, + mut env: BTreeMap, ) -> EffectiveAgentEnv { let effective_command = crate::managed_agents::record_agent_command(record, personas); - // Layer 1: baked build defaults (floor — internal builds only; OSS = empty). - let mut env = baked_build_env(); + // Layer 1 is supplied by the caller. Production uses baked build defaults; + // tests can inject the exact release floor without relying on option_env!. let (effective_model, effective_provider) = super::global_config::resolve_effective_model_provider(record, personas, global); @@ -1547,194 +1564,19 @@ mod tests { Some("claude-opus-4-5") ); } +} - // ── provider-specific model fallback tests ──────────────────────────── - - #[test] - fn buzz_agent_databricks_v2_with_databricks_model_but_no_buzz_agent_model_is_ready() { - // The baked buzz-releases env sets DATABRICKS_MODEL but not BUZZ_AGENT_MODEL. - // An agent with only DATABRICKS_MODEL must pass the readiness gate. - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "databricks_v2"), - ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), - ("DATABRICKS_HOST", "https://dbc.example.com"), - ]), - ); - assert!( - agent_readiness(&env).is_ready(), - "DATABRICKS_MODEL must satisfy the model requirement for databricks_v2" - ); - } - - #[test] - fn buzz_agent_databricks_v2_hyphen_alias_with_databricks_model_is_ready() { - // buzz-agent accepts both "databricks_v2" and "databricks-v2". The - // readiness gate must recognize the hyphen alias and accept DATABRICKS_MODEL. - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "databricks-v2"), - ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), - ("DATABRICKS_HOST", "https://dbc.example.com"), - ]), - ); - assert!( - agent_readiness(&env).is_ready(), - "databricks-v2 alias with DATABRICKS_MODEL must be Ready" - ); - } - - #[test] - fn buzz_agent_databricks_hyphen_alias_missing_host_returns_not_ready() { - // The hyphen alias "databricks-v2" requires DATABRICKS_HOST just like - // the underscore variants. Without it the agent cannot reach the endpoint. - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "databricks-v2"), - ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), - // DATABRICKS_HOST intentionally absent - ]), - ); - let result = agent_readiness(&env); - assert!( - !result.is_ready(), - "databricks-v2 without DATABRICKS_HOST must be NotReady" - ); - let reqs = result.requirements(); - assert!( - reqs.iter() - .any(|r| matches!(r, Requirement::EnvKey { key } if key == "DATABRICKS_HOST")), - "missing requirements must include DATABRICKS_HOST; got {reqs:?}" - ); - } - - #[test] - fn buzz_agent_databricks_v1_with_databricks_model_but_no_buzz_agent_model_is_ready() { - // V1 (Model Serving) also resolves DATABRICKS_MODEL — same fallback applies. - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "databricks"), - ("DATABRICKS_MODEL", "dbrx-instruct"), - ("DATABRICKS_HOST", "https://dbc.example.com"), - ]), - ); - assert!( - agent_readiness(&env).is_ready(), - "DATABRICKS_MODEL must satisfy the model requirement for databricks (V1)" - ); - } - - #[test] - fn buzz_agent_anthropic_with_anthropic_model_but_no_buzz_agent_model_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "anthropic"), - ("ANTHROPIC_MODEL", "claude-opus-4-5"), - ("ANTHROPIC_API_KEY", "sk-test"), - ]), - ); - assert!( - agent_readiness(&env).is_ready(), - "ANTHROPIC_MODEL must satisfy the model requirement for anthropic" - ); - } - - #[test] - fn buzz_agent_openai_with_openai_compat_model_but_no_buzz_agent_model_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openai"), - ("OPENAI_COMPAT_MODEL", "gpt-4o"), - ("OPENAI_COMPAT_API_KEY", "sk-test"), - ]), - ); - assert!( - agent_readiness(&env).is_ready(), - "OPENAI_COMPAT_MODEL must satisfy the model requirement for openai" - ); - } - - #[test] - fn buzz_agent_empty_provider_model_fallback_key_is_not_ready() { - // An empty DATABRICKS_MODEL with no BUZZ_AGENT_MODEL must still be NotReady. - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "databricks_v2"), - ("DATABRICKS_MODEL", ""), - ("DATABRICKS_HOST", "https://dbc.example.com"), - ]), - ); - let result = agent_readiness(&env); - assert!( - !result.is_ready(), - "empty DATABRICKS_MODEL with no BUZZ_AGENT_MODEL must be NotReady" - ); - assert!(result - .requirements() - .contains(&Requirement::NormalizedField { - field: "model".to_string() - })); - } - - // ── OpenRouter readiness ───────────────────────────────────────────── - - #[test] - fn buzz_agent_openrouter_with_all_fields_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "openrouter with all fields should be ready" - ); - } - - #[test] - fn buzz_agent_openrouter_missing_key_returns_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ]), - ); - let result = agent_readiness(&env); - assert!(!result.is_ready()); - assert!(result.requirements().contains(&Requirement::EnvKey { - key: "OPENROUTER_API_KEY".to_string() - })); - } +// Provider fallback readiness tests live in a sibling file so this module +// stays under the desktop file-size ratchet. +#[cfg(test)] +#[path = "readiness_provider_fallback_tests.rs"] +mod provider_fallback_tests; - #[test] - fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "OPENROUTER_MODEL fallback should satisfy model requirement" - ); - } -} +// Release-floor layering regression tests live in a sibling file so this +// module stays under the desktop file-size ratchet. +#[cfg(test)] +#[path = "readiness_release_floor_tests.rs"] +mod release_floor_tests; // Goose file-config-aware requirement tests live in a sibling file so this // module stays under the desktop file-size ratchet. diff --git a/desktop/src-tauri/src/managed_agents/readiness_provider_fallback_tests.rs b/desktop/src-tauri/src/managed_agents/readiness_provider_fallback_tests.rs new file mode 100644 index 0000000000..83cf347fb3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness_provider_fallback_tests.rs @@ -0,0 +1,209 @@ +//! Provider-specific model fallback readiness tests. + +use std::collections::BTreeMap; + +use super::*; +use crate::managed_agents::discovery::known_acp_runtime_exact; + +fn make_env(command: &str, env: BTreeMap) -> EffectiveAgentEnv { + let runtime = known_acp_runtime_exact(command); + EffectiveAgentEnv { + env, + config_file_path: runtime.and_then(|runtime| runtime.config_file_path), + effective_command: command.to_string(), + } +} + +fn env_with(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() +} + +// ── provider-specific model fallback tests ──────────────────────────── + +#[test] +fn buzz_agent_databricks_v2_with_databricks_model_but_no_buzz_agent_model_is_ready() { + // The baked buzz-releases env sets DATABRICKS_MODEL but not BUZZ_AGENT_MODEL. + // An agent with only DATABRICKS_MODEL must pass the readiness gate. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks_v2"), + ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), + ("DATABRICKS_HOST", "https://dbc.example.com"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "DATABRICKS_MODEL must satisfy the model requirement for databricks_v2" + ); +} + +#[test] +fn buzz_agent_databricks_v2_hyphen_alias_with_databricks_model_is_ready() { + // buzz-agent accepts both "databricks_v2" and "databricks-v2". The + // readiness gate must recognize the hyphen alias and accept DATABRICKS_MODEL. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks-v2"), + ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), + ("DATABRICKS_HOST", "https://dbc.example.com"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "databricks-v2 alias with DATABRICKS_MODEL must be Ready" + ); +} + +#[test] +fn buzz_agent_databricks_hyphen_alias_missing_host_returns_not_ready() { + // The hyphen alias "databricks-v2" requires DATABRICKS_HOST just like + // the underscore variants. Without it the agent cannot reach the endpoint. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks-v2"), + ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), + // DATABRICKS_HOST intentionally absent + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "databricks-v2 without DATABRICKS_HOST must be NotReady" + ); + let reqs = result.requirements(); + assert!( + reqs.iter() + .any(|r| matches!(r, Requirement::EnvKey { key } if key == "DATABRICKS_HOST")), + "missing requirements must include DATABRICKS_HOST; got {reqs:?}" + ); +} + +#[test] +fn buzz_agent_databricks_v1_with_databricks_model_but_no_buzz_agent_model_is_ready() { + // V1 (Model Serving) also resolves DATABRICKS_MODEL — same fallback applies. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("DATABRICKS_MODEL", "dbrx-instruct"), + ("DATABRICKS_HOST", "https://dbc.example.com"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "DATABRICKS_MODEL must satisfy the model requirement for databricks (V1)" + ); +} + +#[test] +fn buzz_agent_anthropic_with_anthropic_model_but_no_buzz_agent_model_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("ANTHROPIC_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "ANTHROPIC_MODEL must satisfy the model requirement for anthropic" + ); +} + +#[test] +fn buzz_agent_openai_with_openai_compat_model_but_no_buzz_agent_model_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openai"), + ("OPENAI_COMPAT_MODEL", "gpt-4o"), + ("OPENAI_COMPAT_API_KEY", "sk-test"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "OPENAI_COMPAT_MODEL must satisfy the model requirement for openai" + ); +} + +#[test] +fn buzz_agent_empty_provider_model_fallback_key_is_not_ready() { + // An empty DATABRICKS_MODEL with no BUZZ_AGENT_MODEL must still be NotReady. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks_v2"), + ("DATABRICKS_MODEL", ""), + ("DATABRICKS_HOST", "https://dbc.example.com"), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty DATABRICKS_MODEL with no BUZZ_AGENT_MODEL must be NotReady" + ); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "model".to_string() + })); +} + +// ── OpenRouter readiness ───────────────────────────────────────────── + +#[test] +fn buzz_agent_openrouter_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "openrouter with all fields should be ready" + ); +} + +#[test] +fn buzz_agent_openrouter_missing_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "OPENROUTER_API_KEY".to_string() + })); +} + +#[test] +fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "OPENROUTER_MODEL fallback should satisfy model requirement" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/readiness_release_floor_tests.rs b/desktop/src-tauri/src/managed_agents/readiness_release_floor_tests.rs new file mode 100644 index 0000000000..286fd3d7f1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness_release_floor_tests.rs @@ -0,0 +1,38 @@ +use std::collections::BTreeMap; + +use super::{agent_readiness, effective_agent_env_with_floor}; +use crate::managed_agents::discovery::known_acp_runtime_exact; + +#[test] +fn empty_persisted_config_is_ready_with_baked_databricks_release_defaults() { + let record: crate::managed_agents::types::ManagedAgentRecord = serde_json::from_str(&format!( + r#"{{ + "pubkey": "{}", + "name": "fresh-starter", + "relay_url": "", + "acp_command": "buzz-acp", + "agent_command": "buzz-agent", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }}"#, + "aa".repeat(32) + )) + .unwrap(); + let baked = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".into(), "databricks_v2".into()), + ("DATABRICKS_HOST".into(), "https://dbc.example.com".into()), + ("DATABRICKS_MODEL".into(), "goose-claude-4-6-sonnet".into()), + ]); + let runtime = known_acp_runtime_exact("buzz-agent"); + + let effective = + effective_agent_env_with_floor(&record, &[], runtime, &Default::default(), baked); + + assert!(agent_readiness(&effective).is_ready()); + assert!(record.provider.is_none()); + assert!(record.model.is_none()); + assert!(record.env_vars.is_empty()); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b1..a722219d5c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -11,7 +11,7 @@ use super::{ ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle, ManagedAgentRuntimeReceipt, ManagedAgentRuntimeStatus, }; -use crate::app_state::AppState; +use crate::{app_state::AppState, managed_agents::readiness::EffectiveAgentEnv}; const STATUS_EVENT: &str = "managed-agent-runtime-status"; @@ -137,6 +137,39 @@ pub fn put_managed_agent_runtime_lifecycle( Ok(status) } +fn readiness_for_record( + record: &super::ManagedAgentRecord, + personas: &[super::AgentDefinition], + global: &super::GlobalAgentConfig, +) -> Result { + let descriptor = super::resolve_effective_harness_descriptor(record, personas, global)?; + let metadata = super::known_acp_runtime(&descriptor.command); + let effective = EffectiveAgentEnv { + env: descriptor.env, + config_file_path: metadata.and_then(|runtime| runtime.config_file_path), + effective_command: descriptor.command, + }; + Ok(agent_readiness(&effective)) +} + +/// Return backend-authoritative readiness for the exact managed instance the +/// caller intends to start. This follows the same runtime and layered env +/// resolution as spawning, including baked release defaults. +#[tauri::command] +pub fn get_managed_agent_readiness( + pubkey: String, + app: AppHandle, +) -> Result { + let records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|record| record.pubkey.eq_ignore_ascii_case(&pubkey)) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + let personas = load_personas(&app).unwrap_or_default(); + let global = load_global_agent_config(&app).unwrap_or_default(); + readiness_for_record(record, &personas, &global) +} + #[tauri::command] pub fn list_managed_agent_runtimes( app: AppHandle, @@ -606,6 +639,45 @@ mod tests { .unwrap() } + fn record_with_command(command: &str) -> super::super::ManagedAgentRecord { + let mut record = record_with_relay(""); + record.agent_command_override = Some(command.to_string()); + record + } + + #[test] + fn readiness_is_evaluated_for_the_selected_instance_runtime() { + let selected = record_with_command("buzz-agent"); + let unrelated_ready_command = std::env::current_exe() + .expect("current test executable must be available") + .to_string_lossy() + .into_owned(); + let unrelated_ready_runtime = record_with_command(&unrelated_ready_command); + let global = super::super::GlobalAgentConfig::default(); + + assert!(matches!( + readiness_for_record(&selected, &[], &global), + Ok(AgentReadiness::NotReady { .. }) + )); + assert!(matches!( + readiness_for_record(&unrelated_ready_runtime, &[], &global), + Ok(AgentReadiness::Ready) + )); + } + + #[test] + fn readiness_rejects_a_dangling_selected_runtime_like_spawn() { + let mut selected = record_with_command("buzz-agent"); + selected.agent_command_override = None; + selected.runtime = Some("removed-harness".to_string()); + + let error = + readiness_for_record(&selected, &[], &super::super::GlobalAgentConfig::default()) + .expect_err("dangling runtime must not fall through to a default command"); + + assert_eq!(error, "DANGLING_HARNESS_ID:removed-harness"); + } + #[test] fn legacy_relay_pin_is_ignored_for_fan_out() { // Zero-touch cutover (#2122): a record carrying a creation-era diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts index 57352b5294..b51cd19fa8 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.ts +++ b/desktop/src/features/onboarding/welcomeKickoff.ts @@ -9,7 +9,6 @@ import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks"; import { useCommunities } from "@/features/communities/useCommunities"; import { welcomeKickoffMarker } from "@/features/onboarding/devFreshOnboarding"; -import { resolveAgentReadiness } from "@/features/onboarding/ui/agentReadiness"; import { ensureWelcomeTeam, pickWelcomeTeamStarterAgentForRelay, @@ -20,6 +19,7 @@ import { isWelcomeChannel } from "@/features/onboarding/welcome"; import { getThreadReference } from "@/features/messages/lib/threading"; import { useThreadReplies } from "@/features/messages/useThreadReplies"; import { + getManagedAgentReadiness, startManagedAgent, stopManagedAgent, } from "@/shared/api/tauriManagedAgents"; @@ -496,7 +496,7 @@ export function useWelcomeKickoff( const { activeCommunity } = useCommunities(); const runtimesQuery = useAcpRuntimesQuery(); const managedAgentsQuery = useManagedAgentsQuery(); - const { globalConfig, isLoading: configLoading } = useGlobalAgentConfig(); + const { isLoading: configLoading } = useGlobalAgentConfig(); const channelId = activeChannel?.id ?? null; const isActiveWelcome = isWelcomeChannel(activeChannel); const focusedWelcomeChannelRef = React.useRef(null); @@ -547,10 +547,6 @@ export function useWelcomeKickoff( ), [activeCommunity?.relayUrl, managedAgentsQuery.data], ); - const readiness = React.useMemo( - () => resolveAgentReadiness(runtimesQuery.data ?? [], globalConfig), - [globalConfig, runtimesQuery.data], - ); React.useEffect(() => { if ( !channelId || @@ -583,7 +579,10 @@ export function useWelcomeKickoff( if (await markerExists(channelId, closerMarker)) { return; } - if (!readiness.ready) { + const readiness = await getManagedAgentReadiness( + resolvedAgentSet.lead.pubkey, + ); + if (readiness.status !== "ready") { await sendManagedAgentChannelMessage({ agentPubkey: resolvedAgentSet.lead.pubkey, channelId, @@ -692,7 +691,6 @@ export function useWelcomeKickoff( isActiveWelcome, onKickoffOpenerPosted, queryClient, - readiness, runtimesQuery.isPending, ]); diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index c74b099f88..7cfb0edae0 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -50,6 +50,16 @@ export async function setManagedAgentAutoRestart( return fromRawManagedAgent(response); } +export type ManagedAgentReadiness = + | { status: "ready" } + | { status: "not_ready"; requirements: unknown[] }; + +export async function getManagedAgentReadiness( + pubkey: string, +): Promise { + return invokeTauri("get_managed_agent_readiness", { pubkey }); +} + export async function listManagedAgentRuntimes(): Promise< ManagedAgentRuntimeStatus[] > { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 818144415a..56bd33612d 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11404,6 +11404,14 @@ export function maybeInstallE2eTauriMocks() { return handleStopManagedAgent( payload as Parameters[0], ); + case "get_managed_agent_readiness": { + const { pubkey } = payload as { pubkey: string }; + const agent = mockManagedAgents.find( + (candidate) => candidate.pubkey === pubkey, + ); + if (!agent) throw new Error(`agent ${pubkey} not found`); + return { status: "ready" }; + } case "list_managed_agent_runtimes": return mockManagedAgentRuntimes.map((row) => ({ ...row })); case "start_managed_agent_runtime": diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 0c98cb411e..cb20a66557 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -2847,19 +2847,11 @@ test("successful starter channel retry clears its actionable toast", async ({ await expectStarterChannels(page); }); -test("first-run onboarding posts the live Fizz kickoff", async ({ page }) => { +test("first-run onboarding with empty persisted agent config posts the live Fizz kickoff", async ({ + page, +}) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); - await installMockBridge( - page, - { - globalAgentConfig: { - env_vars: { OPENAI_API_KEY: "e2e-placeholder" }, - provider: "openai", - model: "gpt-5.5", - }, - }, - { skipOnboardingSeed: true }, - ); + await installMockBridge(page, undefined, { skipOnboardingSeed: true }); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); @@ -2874,6 +2866,7 @@ test("first-run onboarding posts the live Fizz kickoff", async ({ page }) => { await expect(page.getByTestId("message-timeline")).toContainText( "Honey and Bumble, introduce yourselves", ); + expect(await commandCount(page, "get_managed_agent_readiness")).toBe(1); }); test("first-run onboarding lands before Welcome team bootstrap completes", async ({