diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index ed96c50d1e6..54e51ebbdb9 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -134,7 +134,11 @@ const overrides = new Map([ // config-parity: max_tokens_env_var + context_limit_env_var fields added to // KnownAcpRuntime (2 fields × 4 runtimes + discovery tests = ~13 lines). // Load-bearing — required for buzz-agent normalized config parity. - ["src-tauri/src/managed_agents/discovery.rs", 1124], + // same-runtime-pin: update_time_agent_command_override + its override / + // same-runtime / alias / sentinel / non-override / persona-less test matrix + // (~135 lines, mostly tests) so a deliberate Custom pin survives the update + // path instead of being dropped back to inherit. Load-bearing, not debt. + ["src-tauri/src/managed_agents/discovery.rs", 1259], // migration_tests.rs carries the harness-sync migration coverage plus the // patch_json_records owner-only writeback regression test (SECURITY.md:90 // crash-safe 0o600 fallback). Load-bearing security + feature coverage, not @@ -207,7 +211,9 @@ const overrides = new Map([ // a GUI-launched DMG (the discovery_env_with_baked_floor fold). // +3: provider tri-state applied in update_managed_agent handler // (if let Some(provider_update) = input.provider { record.provider = provider_update; }). - ["src-tauri/src/commands/agent_models.rs", 1071], + // +8: harness_override thread-through in update_managed_agent so a deliberate + // Custom pin routes to update_time_agent_command_override (comment + call). + ["src-tauri/src/commands/agent_models.rs", 1079], // draft-persistence predicate: submit-time `loadDraft` check + inline comment // + deps-array entry in submitMessage closes the never-persisted-boundary // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 13d27a87d7a..52759389cc0 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -860,13 +860,21 @@ pub async fn update_managed_agent( // that diverges from the persona. An empty/whitespace value (the // "Inherit from persona" sentinel) clears the pin back to `None`. A // name-only edit (`agent_command == None`) leaves the pin intact. + // + // `harness_override` threads the user's explicit intent: when they pick + // a runtime/Custom command in the dialog it is a real pin even if it + // maps to the persona's own runtime, so a same-runtime pick is kept + // rather than dropped back to inherit (see + // `update_time_agent_command_override`). if let Some(agent_command) = input.agent_command { let personas = load_personas(&app).unwrap_or_default(); - record.agent_command_override = crate::managed_agents::divergent_agent_command_override( - record.persona_id.as_deref(), - &personas, - Some(&agent_command), - ); + record.agent_command_override = + crate::managed_agents::update_time_agent_command_override( + record.persona_id.as_deref(), + &personas, + Some(&agent_command), + input.harness_override, + ); } if let Some(agent_args) = input.agent_args { record.agent_args = agent_args; diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 4685697b465..8ace99ee60e 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -348,6 +348,47 @@ pub fn divergent_agent_command_override( } } +/// Decide the `agent_command_override` to persist at AGENT UPDATE time. +/// +/// The edit dialog sends `agent_command` as a tri-state string: the empty +/// "inherit from persona" sentinel (clear the pin), or a concrete command +/// (pin). Resolution: +/// +/// - EMPTY / whitespace → the inherit sentinel: always `None` regardless of +/// `harness_override`, so toggling "Inherit runtime from persona" clears the +/// pin. +/// - DELIBERATE OVERRIDE (`harness_override` true, persona linked): the user +/// explicitly picked a runtime/Custom command in the dialog. This is a real +/// pin and is preserved VERBATIM — even when the picked command maps to, or +/// is byte-identical to, the persona's own runtime command. Selecting "Custom +/// command" and saving e.g. `goose` for a goose persona is a deliberate act +/// to freeze the harness against future persona runtime edits; dropping it +/// back to inherit (as [`divergent_agent_command_override`] would) defeats +/// that intent. Unlike the create-time path, there is no byte-identical +/// exception here: at create the command is machine-derived from the persona, +/// so equality means "no user divergence"; at update an equal command reached +/// the force branch only because the user picked Custom, which IS the +/// divergence. +/// - NO OVERRIDE INTENT (`harness_override` false) or NO PERSONA: defer to +/// [`divergent_agent_command_override`], which keeps the persona authoritative +/// and treats a same-runtime restatement as inherit. +pub fn update_time_agent_command_override( + persona_id: Option<&str>, + personas: &[crate::managed_agents::types::PersonaRecord], + picked_command: Option<&str>, + harness_override: bool, +) -> Option { + let picked = picked_command + .map(str::trim) + .filter(|value| !value.is_empty())?; + + if persona_id.is_some() && harness_override { + return Some(picked.to_string()); + } + + divergent_agent_command_override(persona_id, personas, Some(picked)) +} + /// Decide the `agent_command_override` to persist at AGENT CREATE time. /// /// A persona-backed create receives its harness command from @@ -729,8 +770,8 @@ mod tests { use super::{ classify_runtime, create_time_agent_command_override, default_agent_command, divergent_agent_command_override, effective_agent_command, find_via_login_shell, - managed_agent_avatar_url, normalize_agent_args, BUZZ_AGENT_AVATAR_URL, - CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, + managed_agent_avatar_url, normalize_agent_args, update_time_agent_command_override, + BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -1120,4 +1161,98 @@ mod tests { Some("codex-acp".to_string()) ); } + + #[test] + fn update_time_override_preserves_same_runtime_pin_when_overriding() { + // The bug this fixes: the user picks "Custom command" in the edit + // dialog and saves `goose` verbatim for a goose persona. That is a + // deliberate pin (harness_override true) — it must be kept so future + // persona runtime edits stop propagating, even though it maps to the + // persona's own runtime. `divergent_agent_command_override` alone would + // wrongly drop it to `None`. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + assert_eq!( + update_time_agent_command_override(Some("p1"), &personas, Some("goose"), true), + Some("goose".to_string()) + ); + } + + #[test] + fn update_time_override_preserves_exact_persona_command_when_overriding() { + // Even when the pick is byte-identical to the persona's own command, an + // explicit Custom selection (harness_override true) is a deliberate pin + // and is preserved. This is the core divergence from the create-time + // contract: at update, equality reached the force branch only because + // the user picked Custom. + let personas = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + update_time_agent_command_override( + Some("p1"), + &personas, + Some("claude-agent-acp"), + true + ), + Some("claude-agent-acp".to_string()) + ); + } + + #[test] + fn update_time_override_preserves_alias_pin_when_overriding() { + // A `claude` persona with an installed `claude-code-acp` alias: picking + // it as a Custom pin is a deliberate divergence from the primary + // command and must be preserved when overriding. + let personas = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + update_time_agent_command_override( + Some("p1"), + &personas, + Some("claude-code-acp"), + true + ), + Some("claude-code-acp".to_string()) + ); + } + + #[test] + fn update_time_override_defers_to_divergent_when_not_overriding() { + // Without the explicit intent bit (e.g. a name-only edit that still + // echoes the command), the persona stays authoritative: a same-runtime + // command inherits, a different runtime pins. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + assert_eq!( + update_time_agent_command_override(Some("p1"), &personas, Some("goose"), false), + None + ); + assert_eq!( + update_time_agent_command_override(Some("p1"), &personas, Some("codex-acp"), false), + Some("codex-acp".to_string()) + ); + } + + #[test] + fn update_time_override_clears_pin_for_inherit_sentinel() { + // The empty "Inherit from persona" sentinel always clears the pin, + // regardless of the override flag. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + assert_eq!( + update_time_agent_command_override(Some("p1"), &personas, Some(" "), true), + None + ); + assert_eq!( + update_time_agent_command_override(Some("p1"), &personas, None, true), + None + ); + } + + #[test] + fn update_time_override_preserves_pin_for_persona_less_agent() { + // A persona-less agent has no runtime to inherit, so any picked command + // is a real pin — preserved even without the override flag (mirrors the + // create-time persona-less contract). + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + assert_eq!( + update_time_agent_command_override(None, &personas, Some("codex-acp"), false), + Some("codex-acp".to_string()) + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 8439daf2d26..27767eb591d 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -503,6 +503,14 @@ pub struct UpdateManagedAgentRequest { pub acp_command: Option, #[serde(default)] pub agent_command: Option, + /// True when the accompanying `agent_command` is a runtime/Custom command + /// the user deliberately picked for a linked persona (i.e. the dialog is + /// not inheriting). Distinguishes a real pin — including one that maps to + /// the persona's own runtime — from a persona-authoritative restatement, + /// so a same-runtime pick is preserved instead of being dropped back to + /// inherit. Ignored when `agent_command` is absent or the inherit sentinel. + #[serde(default)] + pub harness_override: bool, #[serde(default)] pub agent_args: Option>, #[serde(default)] diff --git a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx new file mode 100644 index 00000000000..48597b8f244 --- /dev/null +++ b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx @@ -0,0 +1,378 @@ +import { cn } from "@/shared/lib/cn"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; +import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; +import { + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, + PERSONA_LABEL_OPTIONAL_CLASS, +} from "./personaDialogPickers"; +import type { AgentPersona } from "@/shared/api/types"; + +export function EditAgentAdvancedFields({ + acpCommand, + agentArgs, + agentCommand, + disabled, + envVars, + fileSatisfiedEnvKeys, + inheritedEnvVars, + inheritHarness, + linkedPersona, + mcpCommand, + mcpToolsets, + parallelism, + relayUrl, + requiredEnvKeys, + selectedRuntimeId, + systemPrompt, + turnTimeoutSeconds, + onAcpCommandChange, + onAgentArgsChange, + onAgentCommandChange, + onEnvVarsChange, + onInheritHarnessChange, + onMcpCommandChange, + onMcpToolsetsChange, + onParallelismChange, + onRelayUrlChange, + onSystemPromptChange, + onTurnTimeoutChange, +}: { + acpCommand: string; + agentArgs: string; + agentCommand: string; + disabled: boolean; + envVars: EnvVarsValue; + fileSatisfiedEnvKeys: readonly string[]; + inheritedEnvVars: Record; + inheritHarness: boolean; + linkedPersona: AgentPersona | null; + mcpCommand: string; + mcpToolsets: string; + parallelism: string; + relayUrl: string; + requiredEnvKeys: readonly string[]; + selectedRuntimeId: string; + systemPrompt: string; + turnTimeoutSeconds: string; + onAcpCommandChange: (value: string) => void; + onAgentArgsChange: (value: string) => void; + onAgentCommandChange: (value: string) => void; + onEnvVarsChange: (value: EnvVarsValue) => void; + onInheritHarnessChange: (value: boolean) => void; + onMcpCommandChange: (value: string) => void; + onMcpToolsetsChange: (value: string) => void; + onParallelismChange: (value: string) => void; + onRelayUrlChange: (value: string) => void; + onSystemPromptChange: (value: string) => void; + onTurnTimeoutChange: (value: string) => void; +}) { + return ( +
+ {/* Inherit runtime from persona */} + {linkedPersona ? ( +
+ +

+ {inheritHarness + ? `Uses the ${linkedPersona.displayName} persona's runtime${ + linkedPersona.runtime ? ` (${linkedPersona.runtime})` : "" + }. Editing the persona and respawning propagates the new runtime.` + : "Pins this agent to a specific runtime command, overriding the persona's runtime."} +

+
+ ) : null} + + {/* Custom agent command (when custom runtime) */} + {selectedRuntimeId === "custom" && !inheritHarness ? ( +
+ +
+ onAgentCommandChange(event.target.value)} + placeholder="Full path or shell command" + value={agentCommand} + /> +
+
+ ) : null} + + {/* Agent runtime args */} +
+ +
+ onAgentArgsChange(event.target.value)} + placeholder="Comma-separated" + value={agentArgs} + /> +
+
+ + {/* MCP command */} +
+ +
+ onMcpCommandChange(event.target.value)} + placeholder="Optional MCP server command" + value={mcpCommand} + /> +
+
+ + {/* MCP toolsets */} +
+ +
+ onMcpToolsetsChange(event.target.value)} + placeholder="default,canvas,forums,dms,media" + value={mcpToolsets} + /> +
+

+ Comma-separated list of toolsets to expose via BUZZ_TOOLSETS. +

+
+ + {/* Turn timeout + Parallelism side by side */} +
+
+ +
+ onTurnTimeoutChange(event.target.value)} + placeholder="300" + value={turnTimeoutSeconds} + /> +
+
+ +
+ +
+ onParallelismChange(event.target.value)} + placeholder="1" + value={parallelism} + /> +
+
+
+ + {/* Relay URL */} +
+ +
+ onRelayUrlChange(event.target.value)} + placeholder="Leave blank to use the workspace relay" + value={relayUrl} + /> +
+
+ + {/* ACP command */} +
+ +
+ onAcpCommandChange(event.target.value)} + value={acpCommand} + /> +
+
+ + {/* System prompt override */} +
+ +
+