diff --git a/desktop/src-tauri/src/managed_agents/persona_card.rs b/desktop/src-tauri/src/managed_agents/persona_card.rs index f933c7467e..abb5803b6e 100644 --- a/desktop/src-tauri/src/managed_agents/persona_card.rs +++ b/desktop/src-tauri/src/managed_agents/persona_card.rs @@ -301,14 +301,7 @@ pub fn parse_md_persona(md_bytes: &[u8]) -> Result fn parsed_preview_from_md_config( config: buzz_persona_pkg::persona::PersonaConfig, ) -> ParsedPersonaPreview { - // Split "provider:model" into separate fields for the preview. - let model = match config.model.as_deref() { - Some(s) if !s.is_empty() => { - let (_prov, id) = buzz_persona_pkg::persona::split_model(s); - Some(id.to_owned()) - } - _ => None, - }; + let (provider, model) = split_preview_model(config.model.as_deref()); ParsedPersonaPreview { display_name: config.display_name, @@ -317,12 +310,22 @@ fn parsed_preview_from_md_config( avatar_ref: config.avatar, runtime: config.runtime, model, - provider: None, // Markdown persona format does not carry llmProvider + provider, name_pool: Vec::new(), source_file: String::new(), } } +fn split_preview_model(model: Option<&str>) -> (Option, Option) { + match model.map(str::trim).filter(|s| !s.is_empty()) { + Some(raw_model) => { + let (provider, id) = buzz_persona_pkg::persona::split_model(raw_model); + (provider.map(str::to_owned), Some(id.to_owned())) + } + None => (None, None), + } +} + #[derive(Debug, Deserialize)] struct LenientMdFrontmatter { name: Option, @@ -343,14 +346,7 @@ fn parse_lenient_md_persona(content: &str) -> Result { - let (_prov, id) = buzz_persona_pkg::persona::split_model(s); - Some(id.to_owned()) - } - _ => None, - }; + let (provider, model) = split_preview_model(fields.model.as_deref()); Ok(ParsedPersonaPreview { display_name, @@ -359,7 +355,7 @@ fn parse_lenient_md_persona(content: &str) -> Result void; + previousPersona?: AgentPersona; + runtimes?: readonly AcpRuntimeCatalogEntry[]; updateManagedAgent: ( input: UpdateManagedAgentInput, ) => Promise<{ agent: ManagedAgent; profileSyncError: string | null }>; @@ -30,6 +33,8 @@ export async function submitProfilePersonaDialog({ input, managedAgent, onDone, + previousPersona, + runtimes, updateManagedAgent, updatePersona, }: SubmitProfilePersonaDialogOptions) { @@ -37,7 +42,10 @@ export async function submitProfilePersonaDialog({ if ("id" in input) { const persona = await updatePersona(input); const agentUpdate = managedAgent - ? personaManagedAgentUpdate(managedAgent, persona) + ? personaManagedAgentUpdate(managedAgent, persona, { + previousPersona, + runtimes, + }) : null; const result = agentUpdate ? await updateManagedAgent(agentUpdate) : null; if (result?.profileSyncError) { diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index aa53902053..034f1380de 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -59,6 +59,24 @@ function persona(overrides = {}) { }; } +function runtime(overrides = {}) { + return { + id: "claude", + label: "Claude Code", + avatarUrl: "app-avatar://claude", + availability: "available", + command: "claude", + binaryPath: "/usr/local/bin/claude", + defaultArgs: ["mcp", "serve"], + mcpCommand: "claude-mcp", + installHint: "", + installInstructionsUrl: "", + canAutoInstall: false, + underlyingCliPath: null, + ...overrides, + }; +} + test("personaManagedAgentUpdate syncs edited persona identity to linked agent", () => { assert.deepEqual(personaManagedAgentUpdate(agent(), persona()), { pubkey: "deadbeef".repeat(8), @@ -89,3 +107,44 @@ test("personaManagedAgentUpdate skips unrelated or unchanged agents", () => { null, ); }); + +test("personaManagedAgentUpdate maps changed persona runtime to linked agent commands", () => { + assert.deepEqual( + personaManagedAgentUpdate(agent(), persona({ runtime: "claude" }), { + previousPersona: persona({ runtime: "goose" }), + runtimes: [runtime()], + }), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + agentCommand: "claude", + agentArgs: ["mcp", "serve"], + mcpCommand: "claude-mcp", + }, + ); +}); + +test("personaManagedAgentUpdate leaves runtime fields alone when runtime is unchanged", () => { + assert.equal( + personaManagedAgentUpdate( + agent({ + name: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + agentArgs: ["custom"], + }), + persona({ runtime: "goose" }), + { + previousPersona: persona({ runtime: "goose" }), + runtimes: [runtime({ id: "goose", command: "goose" })], + }, + ), + null, + ); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index d40237f2de..e953ae8f1e 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -1,5 +1,6 @@ import * as React from "react"; import type { + AcpRuntimeCatalogEntry, AgentPersona, Channel, ManagedAgent, @@ -162,6 +163,10 @@ export function resolveAgentInstruction( export function personaManagedAgentUpdate( agent: ManagedAgent, persona: AgentPersona, + options: { + previousPersona?: AgentPersona; + runtimes?: readonly AcpRuntimeCatalogEntry[]; + } = {}, ): UpdateManagedAgentInput | null { if (agent.personaId !== persona.id) return null; @@ -193,9 +198,39 @@ export function personaManagedAgentUpdate( hasChanges = true; } + const runtimeChanged = + options.previousPersona !== undefined && + options.previousPersona.runtime !== persona.runtime; + const runtime = runtimeChanged + ? options.runtimes?.find((candidate) => candidate.id === persona.runtime) + : undefined; + if (runtime?.command) { + if (runtime.command !== agent.agentCommand) { + input.agentCommand = runtime.command; + hasChanges = true; + } + + if (!stringArrayEqual(runtime.defaultArgs, agent.agentArgs)) { + input.agentArgs = [...runtime.defaultArgs]; + hasChanges = true; + } + + const mcpCommand = runtime.mcpCommand ?? ""; + if (mcpCommand !== agent.mcpCommand) { + input.mcpCommand = mcpCommand; + hasChanges = true; + } + } + return hasChanges ? input : null; } +function stringArrayEqual(left: readonly string[], right: readonly string[]) { + if (left.length !== right.length) return false; + + return left.every((value, index) => value === right[index]); +} + function stringRecordEqual( left: Record, right: Record,