From 5172532b6ba3b07d31b0f93e7a1f3c05b1205fb7 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 29 May 2026 14:00:29 -0400 Subject: [PATCH] feat: consume runtime metadata for env injection and overhaul runtime dropdown UX Replace hardcoded GOOSE_MODE injection with a metadata-driven default_env loop on KnownAcpRuntime, and inject GOOSE_MODEL via model_env_var for runtimes that don't support ACP model switching. This ensures goose agents respect PersonaRecord.model even without a persona pack. Redesign the AddChannelBotDialog runtime dropdown: defaults to "Use persona defaults" (unset) instead of auto-selecting the first runtime. Explicit selection overrides all personas. Per-persona runtime badges on chips show the effective runtime with warning styling when overridden or when a stored preference is unavailable. --- .../src-tauri/src/commands/agent_models.rs | 15 +- .../src-tauri/src/managed_agents/discovery.rs | 4 - .../src-tauri/src/managed_agents/runtime.rs | 21 +- .../agents/lib/resolvePersonaRuntime.test.mjs | 207 ++++++++++++++++++ .../agents/lib/resolvePersonaRuntime.ts | 31 ++- .../channels/ui/AddChannelBotDialog.tsx | 90 ++++---- .../ui/AddChannelBotPersonasSection.tsx | 31 +++ .../channels/ui/useEffectiveRuntimes.ts | 51 +++++ 8 files changed, 388 insertions(+), 62 deletions(-) create mode 100644 desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs create mode 100644 desktop/src/features/channels/ui/useEffectiveRuntimes.ts diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index dc1ab6059..e6e8f0c06 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -7,7 +7,7 @@ use crate::{ app_state::AppState, managed_agents::{ build_managed_agent_summary, default_agent_workdir, find_managed_agent_mut, - load_managed_agents, managed_agent_avatar_url, missing_command_message, + known_acp_runtime, load_managed_agents, managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse, DEFAULT_MCP_COMMAND, @@ -84,11 +84,14 @@ pub async fn get_agent_models( cmd.arg("models") .arg("--json") .env("SPROUT_ACP_AGENT_COMMAND", &agent_command) - .env("SPROUT_ACP_AGENT_ARGS", agent_args.join(",")) - .env( - "GOOSE_MODE", - std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".into()), - ); + .env("SPROUT_ACP_AGENT_ARGS", agent_args.join(",")); + if let Some(meta) = known_acp_runtime(&agent_command) { + for (key, value) in meta.default_env { + if std::env::var(key).is_err() { + cmd.env(key, value); + } + } + } // User env layering — written LAST so it overrides any Sprout-set env above. for (k, v) in &merged_env { cmd.env(k, v); diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index dd95a76dc..9b8070ab6 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -34,16 +34,12 @@ pub(crate) struct KnownAcpRuntime { /// pointing to the canonical `.agents/skills/sprout-cli`. `None` → this /// runtime reads the canonical path directly or has no skill support. pub skill_dir: Option<&'static str>, - // Phase 3: these fields are consumed by runtime.rs spawn logic to replace ad-hoc env var injection. - #[allow(dead_code)] pub supports_acp_model_switching: bool, - #[allow(dead_code)] pub model_env_var: Option<&'static str>, #[allow(dead_code)] pub provider_env_var: Option<&'static str>, #[allow(dead_code)] pub provider_locked: bool, - #[allow(dead_code)] pub default_env: &'static [(&'static str, &'static str)], } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 0a30ee63b..0219eda28 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -593,7 +593,8 @@ pub fn spawn_agent_child( } // Enable MCP hook tools (_Stop, _PostCompact) for agents that need them. // Uses "*" because build_mcp_servers() hard-codes the server name to "sprout-mcp". - if known_acp_runtime(&record.agent_command).is_some_and(|p| p.mcp_hooks) { + let runtime_meta = known_acp_runtime(&record.agent_command); + if runtime_meta.is_some_and(|r| r.mcp_hooks) { command.env("MCP_HOOK_SERVERS", "*"); } // Only emit SPROUT_ACP_IDLE_TIMEOUT when the user has explicitly set an @@ -613,10 +614,13 @@ pub fn spawn_agent_child( command.env("SPROUT_ACP_AGENTS", record.parallelism.to_string()); command.env("SPROUT_ACP_MULTIPLE_EVENT_HANDLING", "owner-interrupt"); command.env("SPROUT_ACP_DEDUP", "queue"); - command.env( - "GOOSE_MODE", - std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".to_string()), - ); + if let Some(meta) = runtime_meta { + for (key, value) in meta.default_env { + if std::env::var(key).is_err() { + command.env(key, value); + } + } + } if let (Some(pack_path), Some(persona_name)) = (&record.persona_pack_path, &record.persona_name_in_pack) { @@ -659,6 +663,13 @@ pub fn spawn_agent_child( } else { command.env_remove("SPROUT_ACP_MODEL"); } + if let Some(meta) = runtime_meta { + if !meta.supports_acp_model_switching { + if let (Some(env_key), Some(model)) = (meta.model_env_var, &effective_model) { + command.env(env_key, model); + } + } + } if let Some(toolsets) = &record.mcp_toolsets { command.env("SPROUT_TOOLSETS", toolsets); } else { diff --git a/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs new file mode 100644 index 000000000..a280028fc --- /dev/null +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + collectRuntimeWarnings, + resolvePersonaRuntime, +} from "./resolvePersonaRuntime.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makeRuntime(id, label = `${id} label`) { + return { id, label, command: id, avatarUrl: "" }; +} + +const goose = makeRuntime("goose", "Goose"); +const claude = makeRuntime("claude", "Claude"); +const runtimes = [goose, claude]; + +// --------------------------------------------------------------------------- +// resolvePersonaRuntime — Case 1: no personaRuntimeId +// --------------------------------------------------------------------------- + +test("resolvePersonaRuntime — no personaRuntimeId returns defaultRuntime with no warnings", () => { + const result = resolvePersonaRuntime(null, runtimes, goose); + assert.deepEqual(result, { + runtime: goose, + warnings: [], + isOverridden: false, + }); +}); + +test("resolvePersonaRuntime — undefined personaRuntimeId also returns defaultRuntime", () => { + const result = resolvePersonaRuntime(undefined, runtimes, goose); + assert.deepEqual(result, { + runtime: goose, + warnings: [], + isOverridden: false, + }); +}); + +test("resolvePersonaRuntime — no personaRuntimeId and no defaultRuntime returns null with warning", () => { + const result = resolvePersonaRuntime(null, runtimes, null); + assert.equal(result.runtime, null); + assert.equal(result.warnings.length, 1); + assert.match(result.warnings[0], /No agent runtimes are available/); + assert.equal(result.isOverridden, false); +}); + +// --------------------------------------------------------------------------- +// resolvePersonaRuntime — Case 2: matching runtime found +// --------------------------------------------------------------------------- + +test("resolvePersonaRuntime — matching runtime found returns matched runtime, no warnings", () => { + const result = resolvePersonaRuntime("goose", runtimes, claude); + assert.deepEqual(result, { + runtime: goose, + warnings: [], + isOverridden: false, + }); +}); + +test("resolvePersonaRuntime — override=true with same runtime as default returns default, no warnings", () => { + const result = resolvePersonaRuntime("goose", runtimes, goose, true); + assert.deepEqual(result, { + runtime: goose, + warnings: [], + isOverridden: false, + }); +}); + +test("resolvePersonaRuntime — override=true with different default emits override warning and returns default", () => { + const result = resolvePersonaRuntime("goose", runtimes, claude, true); + assert.equal(result.runtime, claude); + assert.equal(result.warnings.length, 1); + assert.match(result.warnings[0], /Runtime override/); + assert.match(result.warnings[0], /Claude/); + assert.match(result.warnings[0], /Goose/); + assert.equal(result.isOverridden, true); +}); + +test("resolvePersonaRuntime — override=false returns matched runtime, ignores override flag", () => { + const result = resolvePersonaRuntime("goose", runtimes, claude, false); + assert.deepEqual(result, { + runtime: goose, + warnings: [], + isOverridden: false, + }); +}); + +test("resolvePersonaRuntime — override=true but no defaultRuntime returns matched runtime, no warnings", () => { + const result = resolvePersonaRuntime("goose", runtimes, null, true); + assert.deepEqual(result, { + runtime: goose, + warnings: [], + isOverridden: false, + }); +}); + +// --------------------------------------------------------------------------- +// resolvePersonaRuntime — Case 3: personaRuntimeId not in runtimes, has default +// --------------------------------------------------------------------------- + +test("resolvePersonaRuntime — unrecognised runtimeId falls back to defaultRuntime with warning", () => { + const result = resolvePersonaRuntime("unknown-rt", runtimes, goose); + assert.equal(result.runtime, goose); + assert.equal(result.warnings.length, 1); + assert.match(result.warnings[0], /unknown-rt/); + assert.match(result.warnings[0], /Goose/); + assert.match(result.warnings[0], /not available/); + assert.equal(result.isOverridden, true); +}); + +// --------------------------------------------------------------------------- +// resolvePersonaRuntime — Case 4: personaRuntimeId not in runtimes, no default +// --------------------------------------------------------------------------- + +test("resolvePersonaRuntime — unrecognised runtimeId and no defaultRuntime returns null with error warning", () => { + const result = resolvePersonaRuntime("unknown-rt", [], null); + assert.equal(result.runtime, null); + assert.equal(result.warnings.length, 1); + assert.match(result.warnings[0], /unknown-rt/); + assert.match(result.warnings[0], /no other runtimes were found/); + assert.equal(result.isOverridden, false); +}); + +// --------------------------------------------------------------------------- +// resolvePersonaRuntime — isOverridden field +// --------------------------------------------------------------------------- + +test("resolvePersonaRuntime — isOverridden is true when override redirects to different runtime", () => { + const result = resolvePersonaRuntime("goose", runtimes, claude, true); + assert.equal(result.isOverridden, true); +}); + +test("resolvePersonaRuntime — isOverridden is false when no override active", () => { + const result = resolvePersonaRuntime("goose", runtimes, claude); + assert.equal(result.isOverridden, false); +}); + +test("resolvePersonaRuntime — isOverridden is true when persona's runtime is unavailable and falls back", () => { + const result = resolvePersonaRuntime("unknown-rt", runtimes, goose); + assert.equal(result.isOverridden, true); +}); + +test("resolvePersonaRuntime — isOverridden is false when override selects same runtime as persona", () => { + const result = resolvePersonaRuntime("goose", runtimes, goose, true); + assert.equal(result.isOverridden, false); +}); + +// --------------------------------------------------------------------------- +// collectRuntimeWarnings +// --------------------------------------------------------------------------- + +test("collectRuntimeWarnings — no fallbackRuntime returns empty array regardless of personas", () => { + const personas = [{ runtime: "goose" }, { runtime: "unknown-rt" }]; + const warnings = collectRuntimeWarnings(personas, runtimes, null); + assert.deepEqual(warnings, []); +}); + +test("collectRuntimeWarnings — all personas match their runtimes returns empty array", () => { + const personas = [{ runtime: "goose" }, { runtime: "claude" }]; + const warnings = collectRuntimeWarnings(personas, runtimes, goose); + assert.deepEqual(warnings, []); +}); + +test("collectRuntimeWarnings — persona with no runtime preference produces no warning", () => { + const personas = [{ runtime: null }]; + const warnings = collectRuntimeWarnings(personas, runtimes, goose); + assert.deepEqual(warnings, []); +}); + +test("collectRuntimeWarnings — mixed personas: matching ones are silent, non-matching emit warnings", () => { + const personas = [{ runtime: "goose" }, { runtime: "unknown-rt" }]; + const warnings = collectRuntimeWarnings(personas, runtimes, goose); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /unknown-rt/); +}); + +test("collectRuntimeWarnings — override mode collects one warning per persona whose runtime differs from default", () => { + const personas = [{ runtime: "goose" }, { runtime: "goose" }]; + const warnings = collectRuntimeWarnings(personas, runtimes, claude, true); + assert.equal(warnings.length, 2); + for (const w of warnings) { + assert.match(w, /Runtime override/); + } +}); + +test("collectRuntimeWarnings — override with one matching, one mismatching persona emits one warning", () => { + const personas = [{ runtime: "claude" }, { runtime: "goose" }]; + const warnings = collectRuntimeWarnings(personas, runtimes, claude, true); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /Runtime override/); + assert.match(warnings[0], /Goose/); +}); + +test("collectRuntimeWarnings — override=false behaves identically to no override flag", () => { + const personas = [{ runtime: "goose" }, { runtime: "claude" }]; + const withoutFlag = collectRuntimeWarnings(personas, runtimes, goose); + const withFalse = collectRuntimeWarnings(personas, runtimes, goose, false); + assert.deepEqual(withoutFlag, withFalse); +}); + +test("collectRuntimeWarnings — empty personas array always returns empty", () => { + assert.deepEqual(collectRuntimeWarnings([], runtimes, goose, true), []); +}); diff --git a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts index e9ed6494e..be0e93689 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts @@ -8,10 +8,13 @@ import type { AcpRuntime } from "@/shared/api/types"; * `warnings` contains user-visible messages when the resolved runtime * differs from what the persona requested (e.g. the configured runtime * was uninstalled) or when no runtime is available at all. + * `isOverridden` is true when the resolved runtime differs from what the + * persona originally requested (either via explicit override or fallback). */ export type ResolvePersonaRuntimeResult = { runtime: AcpRuntime | null; warnings: string[]; + isOverridden: boolean; }; /** @@ -19,7 +22,9 @@ export type ResolvePersonaRuntimeResult = { * * Resolution order: * 1. If the persona has no `runtimeId` → use `defaultRuntime`, no warnings. - * 2. If the persona's `runtimeId` matches an available runtime → use it. + * 2. If the persona's `runtimeId` matches an available runtime → use it, + * unless `forceOverride` is true and `defaultRuntime` is set, in which case + * `defaultRuntime` is used instead (with an info warning if they differ). * 3. If the persona's `runtimeId` is set but not found in `runtimes` → * fall back to `defaultRuntime` and emit a warning. * 4. If there is no `defaultRuntime` either → return `null` with an error @@ -29,6 +34,7 @@ export function resolvePersonaRuntime( personaRuntimeId: string | undefined | null, runtimes: readonly AcpRuntime[], defaultRuntime: AcpRuntime | null, + forceOverride?: boolean, ): ResolvePersonaRuntimeResult { // Case 1: Persona has no runtime preference — use the default. if (!personaRuntimeId) { @@ -39,13 +45,27 @@ export function resolvePersonaRuntime( : [ "No agent runtimes are available. Install a runtime (e.g. Goose) to deploy agents.", ], + isOverridden: false, }; } // Case 2: Persona's preferred runtime is available. const matched = runtimes.find((p) => p.id === personaRuntimeId); if (matched) { - return { runtime: matched, warnings: [] }; + if (forceOverride && defaultRuntime && matched.id !== defaultRuntime.id) { + return { + runtime: defaultRuntime, + warnings: [ + `Runtime override: using ${defaultRuntime.label} instead of ${matched.label}.`, + ], + isOverridden: true, + }; + } + return { + runtime: forceOverride && defaultRuntime ? defaultRuntime : matched, + warnings: [], + isOverridden: false, + }; } // Case 3 & 4: Persona's runtime is not available — fall back. @@ -55,6 +75,7 @@ export function resolvePersonaRuntime( warnings: [ `Persona is configured for runtime "${personaRuntimeId}" but it is not available. Using ${defaultRuntime.label} instead.`, ], + isOverridden: true, }; } @@ -63,6 +84,7 @@ export function resolvePersonaRuntime( warnings: [ `Persona is configured for runtime "${personaRuntimeId}" but it is not available, and no other runtimes were found.`, ], + isOverridden: false, }; } @@ -76,7 +98,11 @@ export function collectRuntimeWarnings( personas: readonly { runtime: string | null }[], runtimes: readonly AcpRuntime[], fallbackRuntime: AcpRuntime | null, + forceOverride?: boolean, ): string[] { + // When no fallback runtime exists, the caller's UI is responsible for + // showing the global "no runtimes found" state. Per-persona warnings + // would be redundant noise alongside that. if (!fallbackRuntime) return []; const warnings: string[] = []; for (const persona of personas) { @@ -84,6 +110,7 @@ export function collectRuntimeWarnings( persona.runtime, runtimes, fallbackRuntime, + forceOverride, ); warnings.push(...w); } diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index f58945a25..139931e63 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -29,16 +29,15 @@ import { DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { coerceConfigValues, ProviderConfigFields, } from "@/features/agents/ui/ProviderConfigFields"; -import { - collectRuntimeWarnings, - resolvePersonaRuntime, -} from "@/features/agents/lib/resolvePersonaRuntime"; +import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; +import { useEffectiveRuntimes } from "@/features/channels/ui/useEffectiveRuntimes"; import { getActivePersonas } from "@/features/agents/lib/catalog"; import { getUsableTeams } from "@/features/agents/lib/teamPersonas"; import { useLastRuntime } from "@/features/agents/lib/useLastRuntime"; @@ -56,6 +55,8 @@ type AddChannelBotDialogProps = { onOpenChange: (open: boolean) => void; }; +const RUNTIME_NONE_SENTINEL = "__none__"; + function defaultBotName(runtime: AcpRuntime | null) { if (!runtime) { return ""; @@ -103,7 +104,7 @@ export function AddChannelBotDialog({ onAdded, onOpenChange, }: AddChannelBotDialogProps) { - const { lastRuntimeId, setLastRuntime } = useLastRuntime(); + const { setLastRuntime } = useLastRuntime(); const personasQuery = usePersonasQuery(); const teamsQuery = useTeamsQuery(); const inChannelPersonaIds = useInChannelPersonaIds( @@ -152,11 +153,13 @@ export function AddChannelBotDialog({ const selectedRuntime = React.useMemo( () => - providers.find((runtime) => runtime.id === selectedRuntimeId) ?? - providers[0] ?? - null, + selectedRuntimeId + ? (providers.find((runtime) => runtime.id === selectedRuntimeId) ?? + null) + : null, [providers, selectedRuntimeId], ); + const isOverrideActive = selectedRuntime !== null; const selectedPersonas = React.useMemo( () => personas.filter((persona) => selectedPersonaIds.includes(persona.id)), [personas, selectedPersonaIds], @@ -166,21 +169,18 @@ export function AddChannelBotDialog({ const reusableAgent = useReusableAgentDetection( channelId, open && channelId !== null, - selectedRuntime, + selectedRuntime ?? providers[0] ?? null, selectedPersonas, includeGeneric, customPrompt, ); - // Surface warnings when a persona's preferred runtime differs from the - // user-selected runtime. In this dialog the user explicitly picks a - // runtime via the dropdown, so the fallback is `selectedRuntime` (their - // choice), NOT `providers[0]`. This differs intentionally from - // AddTeamToChannelDialog which has no runtime selector and falls back - // to the first available runtime. - const runtimeWarnings = React.useMemo( - () => collectRuntimeWarnings(selectedPersonas, providers, selectedRuntime), - [selectedPersonas, providers, selectedRuntime], + const { runtimeWarnings, effectiveRuntimes } = useEffectiveRuntimes( + personas, + selectedPersonas, + providers, + selectedRuntime, + isOverrideActive, ); const isProviderMode = runOn !== "local"; @@ -197,19 +197,6 @@ export function AddChannelBotDialog({ ); }, [isProviderMode, probedProvider, providerConfig]); - React.useEffect(() => { - if (!open) { - return; - } - - if (!selectedRuntimeId && providers.length > 0) { - const remembered = lastRuntimeId - ? providers.find((p) => p.id === lastRuntimeId) - : null; - setSelectedRuntimeId(remembered ? remembered.id : providers[0].id); - } - }, [open, providers, selectedRuntimeId, lastRuntimeId]); - React.useEffect(() => { if (!selectedRuntime || hasEditedCustomName) { return; @@ -318,7 +305,7 @@ export function AddChannelBotDialog({ } async function handleSubmit() { - if (!selectedRuntime || selectedCount === 0) { + if (providers.length === 0 || selectedCount === 0) { return; } @@ -346,7 +333,7 @@ export function AddChannelBotDialog({ ...(includeGeneric ? [ { - runtime: selectedRuntime, + runtime: selectedRuntime ?? providers[0], name: customName, systemPrompt: customPrompt, role: "bot" as const, @@ -357,13 +344,15 @@ export function AddChannelBotDialog({ ] : []), ...selectedPersonas.map((persona) => { + const effectiveFallback = selectedRuntime ?? providers[0] ?? null; const resolved = resolvePersonaRuntime( persona.runtime, providers, - selectedRuntime, + effectiveFallback, + isOverrideActive, ); return { - runtime: resolved.runtime ?? selectedRuntime, + runtime: resolved.runtime ?? effectiveFallback ?? providers[0], name: persona.displayName, personaId: persona.id, systemPrompt: persona.systemPrompt, @@ -424,7 +413,7 @@ export function AddChannelBotDialog({ respondTo !== "allowlist" || respondToAllowlist.length > 0; const canSubmit = - selectedRuntime !== null && + (selectedRuntime !== null || providers.length > 0) && selectedCount > 0 && (!includeGeneric || customName.trim().length > 0) && respondToValid && @@ -436,9 +425,11 @@ export function AddChannelBotDialog({ const canChooseProvider = providers.length > 0 && !providersLoading && !createBotsMutation.isPending; const canToggleSelections = !createBotsMutation.isPending; - const providerTriggerLabel = providersLoading + const runtimeTriggerLabel = providersLoading ? "Loading runtimes..." - : (selectedRuntime?.label ?? "No runtimes found"); + : providers.length === 0 + ? "No runtimes found" + : (selectedRuntime?.label ?? "Use persona defaults"); const addButtonLabel = createBotsMutation.isPending ? selectedCount > 1 ? `Adding ${selectedCount}...` @@ -538,7 +529,7 @@ export function AddChannelBotDialog({ type="button" variant="ghost" > - {providerTriggerLabel} + {runtimeTriggerLabel} @@ -549,11 +540,19 @@ export function AddChannelBotDialog({ > { - setSelectedRuntimeId(id); - setLastRuntime(id); + if (id === RUNTIME_NONE_SENTINEL) { + setSelectedRuntimeId(""); + } else { + setSelectedRuntimeId(id); + setLastRuntime(id); + } }} - value={selectedRuntime?.id ?? ""} + value={selectedRuntime?.id ?? RUNTIME_NONE_SENTINEL} > + + Use persona defaults + + {providers.map((provider) => ( {provider.label} @@ -563,9 +562,9 @@ export function AddChannelBotDialog({

