diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 25a681341..1ab4ddf65 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -96,12 +96,14 @@ with a TypeScript lookup table or an id comparison in a component. editor persists its visible fallback on the next save. Its dependent provider/model defaults are also ignored for new implicit fallback agents, without changing the persisted configuration used by existing agents. - `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 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. + `resolvePersonaRuntime` is the shared visibility boundary for every + runtime-less deployment or provisioning path. Definition-to-instance starts + use the stricter `resolveStartRuntimeForDefinition` wrapper; call one of + these shared resolvers instead of duplicating default selection in a start + surface. Team deploys use the 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 f77f056d6..6a5b66a5e 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs @@ -33,6 +33,46 @@ test("resolvePersonaRuntime — undefined personaRuntimeId also returns defaultR }); }); +test("resolvePersonaRuntime — hidden defaults are skipped for runtime-less personas", () => { + const result = resolvePersonaRuntime(null, runtimes, goose, false, ["goose"]); + assert.deepEqual(result, { + runtime: claude, + warnings: [], + isOverridden: false, + }); +}); + +test("resolvePersonaRuntime — explicitly pinned hidden runtimes remain available", () => { + const result = resolvePersonaRuntime("goose", runtimes, claude, false, [ + "goose", + ]); + assert.deepEqual(result, { + runtime: goose, + warnings: [], + isOverridden: false, + }); +}); + +test("resolvePersonaRuntime — hidden fallback is replaced when a pinned runtime is unavailable", () => { + const result = resolvePersonaRuntime("unknown-rt", runtimes, goose, false, [ + "goose", + ]); + assert.equal(result.runtime, claude); + assert.equal(result.warnings.length, 1); + assert.match(result.warnings[0], /Claude/); + assert.equal(result.isOverridden, true); +}); + +test("resolvePersonaRuntime — no implicit fallback remains when every runtime is hidden", () => { + const result = resolvePersonaRuntime(null, runtimes, goose, false, [ + "goose", + "claude", + ]); + assert.equal(result.runtime, null); + assert.equal(result.warnings.length, 1); + assert.equal(result.isOverridden, false); +}); + test("resolvePersonaRuntime — no personaRuntimeId and no defaultRuntime returns null with warning", () => { const result = resolvePersonaRuntime(null, runtimes, null); assert.equal(result.runtime, null); diff --git a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts index 474c218f6..3b09e5b4f 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts @@ -1,4 +1,8 @@ import type { AcpRuntime, AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { + filterEnabledAcpRuntimes, + getDisabledAcpRuntimeIdsSnapshot, +} from "./runtimeVisibilityPreference"; /** * Select the best default runtime from a catalog, using the same preference @@ -55,18 +59,31 @@ export type ResolvePersonaRuntimeResult = { * fall back to `defaultRuntime` and emit a warning. * 4. If there is no `defaultRuntime` either → return `null` with an error * warning so the UI can block deployment. + * + * Hidden runtimes remain eligible when explicitly pinned by a persona. They + * are removed only from the implicit fallback set, at this shared boundary, + * so every provisioning surface observes the device visibility preference. */ export function resolvePersonaRuntime( personaRuntimeId: string | undefined | null, runtimes: readonly AcpRuntime[], defaultRuntime: AcpRuntime | null, forceOverride?: boolean, + disabledRuntimeIds: readonly string[] = getDisabledAcpRuntimeIdsSnapshot(), ): ResolvePersonaRuntimeResult { + const implicitDefaultRuntime = forceOverride + ? defaultRuntime + : resolveVisibleDefaultRuntime( + runtimes, + defaultRuntime, + disabledRuntimeIds, + ); + // Case 1: Persona has no runtime preference — use the default. if (!personaRuntimeId) { return { - runtime: defaultRuntime, - warnings: defaultRuntime + runtime: implicitDefaultRuntime, + warnings: implicitDefaultRuntime ? [] : [ "No agent runtimes are available. Install a runtime (e.g. Goose) to deploy agents.", @@ -78,28 +95,35 @@ export function resolvePersonaRuntime( // Case 2: Persona's preferred runtime is available. const matched = runtimes.find((p) => p.id === personaRuntimeId); if (matched) { - if (forceOverride && defaultRuntime && matched.id !== defaultRuntime.id) { + if ( + forceOverride && + implicitDefaultRuntime && + matched.id !== implicitDefaultRuntime.id + ) { return { - runtime: defaultRuntime, + runtime: implicitDefaultRuntime, warnings: [ - `Runtime override: using ${defaultRuntime.label} instead of ${matched.label}.`, + `Runtime override: using ${implicitDefaultRuntime.label} instead of ${matched.label}.`, ], isOverridden: true, }; } return { - runtime: forceOverride && defaultRuntime ? defaultRuntime : matched, + runtime: + forceOverride && implicitDefaultRuntime + ? implicitDefaultRuntime + : matched, warnings: [], isOverridden: false, }; } // Case 3 & 4: Persona's runtime is not available — fall back. - if (defaultRuntime) { + if (implicitDefaultRuntime) { return { - runtime: defaultRuntime, + runtime: implicitDefaultRuntime, warnings: [ - `This agent is configured for runtime "${personaRuntimeId}" but it is not available. Using ${defaultRuntime.label} instead.`, + `This agent is configured for runtime "${personaRuntimeId}" but it is not available. Using ${implicitDefaultRuntime.label} instead.`, ], isOverridden: true, }; @@ -114,6 +138,24 @@ export function resolvePersonaRuntime( }; } +function resolveVisibleDefaultRuntime( + runtimes: readonly AcpRuntime[], + defaultRuntime: AcpRuntime | null, + disabledRuntimeIds: readonly string[], +): AcpRuntime | null { + if (!defaultRuntime) return null; + + const visibleRuntimes = filterEnabledAcpRuntimes( + runtimes, + disabledRuntimeIds, + ); + return ( + visibleRuntimes.find((runtime) => runtime.id === defaultRuntime.id) ?? + visibleRuntimes[0] ?? + null + ); +} + /** * Collect runtime-resolution warnings for a list of personas. *