Sync persona profile edits to agents

This commit is contained in:
klopez4212
2026-06-22 15:44:48 +01:00
parent 650400a16f
commit bee5314c90
12 changed files with 298 additions and 58 deletions
+25 -6
View File
@@ -17,6 +17,12 @@ use crate::{
util::now_iso,
};
fn trim_optional(value: Option<String>) -> Option<String> {
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 {
+11 -9
View File
@@ -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
@@ -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(),
@@ -230,6 +230,7 @@ pub struct ManagedAgentSummary {
pub max_turn_duration_seconds: Option<u64>,
pub parallelism: u32,
pub system_prompt: Option<String>,
pub avatar_url: Option<String>,
pub model: Option<String>,
pub mcp_toolsets: Option<String>,
#[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<String>,
/// Absent = don't touch. null = clear. "url" = set.
#[serde(default)]
pub avatar_url: Option<Option<String>>,
/// Absent = don't touch. null = clear to agent default. "id" = set.
#[serde(default)]
pub model: Option<Option<String>>,
+4 -4
View File
@@ -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();
@@ -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}
@@ -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<CreateManagedAgentResponse>;
createPersona: (input: CreatePersonaInput) => Promise<AgentPersona>;
input: CreatePersonaInput | UpdatePersonaInput;
managedAgent: ManagedAgent | undefined;
onDone: () => void;
updateManagedAgent: (
input: UpdateManagedAgentInput,
) => Promise<{ agent: ManagedAgent; profileSyncError: string | null }>;
updatePersona: (input: UpdatePersonaInput) => Promise<AgentPersona>;
};
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.",
);
}
}
@@ -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,
);
});
@@ -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<string, string>,
right: Record<string, string>,
) {
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,
+4 -4
View File
@@ -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<string, string>;
@@ -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 ?? [],
};
+2
View File
@@ -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;
+8
View File
@@ -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<string, string>;
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<string, string>;
@@ -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;
}