- {selectedPersonas.some((p) => p.runtime) - ? "Personas with a preferred runtime will use their own instead of this selection." - : "Default runtime for all deployed agents."} + {isOverrideActive + ? "All agents will use this runtime, overriding persona preferences." + : "Each persona uses its preferred runtime. Choose a runtime above to override all."}

@@ -583,6 +582,7 @@ export function AddChannelBotDialog({ + {label} + + ); +} + type AddChannelBotPersonasSectionProps = { canToggleSelections: boolean; + /** Map of personaId → effective runtime label for badge display */ + effectiveRuntimes?: ReadonlyMap< + string, + { label: string; isOverridden: boolean } + >; inChannelPersonaIds?: ReadonlySet; includeGeneric: boolean; isLoading: boolean; @@ -78,6 +104,7 @@ type AddChannelBotPersonasSectionProps = { export function AddChannelBotPersonasSection({ canToggleSelections, + effectiveRuntimes, inChannelPersonaIds, includeGeneric, isLoading, @@ -128,6 +155,7 @@ export function AddChannelBotPersonasSection({ {personas.map((persona) => { const isSelected = selectedPersonaIds.includes(persona.id); const isInChannel = inChannelPersonaIds?.has(persona.id) ?? false; + const runtimeBadge = effectiveRuntimes?.get(persona.id); return ( @@ -140,6 +168,9 @@ export function AddChannelBotPersonasSection({ selected={isSelected} > {persona.displayName} + {runtimeBadge ? ( + + ) : null} {isInChannel ? ( + collectRuntimeWarnings( + selectedPersonas, + providers, + fallback, + isOverrideActive, + ), + [selectedPersonas, providers, fallback, isOverrideActive], + ); + + const effectiveRuntimes = React.useMemo(() => { + const map = new Map(); + for (const persona of personas) { + const resolved = resolvePersonaRuntime( + persona.runtime, + providers, + fallback, + isOverrideActive, + ); + if (resolved.runtime) { + map.set(persona.id, { + label: resolved.runtime.label, + isOverridden: resolved.isOverridden, + }); + } + } + return map; + }, [personas, providers, fallback, isOverrideActive]); + + return { runtimeWarnings, effectiveRuntimes }; +}