diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 1c8b9925f..280d3ba72 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -17,6 +17,12 @@ use crate::{ util::now_iso, }; +fn trim_optional(value: Option) -> Option { + value + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + /// Query available models from an agent via `buzz-acp models --json`. /// /// Spawns a short-lived subprocess (no relay connection needed). The subprocess @@ -139,7 +145,8 @@ pub async fn get_agent_models( /// /// Does NOT auto-restart the agent. Runtime config changes (system prompt, /// parallelism, commands, toolsets) take effect on the next agent spawn. -/// Name changes are synced to the relay immediately via a kind:0 re-publish. +/// Name and avatar changes are synced to the relay immediately via a kind:0 +/// re-publish. #[tauri::command] pub async fn update_managed_agent( input: UpdateManagedAgentRequest, @@ -162,6 +169,7 @@ pub async fn update_managed_agent( let record = find_managed_agent_mut(&mut records, &input.pubkey)?; let mut name_changed = false; + let mut avatar_changed = false; if let Some(name_update) = input.name { let trimmed = name_update.trim().to_string(); if !trimmed.is_empty() && trimmed != record.name { @@ -169,6 +177,13 @@ pub async fn update_managed_agent( name_changed = true; } } + if let Some(avatar_update) = input.avatar_url { + let normalized = trim_optional(avatar_update); + if normalized != record.avatar_url { + record.avatar_url = normalized; + avatar_changed = true; + } + } if let Some(model_update) = input.model { record.model = model_update; } @@ -245,15 +260,19 @@ pub async fn update_managed_agent( .find(|r| r.pubkey == input.pubkey) .ok_or_else(|| format!("agent {} not found", input.pubkey))?; - let sync_params = if name_changed { + let sync_params = if name_changed || avatar_changed { let agent_keys = Keys::parse(&record.private_key_nsec) .map_err(|e| format!("failed to parse agent keys: {e}"))?; let relay_url = record.relay_url.clone(); let display_name = record.name.clone(); - let avatar_url = record - .avatar_url - .clone() - .or_else(|| managed_agent_avatar_url(&record.agent_command)); + let avatar_url = if avatar_changed { + record.avatar_url.clone() + } else { + record + .avatar_url + .clone() + .or_else(|| managed_agent_avatar_url(&record.agent_command)) + }; let auth_tag = record.auth_tag.clone(); Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) } else { diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index a1495aae1..1fcc0da3a 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -934,7 +934,9 @@ pub(crate) async fn reconcile_agent_profile( let existing = query_agent_profile(state, &data.relay_url, agent_pubkey).await?; // Resolve the expected avatar — backfilling for legacy records that have no - // stored avatar_url yet. + // stored avatar_url yet. `None` is meaningful here: it means publish a + // profile with no picture, which clears retired built-in Fizz data URLs from + // the relay. let stored_avatar = filter_retired_fizz_avatar(data.persona_id.as_deref(), data.avatar_url.clone()); let stored_avatar_was_retired_fizz = data @@ -942,7 +944,7 @@ pub(crate) async fn reconcile_agent_profile( .as_deref() .is_some_and(|url| is_retired_fizz_data_url(data.persona_id.as_deref(), url)); let expected_avatar = match stored_avatar { - Some(url) => url.to_string(), + Some(url) => Some(url.to_string()), None => { // Legacy record: the relay profile may have been corrupted by the // old reconciliation code (it overwrote the persona avatar with the @@ -993,15 +995,15 @@ pub(crate) async fn reconcile_agent_profile( } } - backfilled + if backfilled.is_empty() { + None + } else { + Some(backfilled) + } } }; - if expected_avatar.is_empty() { - return Ok(()); - } - - if !profile_needs_sync(existing.as_ref(), &data.name, Some(&expected_avatar)) { + if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) { return Ok(()); } @@ -1013,7 +1015,7 @@ pub(crate) async fn reconcile_agent_profile( &data.relay_url, &agent_keys, &data.name, - Some(&expected_avatar), + expected_avatar.as_deref(), data.auth_tag.as_deref(), ) .await diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 328b888fd..3c6c2fbe5 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1364,6 +1364,7 @@ pub fn build_managed_agent_summary( max_turn_duration_seconds: record.max_turn_duration_seconds, parallelism: record.parallelism, system_prompt: effective_prompt, + avatar_url: record.avatar_url.clone(), model: effective_model, mcp_toolsets: record.mcp_toolsets.clone(), env_vars: record.env_vars.clone(), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 51761a0f9..5c24b2418 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -230,6 +230,7 @@ pub struct ManagedAgentSummary { pub max_turn_duration_seconds: Option, pub parallelism: u32, pub system_prompt: Option, + pub avatar_url: Option, pub model: Option, pub mcp_toolsets: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -424,6 +425,9 @@ pub struct UpdateManagedAgentRequest { /// Absent = don't touch. Present = rename the agent. #[serde(default)] pub name: Option, + /// Absent = don't touch. null = clear. "url" = set. + #[serde(default)] + pub avatar_url: Option>, /// Absent = don't touch. null = clear to agent default. "id" = set. #[serde(default)] pub model: Option>, diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 02b9129ba..65ea29432 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -264,11 +264,11 @@ export function useUpdateManagedAgentMutation() { ); }, onSettled: async (_data, _error, variables) => { - // Backend republishes kind:0 on a name change (sync_managed_agent_profile), - // so the relay has fresh profile data — but the desktop's React Query cache - // for ["user-profile", pubkey] has a 60s staleTime and will not refetch on + // Backend republishes kind:0 on name/avatar changes, so the relay has + // fresh profile data — but the desktop's React Query cache for + // ["user-profile", pubkey] has a 60s staleTime and will not refetch on // its own. Invalidate explicitly so the profile pane re-renders against - // the new display name / about / NIP-05 immediately. Also poke any + // the new display name/avatar immediately. Also poke any // ["users-batch", ...] entries that include this pubkey so sidebar member // rows, channel header chips, and message author labels refresh too. const lowerPubkey = variables.pubkey.toLowerCase(); diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index f6d2b6328..c7d893cb1 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -25,6 +25,7 @@ import { useSetPersonaActiveMutation, useStartManagedAgentMutation, useStopManagedAgentMutation, + useUpdateManagedAgentMutation, useUpdatePersonaMutation, } from "@/features/agents/hooks"; import { AddAgentToChannelDialog } from "@/features/agents/ui/AddAgentToChannelDialog"; @@ -64,6 +65,7 @@ import { } from "@/features/profile/ui/UserProfilePanelSections"; import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion"; import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFields"; +import { submitProfilePersonaDialog } from "@/features/profile/ui/UserProfilePanelPersonaSubmit"; import { UserProfilePersonaDialogs } from "@/features/profile/ui/UserProfilePersonaDialogs"; import { buildPersonaDraftProfile, @@ -200,6 +202,7 @@ export function UserProfilePanel({ const availableRuntimesQuery = useAvailableAcpRuntimes(); const acpRuntimesQuery = useAcpRuntimesQuery(); const createAgentMutation = useCreateManagedAgentMutation(); + const updateManagedAgentMutation = useUpdateManagedAgentMutation(); const startAgentMutation = useStartManagedAgentMutation(); const stopAgentMutation = useStopManagedAgentMutation(); const deleteAgentMutation = useDeleteManagedAgentMutation(); @@ -276,6 +279,7 @@ export function UserProfilePanel({ managedAgent === undefined; const isAgentActionPending = createAgentMutation.isPending || + updateManagedAgentMutation.isPending || startAgentMutation.isPending || stopAgentMutation.isPending || deleteAgentMutation.isPending || @@ -488,46 +492,25 @@ export function UserProfilePanel({ const handleSubmitPersona = React.useCallback( async (input: CreatePersonaInput | UpdatePersonaInput) => { - try { - if ("id" in input) { - await updatePersonaMutation.mutateAsync(input); - toast.success(`Updated ${input.displayName}.`); - } else { - const persona = await createPersonaMutation.mutateAsync(input); - try { - const created = await createManagedAgentForPersona(persona); - if (created.spawnError) { - toast.error( - `${persona.displayName} was created, but it did not start: ${created.spawnError}`, - ); - } else { - toast.success(`Created and started ${created.agent.name}.`); - } - if (created.profileSyncError) { - toast.warning( - `${created.agent.name} was created, but profile sync failed: ${created.profileSyncError}`, - ); - } - } catch (error) { - toast.error( - error instanceof Error - ? `${persona.displayName} was created, but the agent instance could not be created: ${error.message}` - : `${persona.displayName} was created, but the agent instance could not be created.`, - ); - } - } - setPersonaDialogState(null); - void personasQuery.refetch(); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to save agent.", - ); - } + await submitProfilePersonaDialog({ + createManagedAgentForPersona, + createPersona: createPersonaMutation.mutateAsync, + input, + managedAgent, + onDone: () => { + setPersonaDialogState(null); + void personasQuery.refetch(); + }, + updateManagedAgent: updateManagedAgentMutation.mutateAsync, + updatePersona: updatePersonaMutation.mutateAsync, + }); }, [ createPersonaMutation.mutateAsync, createManagedAgentForPersona, + managedAgent, personasQuery.refetch, + updateManagedAgentMutation.mutateAsync, updatePersonaMutation.mutateAsync, ], ); @@ -893,6 +876,7 @@ export function UserProfilePanel({ isPending={ createPersonaMutation.isPending || updatePersonaMutation.isPending || + updateManagedAgentMutation.isPending || createAgentMutation.isPending } personaDialogState={personaDialogState} diff --git a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts new file mode 100644 index 000000000..33ed02fde --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts @@ -0,0 +1,80 @@ +import { toast } from "sonner"; + +import { personaManagedAgentUpdate } from "@/features/profile/ui/UserProfilePanelUtils"; +import type { + AgentPersona, + CreateManagedAgentResponse, + CreatePersonaInput, + ManagedAgent, + UpdateManagedAgentInput, + UpdatePersonaInput, +} from "@/shared/api/types"; + +type SubmitProfilePersonaDialogOptions = { + createManagedAgentForPersona: ( + persona: AgentPersona, + ) => Promise; + createPersona: (input: CreatePersonaInput) => Promise; + input: CreatePersonaInput | UpdatePersonaInput; + managedAgent: ManagedAgent | undefined; + onDone: () => void; + updateManagedAgent: ( + input: UpdateManagedAgentInput, + ) => Promise<{ agent: ManagedAgent; profileSyncError: string | null }>; + updatePersona: (input: UpdatePersonaInput) => Promise; +}; + +export async function submitProfilePersonaDialog({ + createManagedAgentForPersona, + createPersona, + input, + managedAgent, + onDone, + updateManagedAgent, + updatePersona, +}: SubmitProfilePersonaDialogOptions) { + try { + if ("id" in input) { + const persona = await updatePersona(input); + const agentUpdate = managedAgent + ? personaManagedAgentUpdate(managedAgent, persona) + : null; + const result = agentUpdate ? await updateManagedAgent(agentUpdate) : null; + if (result?.profileSyncError) { + toast.warning( + `${result.agent.name} was updated, but profile sync failed: ${result.profileSyncError}`, + ); + } + toast.success(`Updated ${input.displayName}.`); + } else { + const persona = await createPersona(input); + try { + const created = await createManagedAgentForPersona(persona); + if (created.spawnError) { + toast.error( + `${persona.displayName} was created, but it did not start: ${created.spawnError}`, + ); + } else { + toast.success(`Created and started ${created.agent.name}.`); + } + if (created.profileSyncError) { + toast.warning( + `${created.agent.name} was created, but profile sync failed: ${created.profileSyncError}`, + ); + } + } catch (error) { + toast.error( + error instanceof Error + ? `${persona.displayName} was created, but the agent instance could not be created: ${error.message}` + : `${persona.displayName} was created, but the agent instance could not be created.`, + ); + } + } + + onDone(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to save agent.", + ); + } +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs new file mode 100644 index 000000000..aa5390205 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { personaManagedAgentUpdate } from "./UserProfilePanelUtils.ts"; + +function agent(overrides = {}) { + return { + pubkey: "deadbeef".repeat(8), + name: "Fizz", + personaId: "persona-1", + relayUrl: "ws://localhost:3000", + acpCommand: "buzz-acp", + agentCommand: "goose", + agentArgs: [], + mcpCommand: "", + turnTimeoutSeconds: 320, + idleTimeoutSeconds: null, + maxTurnDurationSeconds: null, + parallelism: 1, + systemPrompt: "Old prompt", + avatarUrl: "app-avatar://old", + model: "old-model", + mcpToolsets: null, + envVars: { OLD_KEY: "1" }, + status: "stopped", + pid: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + lastStartedAt: null, + lastStoppedAt: null, + lastExitCode: null, + lastError: null, + logPath: null, + startOnAppLaunch: true, + backend: { type: "local" }, + backendAgentId: null, + respondTo: "owner-only", + respondToAllowlist: [], + ...overrides, + }; +} + +function persona(overrides = {}) { + return { + id: "persona-1", + displayName: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + runtime: "goose", + model: "new-model", + provider: null, + namePool: [], + isBuiltIn: false, + isActive: true, + envVars: { NEW_KEY: "2" }, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +test("personaManagedAgentUpdate syncs edited persona identity to linked agent", () => { + assert.deepEqual(personaManagedAgentUpdate(agent(), persona()), { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + }); +}); + +test("personaManagedAgentUpdate skips unrelated or unchanged agents", () => { + assert.equal( + personaManagedAgentUpdate(agent({ personaId: "persona-2" }), persona()), + null, + ); + assert.equal( + personaManagedAgentUpdate( + agent({ + name: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + }), + persona(), + ), + null, + ); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 0cb43c2f7..d40237f2d 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -5,6 +5,7 @@ import type { ManagedAgent, Profile, RelayAgent, + UpdateManagedAgentInput, } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -158,6 +159,54 @@ export function resolveAgentInstruction( ); } +export function personaManagedAgentUpdate( + agent: ManagedAgent, + persona: AgentPersona, +): UpdateManagedAgentInput | null { + if (agent.personaId !== persona.id) return null; + + const input: UpdateManagedAgentInput = { pubkey: agent.pubkey }; + let hasChanges = false; + + if (persona.displayName !== agent.name) { + input.name = persona.displayName; + hasChanges = true; + } + + if ((persona.avatarUrl ?? null) !== (agent.avatarUrl ?? null)) { + input.avatarUrl = persona.avatarUrl; + hasChanges = true; + } + + if (persona.systemPrompt !== (agent.systemPrompt ?? "")) { + input.systemPrompt = persona.systemPrompt; + hasChanges = true; + } + + if ((persona.model ?? null) !== (agent.model ?? null)) { + input.model = persona.model; + hasChanges = true; + } + + if (!stringRecordEqual(persona.envVars, agent.envVars)) { + input.envVars = persona.envVars; + hasChanges = true; + } + + return hasChanges ? input : null; +} + +function stringRecordEqual( + left: Record, + right: Record, +) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + + return leftKeys.every((key) => left[key] === right[key]); +} + export function useRetainedPersona( sourcePersona: AgentPersona | undefined, profileIdentityKey: string, diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 11e25da4d..35d76d2bc 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -213,6 +213,7 @@ export type RawManagedAgent = { max_turn_duration_seconds: number | null; parallelism: number; system_prompt: string | null; + avatar_url?: string | null; model: string | null; mcp_toolsets: string | null; env_vars?: Record; @@ -228,8 +229,7 @@ export type RawManagedAgent = { start_on_app_launch: boolean; backend: ManagedAgentBackend; backend_agent_id: string | null; - // Optional: pre-feature mock fixtures may omit these. Mapped to - // `"owner-only"` / `[]` in `fromRawManagedAgent`. + // Optional in pre-feature mock fixtures; mapped in fromRawManagedAgent. respond_to?: ManagedAgent["respondTo"]; respond_to_allowlist?: string[]; }; @@ -868,6 +868,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { maxTurnDurationSeconds: agent.max_turn_duration_seconds, parallelism: agent.parallelism, systemPrompt: agent.system_prompt, + avatarUrl: agent.avatar_url ?? null, model: agent.model, mcpToolsets: agent.mcp_toolsets, envVars: agent.env_vars ?? {}, @@ -883,8 +884,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { startOnAppLaunch: agent.start_on_app_launch, backend: agent.backend, backendAgentId: agent.backend_agent_id, - // Fallbacks for pre-feature mocks/fixtures that don't carry these fields. - // Real agent records always include them (defaulted server-side). + // Fallbacks for pre-feature mocks; real records default server-side. respondTo: agent.respond_to ?? "owner-only", respondToAllowlist: agent.respond_to_allowlist ?? [], }; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 653a1be99..c3d0f47f9 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -282,6 +282,7 @@ export type ManagedAgent = { maxTurnDurationSeconds: number | null; parallelism: number; systemPrompt: string | null; + avatarUrl: string | null; model: string | null; mcpToolsets: string | null; /** Per-agent env vars. Layered on top of persona envVars. */ @@ -446,6 +447,7 @@ export type AgentModelInfo = { export type UpdateManagedAgentInput = { pubkey: string; name?: string; + avatarUrl?: string | null; model?: string | null; systemPrompt?: string | null; mcpToolsets?: string | null; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 27ce8d90c..d1b07dca9 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -367,6 +367,7 @@ type RawManagedAgent = { max_turn_duration_seconds: number | null; parallelism: number; system_prompt: string | null; + avatar_url: string | null; model: string | null; env_vars?: Record; status: "running" | "stopped" | "deployed" | "not_deployed"; @@ -907,6 +908,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { max_turn_duration_seconds: agent.max_turn_duration_seconds ?? null, parallelism: agent.parallelism, system_prompt: agent.system_prompt, + avatar_url: agent.avatar_url, model: agent.model, env_vars: { ...(agent.env_vars ?? {}) }, status: agent.status, @@ -996,6 +998,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { max_turn_duration_seconds: null, parallelism: 1, system_prompt: null, + avatar_url: null, model: null, env_vars: {}, status, @@ -5230,6 +5233,7 @@ async function handleCreateManagedAgent( max_turn_duration_seconds: args.input.maxTurnDurationSeconds ?? null, parallelism: args.input.parallelism ?? 1, system_prompt: args.input.systemPrompt?.trim() || null, + avatar_url: avatarUrl, model: args.input.model?.trim() || null, env_vars: { ...(args.input.envVars ?? {}) }, status: args.input.spawnAfterCreate ? "running" : "stopped", @@ -5405,6 +5409,7 @@ async function handleUpdateManagedAgent(args: { input: { pubkey: string; name?: string; + avatarUrl?: string | null; model?: string | null; systemPrompt?: string | null; envVars?: Record; @@ -5416,6 +5421,9 @@ async function handleUpdateManagedAgent(args: { if (args.input.name !== undefined) { agent.name = args.input.name; } + if (args.input.avatarUrl !== undefined) { + agent.avatar_url = args.input.avatarUrl?.trim() || null; + } if (args.input.model !== undefined) { agent.model = args.input.model; }