From 6e2950029e7018b644477f14fbcd9fff91862bd7 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 14:57:30 -0700 Subject: [PATCH] Preserve explicit hidden harness starts Signed-off-by: kenny lopez --- .../src-tauri/src/commands/agents_tests.rs | 19 +++++++++++ .../src/managed_agents/global_config/mod.rs | 12 +++---- .../src/managed_agents/global_config/tests.rs | 20 +++++++++++ desktop/src/features/agents/AGENTS.md | 5 +-- .../agents/lib/resolvePersonaRuntime.test.mjs | 23 +++++++++++++ .../agents/lib/resolvePersonaRuntime.ts | 13 ++++++++ .../agents/ui/AddTeamToChannelDialog.tsx | 33 ++++++++++++------- 7 files changed, 104 insertions(+), 21 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 3897c99d8..b1951f195 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -235,6 +235,25 @@ fn deploy_resolver_ignores_defaults_from_a_different_implicit_runtime() { ); } +#[test] +fn deploy_resolver_ignores_mismatched_defaults_without_a_stored_override() { + let mut record = bare_agent_record(Some("p1"), None, None); + record.agent_command = "buzz-agent".to_string(); + record.agent_command_override = None; + let personas = vec![persona_record("p1", None, None)]; + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-provider".to_string()), + preferred_runtime: Some("goose".to_string()), + ..Default::default() + }; + + assert_eq!( + resolve_deploy_model_provider(&record, &personas, &global), + (None, None) + ); +} + #[test] fn normalize_relay_mesh_rejects_empty_model_ref() { let config = RelayMeshConfig { diff --git a/desktop/src-tauri/src/managed_agents/global_config/mod.rs b/desktop/src-tauri/src/managed_agents/global_config/mod.rs index 4d4d51423..00ee5950b 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -235,19 +235,17 @@ pub(crate) fn global_model_provider_for_record<'a>( return global_values; } - let Some(selected_runtime) = record - .agent_command_override + let Some(preferred_runtime) = global + .preferred_runtime .as_deref() .and_then(crate::managed_agents::known_acp_runtime) else { return global_values; }; - let preferred_runtime = global - .preferred_runtime - .as_deref() - .and_then(crate::managed_agents::known_acp_runtime); + let selected_command = crate::managed_agents::record_agent_command(record, personas); + let selected_runtime = crate::managed_agents::known_acp_runtime(&selected_command); - if preferred_runtime.is_some_and(|preferred| std::ptr::eq(preferred, selected_runtime)) { + if selected_runtime.is_some_and(|selected| std::ptr::eq(preferred_runtime, selected)) { global_values } else { (None, None) diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 5f50cdca4..a5e6d35d9 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -491,6 +491,26 @@ fn runtime_fallback_does_not_inherit_defaults_from_a_different_preferred_runtime ); } +#[test] +fn default_command_fallback_does_not_inherit_defaults_from_another_preferred_runtime() { + let mut record = bare_record(); + record.persona_id = Some("p1".to_string()); + record.agent_command = "buzz-agent".to_string(); + record.agent_command_override = None; + let personas = vec![persona("p1", None, None)]; + let global = GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-provider".to_string()), + preferred_runtime: Some("goose".to_string()), + ..Default::default() + }; + + assert_eq!( + resolve_effective_model_provider(&record, &personas, &global), + (None, None) + ); +} + #[test] fn runtime_fallback_inherits_defaults_when_it_matches_the_preferred_runtime() { let mut record = bare_record(); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 54361cf66..25a681341 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -99,8 +99,9 @@ with a TypeScript lookup table or an id comparison in a component. `resolveStartRuntimeForDefinition` is the shared boundary that filters every runtime-less start through the device's visible-runtime set; call it instead of duplicating default selection in a start surface. Team deploys use the - same visible-runtime set. Definitions already pinned to a hidden runtime - remain runnable. + same visible-runtime set only for members that need an implicit fallback, so + a fully pinned team remains deployable even when every installed runtime is + hidden. Definitions already pinned to a hidden runtime remain runnable. 10. **The defaults modal is progressively disclosed.** An unset global config starts on the Buzz Agent-first deployment fallback and carries that visible harness into the next saved edit. The `progressive-defaults` disclosure diff --git a/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs index 37796b8bb..f77f056d6 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + canResolveAllPersonaRuntimes, collectRuntimeWarnings, resolvePersonaRuntime, } from "./resolvePersonaRuntime.ts"; @@ -177,3 +178,25 @@ test("collectRuntimeWarnings — override=false behaves identically to no overri test("collectRuntimeWarnings — empty personas array always returns empty", () => { assert.deepEqual(collectRuntimeWarnings([], runtimes, goose, true), []); }); + +test("team resolution allows explicit runtimes without a fallback", () => { + assert.equal( + canResolveAllPersonaRuntimes( + [{ runtime: "goose" }, { runtime: "claude" }], + runtimes, + null, + ), + true, + ); +}); + +test("team resolution requires a fallback for runtime-less definitions", () => { + assert.equal( + canResolveAllPersonaRuntimes([{ runtime: null }], runtimes, null), + false, + ); + assert.equal( + canResolveAllPersonaRuntimes([{ runtime: null }], runtimes, goose), + true, + ); +}); diff --git a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts index f833d7113..474c218f6 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts @@ -142,3 +142,16 @@ export function collectRuntimeWarnings( } return warnings; } + +/** Whether every definition can resolve with the supplied optional fallback. */ +export function canResolveAllPersonaRuntimes( + personas: readonly { runtime: string | null }[], + runtimes: readonly AcpRuntime[], + fallbackRuntime: AcpRuntime | null, +): boolean { + return personas.every( + (persona) => + resolvePersonaRuntime(persona.runtime, runtimes, fallbackRuntime) + .runtime !== null, + ); +} diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index c105389ae..62475819a 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -14,6 +14,7 @@ import { import { shouldPinSelectedRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition"; import { useSelectableAcpRuntimes } from "@/features/agents/lib/runtimeVisibilityPreference"; import { + canResolveAllPersonaRuntimes, collectRuntimeWarnings, getDefaultPersonaRuntime, resolvePersonaRuntime, @@ -86,6 +87,11 @@ export function AddTeamToChannelDialog({ ); const resolved = teamPersonaResolution.resolvedPersonas; const missingPersonaCount = teamPersonaResolution.missingPersonaCount; + const canResolveTeamRuntimes = canResolveAllPersonaRuntimes( + resolved, + runtimes, + defaultProvider, + ); // Surface warnings when a persona's preferred runtime is unavailable. // This dialog has no runtime selector, so the fallback is always @@ -121,7 +127,7 @@ export function AddTeamToChannelDialog({ channels.find((channel) => channel.id === channelId) ?? null; async function handleDeploy() { - if (!team || !selectedChannel || !defaultProvider) { + if (!team || !selectedChannel || !canResolveTeamRuntimes) { return; } @@ -136,21 +142,23 @@ export function AddTeamToChannelDialog({ runtimes, defaultProvider, ); - const runtimeToUse = personaRuntime ?? defaultProvider; + if (!personaRuntime) { + throw new Error("No runtime is available for this team member."); + } return { runtime: { - id: runtimeToUse.id, - label: runtimeToUse.label, - command: runtimeToUse.command, - defaultArgs: runtimeToUse.defaultArgs, - mcpCommand: runtimeToUse.mcpCommand, + id: personaRuntime.id, + label: personaRuntime.label, + command: personaRuntime.command, + defaultArgs: personaRuntime.defaultArgs, + mcpCommand: personaRuntime.mcpCommand, }, name: persona.displayName, systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, harnessOverride: shouldPinSelectedRuntimeForDefinition( persona.runtime, - runtimeToUse.id, + personaRuntime.id, ), model: persona.model ?? undefined, personaId: persona.id, @@ -262,10 +270,11 @@ export function AddTeamToChannelDialog({

) : null} - {!defaultProvider && !providersQuery.isLoading ? ( + {!canResolveTeamRuntimes && !providersQuery.isLoading ? (

- No ACP runtimes found. Make sure an agent runtime (e.g. Goose) - is installed. + {runtimes.length === 0 + ? "No ACP runtimes found. Make sure an agent runtime (e.g. Goose) is installed." + : "No enabled fallback runtime is available. Turn on a harness to deploy runtime-less team members."}

) : null} @@ -307,7 +316,7 @@ export function AddTeamToChannelDialog({ disabled={ !team || !selectedChannel || - !defaultProvider || + !canResolveTeamRuntimes || resolved.length === 0 || missingPersonaCount > 0 || channelsQuery.isLoading ||