From 05f136f4e718b22ca93f8db55d1005b9dff9e4f6 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 22 Jun 2026 17:09:48 +0100 Subject: [PATCH] Honor persona agent overrides and provider edits --- .../src/features/agents/agentReuse.test.mjs | 72 ++++++++++++ desktop/src/features/agents/agentReuse.ts | 90 ++++++++++++++- .../features/agents/channelAgents.test.mjs | 35 +++++- desktop/src/features/agents/channelAgents.ts | 43 +++++++- .../src/features/agents/ui/PersonaDialog.tsx | 104 +++++++++++++++++- 5 files changed, 333 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/agents/agentReuse.test.mjs b/desktop/src/features/agents/agentReuse.test.mjs index 4274b671d..4bee6eee3 100644 --- a/desktop/src/features/agents/agentReuse.test.mjs +++ b/desktop/src/features/agents/agentReuse.test.mjs @@ -6,6 +6,7 @@ import { parseTimestamp, pickPreferredManagedAgent, findReusablePersonaAgent, + findReusablePersonaAgentForRequest, findReusableGenericAgent, findReusableAgent, } from "./agentReuse.ts"; @@ -18,6 +19,9 @@ function makeAgent(overrides = {}) { id: "agent-1", pubkey: PUB_A, agentCommand: "goose", + agentArgs: ["acp"], + mcpCommand: "", + backend: { type: "local" }, status: "running", personaId: null, systemPrompt: null, @@ -217,6 +221,74 @@ test("findReusablePersonaAgent: channel membership does not affect reuse", () => assert.equal(result, agent); }); +test("findReusablePersonaAgentForRequest: matches persona and requested local runtime", () => { + const agent = makeAgent({ personaId: "p1" }); + const result = findReusablePersonaAgentForRequest([agent], { + personaId: "p1", + command: "goose", + defaultArgs: ["acp"], + mcpCommand: null, + }); + assert.equal(result, agent); +}); + +test("findReusablePersonaAgentForRequest: rejects runtime command overrides", () => { + const agent = makeAgent({ personaId: "p1", agentCommand: "goose" }); + const result = findReusablePersonaAgentForRequest([agent], { + personaId: "p1", + command: "claude-acp", + defaultArgs: ["acp"], + mcpCommand: null, + }); + assert.equal(result, undefined); +}); + +test("findReusablePersonaAgentForRequest: rejects runtime arg overrides", () => { + const agent = makeAgent({ personaId: "p1", agentArgs: ["acp"] }); + const result = findReusablePersonaAgentForRequest([agent], { + personaId: "p1", + command: "goose", + defaultArgs: ["acp", "--profile", "work"], + mcpCommand: null, + }); + assert.equal(result, undefined); +}); + +test("findReusablePersonaAgentForRequest: rejects backend overrides", () => { + const agent = makeAgent({ personaId: "p1", backend: { type: "local" } }); + const result = findReusablePersonaAgentForRequest([agent], { + personaId: "p1", + command: "goose", + defaultArgs: ["acp"], + mcpCommand: null, + backend: { type: "provider", id: "remote-a", config: {} }, + }); + assert.equal(result, undefined); +}); + +test("findReusablePersonaAgentForRequest: accepts equivalent provider backend config", () => { + const agent = makeAgent({ + personaId: "p1", + backend: { + type: "provider", + id: "remote-a", + config: { beta: true, alpha: { second: 2, first: 1 } }, + }, + }); + const result = findReusablePersonaAgentForRequest([agent], { + personaId: "p1", + command: "goose", + defaultArgs: ["acp"], + mcpCommand: "", + backend: { + type: "provider", + id: "remote-a", + config: { alpha: { first: 1, second: 2 }, beta: true }, + }, + }); + assert.equal(result, agent); +}); + // --- findReusableGenericAgent --- test("findReusableGenericAgent: finds agent with matching command and no persona/prompt", () => { diff --git a/desktop/src/features/agents/agentReuse.ts b/desktop/src/features/agents/agentReuse.ts index 0066b60bd..071b16f19 100644 --- a/desktop/src/features/agents/agentReuse.ts +++ b/desktop/src/features/agents/agentReuse.ts @@ -1,4 +1,4 @@ -import type { ManagedAgent } from "@/shared/api/types"; +import type { ManagedAgent, ManagedAgentBackend } from "@/shared/api/types"; /** Inline normalization — avoids runtime dependency on @/shared/lib/pubkey. */ function normalizePubkey(pubkey: string): string { @@ -54,6 +54,66 @@ export function findReusablePersonaAgent( return pickPreferredManagedAgent(candidates); } +export type ReusablePersonaAgentRequest = { + personaId: string; + command: string; + defaultArgs: readonly string[]; + mcpCommand?: string | null; + backend?: ManagedAgentBackend; +}; + +export function findReusablePersonaAgentForRequest( + agents: ManagedAgent[], + request: ReusablePersonaAgentRequest, +): ManagedAgent | undefined { + const candidates = agents.filter((agent) => + reusablePersonaAgentMatchesRequest(agent, request), + ); + return pickPreferredManagedAgent(candidates); +} + +export function reusablePersonaAgentMatchesRequest( + agent: ManagedAgent, + request: ReusablePersonaAgentRequest, +): boolean { + return ( + agent.personaId === request.personaId && + managedAgentRuntimeMatchesRequest(agent, request) && + managedAgentBackendMatchesRequest(agent.backend, request.backend) + ); +} + +export function managedAgentRuntimeMatchesRequest( + agent: ManagedAgent, + request: Pick< + ReusablePersonaAgentRequest, + "command" | "defaultArgs" | "mcpCommand" + >, +) { + return ( + commandsMatch(agent.agentCommand, request.command) && + stringArraysEqual(agent.agentArgs, request.defaultArgs) && + normalizeOptionalCommand(agent.mcpCommand) === + normalizeOptionalCommand(request.mcpCommand) + ); +} + +export function managedAgentBackendMatchesRequest( + existing: ManagedAgentBackend, + requested: ManagedAgentBackend | undefined, +) { + const normalizedRequested = requested ?? { type: "local" as const }; + if (existing.type !== normalizedRequested.type) return false; + if (existing.type === "local") return true; + if (normalizedRequested.type !== "provider") return false; + + return ( + existing.id === normalizedRequested.id && + stableStringify(existing.config) === + stableStringify(normalizedRequested.config) + ); +} + export function findReusableGenericAgent( agents: ManagedAgent[], command: string, @@ -94,3 +154,31 @@ export function findReusableAgent( } return undefined; } + +function stringArraysEqual(left: readonly string[], right: readonly string[]) { + if (left.length !== right.length) return false; + return left.every((value, index) => value === right[index]); +} + +function normalizeOptionalCommand(value: string | null | undefined) { + return value?.trim() ?? ""; +} + +function stableStringify(value: unknown): string { + return JSON.stringify(sortJsonLikeValue(value)); +} + +function sortJsonLikeValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortJsonLikeValue); + } + if (!value || typeof value !== "object") { + return value; + } + + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, sortJsonLikeValue(entry)]), + ); +} diff --git a/desktop/src/features/agents/channelAgents.test.mjs b/desktop/src/features/agents/channelAgents.test.mjs index e8d7781a2..691be6187 100644 --- a/desktop/src/features/agents/channelAgents.test.mjs +++ b/desktop/src/features/agents/channelAgents.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { respondToUpdateForReusedAgent } from "./channelAgents.ts"; +import { + respondToUpdateForReusedAgent, + runtimeUpdateForReusedAgent, +} from "./channelAgents.ts"; const PUBKEY = "a".repeat(64); @@ -75,3 +78,33 @@ test("respondToUpdateForReusedAgent carries explicit allowlist choices", () => { }, ); }); + +test("runtimeUpdateForReusedAgent leaves matching runtime unchanged", () => { + assert.equal( + runtimeUpdateForReusedAgent(agent({ agentArgs: ["acp"] }), { + id: "goose", + label: "Goose", + command: "goose", + defaultArgs: ["acp"], + mcpCommand: null, + }), + null, + ); +}); + +test("runtimeUpdateForReusedAgent returns command fields for runtime overrides", () => { + assert.deepEqual( + runtimeUpdateForReusedAgent(agent({ agentArgs: ["acp"] }), { + id: "claude", + label: "Claude Code", + command: "claude-acp", + defaultArgs: ["--mode", "acp"], + mcpCommand: "claude-mcp", + }), + { + agentCommand: "claude-acp", + agentArgs: ["--mode", "acp"], + mcpCommand: "claude-mcp", + }, + ); +}); diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 89a82f3e1..12aa5ca3e 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -2,6 +2,8 @@ import { commandsMatch, findReusableGenericAgent, findReusablePersonaAgent, + managedAgentBackendMatchesRequest, + managedAgentRuntimeMatchesRequest, pickPreferredManagedAgent, } from "@/features/agents/agentReuse"; export { findReusableAgent } from "@/features/agents/agentReuse"; @@ -117,6 +119,27 @@ export function respondToUpdateForReusedAgent( }; } +export function runtimeUpdateForReusedAgent( + agent: ManagedAgent, + runtime: ChannelAgentRuntime, +): { agentCommand: string; agentArgs: string[]; mcpCommand: string } | null { + if ( + managedAgentRuntimeMatchesRequest(agent, { + command: runtime.command, + defaultArgs: runtime.defaultArgs, + mcpCommand: runtime.mcpCommand, + }) + ) { + return null; + } + + return { + agentCommand: runtime.command, + agentArgs: runtime.defaultArgs, + mcpCommand: runtime.mcpCommand ?? "", + }; +} + export async function attachManagedAgentToChannel( channelId: string, input: AttachManagedAgentToChannelInput, @@ -296,15 +319,29 @@ export async function createChannelManagedAgent( context.managedAgents, input.personaId, ); - if (reusable) { + // Runtime command changes can be applied to the singleton agent below. + // Backend changes cannot be updated by the managed-agent API, so a backend + // mismatch falls through to create the requested backend instance. + if ( + reusable && + managedAgentBackendMatchesRequest(reusable.backend, input.backend) + ) { // Apply the caller's respondTo settings so the user's permission // choice in the dialog is always honored, even when reusing. const respondToUpdate = respondToUpdateForReusedAgent(reusable, input); - const updatedAgent = respondToUpdate + const runtimeUpdate = runtimeUpdateForReusedAgent( + reusable, + input.runtime, + ); + const agentUpdate = { + ...(runtimeUpdate ?? {}), + ...(respondToUpdate ?? {}), + }; + const updatedAgent = Object.keys(agentUpdate).length ? ( await updateManagedAgent({ pubkey: reusable.pubkey, - ...respondToUpdate, + ...agentUpdate, }) ).agent : reusable; diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index dcbc340cc..ab97f9c8e 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -63,6 +63,7 @@ const PERSONA_LABEL_OPTIONAL_CLASS = const PERSONA_DROPDOWN_TRIGGER_CLASS = "flex min-h-11 w-full items-center justify-between gap-3 rounded-xl border border-input bg-muted/40 px-3 py-2 text-left text-sm text-muted-foreground shadow-none transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus:border-muted-foreground/50 focus:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"; const AUTO_MODEL_DROPDOWN_VALUE = "__auto_model__"; +const AUTO_PROVIDER_DROPDOWN_VALUE = "__auto_provider__"; type PersonaModelOption = { id: string; @@ -74,6 +75,14 @@ const AUTO_MODEL_OPTION: PersonaModelOption = { label: "Auto (provider default)", }; +const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [ + { id: "", label: "Auto (runtime default)" }, + { id: "anthropic", label: "Anthropic" }, + { id: "openai", label: "OpenAI" }, + { id: "openai-compat", label: "OpenAI-compatible" }, + { id: "databricks", label: "Databricks" }, +]; + const PERSONA_MODEL_OPTIONS_BY_RUNTIME: Record< string, readonly PersonaModelOption[] @@ -118,6 +127,23 @@ function getPersonaModelOptions( return [...options, { id: trimmedModel, label: `${trimmedModel} (current)` }]; } +function getPersonaProviderOptions( + currentProvider: string, +): readonly PersonaModelOption[] { + const trimmedProvider = currentProvider.trim(); + if ( + trimmedProvider.length === 0 || + PERSONA_LLM_PROVIDER_OPTIONS.some((option) => option.id === trimmedProvider) + ) { + return PERSONA_LLM_PROVIDER_OPTIONS; + } + + return [ + ...PERSONA_LLM_PROVIDER_OPTIONS, + { id: trimmedProvider, label: `${trimmedProvider} (current)` }, + ]; +} + function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) { const suffix = runtime.availability === "adapter_missing" @@ -150,6 +176,7 @@ export function PersonaDialog({ const [systemPrompt, setSystemPrompt] = React.useState(""); const [runtime, setRuntime] = React.useState(""); const [model, setModel] = React.useState(""); + const [provider, setProvider] = React.useState(""); const [envVars, setEnvVars] = React.useState({}); const [isImportingUpdate, setIsImportingUpdate] = React.useState(false); const [importErrorMessage, setImportErrorMessage] = React.useState< @@ -173,6 +200,7 @@ export function PersonaDialog({ setSystemPrompt(initialValues.systemPrompt); setRuntime(initialValues.runtime ?? ""); setModel(initialValues.model ?? ""); + setProvider(initialValues.provider ?? ""); setEnvVars("envVars" in initialValues ? (initialValues.envVars ?? {}) : {}); setImportErrorMessage(null); setIsImportingUpdate(false); @@ -293,6 +321,7 @@ export function PersonaDialog({ setSystemPrompt(""); setRuntime(""); setModel(""); + setProvider(""); setEnvVars({}); setImportErrorMessage(null); setIsImportingUpdate(false); @@ -313,11 +342,6 @@ export function PersonaDialog({ } const trimmedRuntime = runtime.trim(); - const initialRuntime = initialValues.runtime ?? ""; - const preservedProvider = - "id" in initialValues && trimmedRuntime !== initialRuntime - ? undefined - : initialValues.provider; const preservedNamePool = "namePool" in initialValues ? initialValues.namePool : undefined; const baseInput = { @@ -326,7 +350,7 @@ export function PersonaDialog({ systemPrompt: systemPrompt.trim(), runtime: trimmedRuntime || undefined, model: model.trim() || undefined, - provider: preservedProvider ?? undefined, + provider: provider.trim() || undefined, namePool: preservedNamePool, envVars, }; @@ -371,12 +395,18 @@ export function PersonaDialog({ (!isCreateMode || selectedRuntimeIsAvailable) && !isPending; const modelOptions = getPersonaModelOptions(runtime, model); + const providerOptions = getPersonaProviderOptions(provider); const selectedRuntimeLabel = runtimesLoading ? "Loading providers..." : (selectedRuntime?.label ?? "Choose a provider"); const selectedModelLabel = modelOptions.find((option) => option.id === model)?.label ?? AUTO_MODEL_OPTION.label; + const selectedProviderLabel = + providerOptions.find((option) => option.id === provider)?.label ?? + (provider.trim() + ? `${provider.trim()} (current)` + : "Auto (runtime default)"); const previewLabel = displayName.trim() || "Agent name"; const previewAvatarUrl = avatarUrl.trim() || null; const runtimeWarning = @@ -645,6 +675,68 @@ export function PersonaDialog({ +
+
+
+ + +
+
+
+