Sync persona runtime edits to agents

This commit is contained in:
klopez4212
2026-06-21 09:48:11 +01:00
parent b38729a22f
commit 37c1012132
6 changed files with 139 additions and 19 deletions

View File

@@ -301,14 +301,7 @@ pub fn parse_md_persona(md_bytes: &[u8]) -> Result<ParsedPersonaPreview, String>
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<String>, Option<String>) {
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<String>,
@@ -343,14 +346,7 @@ fn parse_lenient_md_persona(content: &str) -> Result<ParsedPersonaPreview, Strin
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| "Missing display name".to_string())?;
// Split "provider:model" into separate fields for the preview.
let model = match fields.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(fields.model.as_deref());
Ok(ParsedPersonaPreview {
display_name,
@@ -359,7 +355,7 @@ fn parse_lenient_md_persona(content: &str) -> Result<ParsedPersonaPreview, Strin
avatar_ref: fields.avatar,
runtime: fields.runtime,
model,
provider: None, // Markdown persona format does not carry llmProvider
provider,
name_pool: Vec::new(),
source_file: String::new(),
})

View File

@@ -420,6 +420,24 @@ You are Goosey.
assert_eq!(result.avatar_data_url, None);
assert_eq!(result.avatar_ref.as_deref(), Some("app-avatar:gloopies-19"));
assert_eq!(result.model.as_deref(), Some("claude-sonnet-4"));
assert_eq!(result.provider.as_deref(), Some("anthropic"));
assert_eq!(result.runtime.as_deref(), Some("goose"));
}
#[test]
fn parse_lenient_md_persona_preserves_model_provider_prefix() {
let md = r#"---
display_name: Lenient Agent
model: databricks:gpt-5
runtime: goose
---
You are lenient.
"#;
let result = parse_lenient_md_persona(md).unwrap();
assert_eq!(result.display_name, "Lenient Agent");
assert_eq!(result.model.as_deref(), Some("gpt-5"));
assert_eq!(result.provider.as_deref(), Some("databricks"));
assert_eq!(result.runtime.as_deref(), Some("goose"));
}

View File

@@ -501,6 +501,8 @@ export function UserProfilePanel({
setPersonaDialogState(null);
void personasQuery.refetch();
},
previousPersona: resolvedPersona,
runtimes: acpRuntimesQuery.data ?? [],
updateManagedAgent: updateManagedAgentMutation.mutateAsync,
updatePersona: updatePersonaMutation.mutateAsync,
});
@@ -510,6 +512,8 @@ export function UserProfilePanel({
createManagedAgentForPersona,
managedAgent,
personasQuery.refetch,
resolvedPersona,
acpRuntimesQuery.data,
updateManagedAgentMutation.mutateAsync,
updatePersonaMutation.mutateAsync,
],

View File

@@ -2,6 +2,7 @@ import { toast } from "sonner";
import { personaManagedAgentUpdate } from "@/features/profile/ui/UserProfilePanelUtils";
import type {
AcpRuntimeCatalogEntry,
AgentPersona,
CreateManagedAgentResponse,
CreatePersonaInput,
@@ -18,6 +19,8 @@ type SubmitProfilePersonaDialogOptions = {
input: CreatePersonaInput | UpdatePersonaInput;
managedAgent: ManagedAgent | undefined;
onDone: () => 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) {

View File

@@ -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,
);
});

View File

@@ -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<string, string>,
right: Record<string, string>,