Honor persona agent overrides and provider edits

This commit is contained in:
klopez4212
2026-06-22 17:09:48 +01:00
parent a666f00812
commit 05f136f4e7
5 changed files with 333 additions and 11 deletions
@@ -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", () => {
+89 -1
View File
@@ -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)]),
);
}
@@ -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",
},
);
});
+40 -3
View File
@@ -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;
@@ -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<EnvVarsValue>({});
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({
</div>
</div>
<div
aria-hidden={!modelFieldVisible}
className={cn(
"grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
modelFieldVisible
? "grid-rows-[1fr] opacity-100"
: "grid-rows-[0fr] opacity-0",
)}
>
<div className="min-h-0 overflow-hidden">
<div
className={cn(
"space-y-1.5 transition-[transform,opacity] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
modelFieldVisible
? "translate-y-0 opacity-100"
: "-translate-y-1 opacity-0",
)}
>
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-llm-provider"
>
LLM provider
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>
Optional
</span>
</label>
<Select
disabled={isPending || !modelFieldVisible}
onValueChange={(nextProvider) => {
setProvider(
nextProvider === AUTO_PROVIDER_DROPDOWN_VALUE
? ""
: nextProvider,
);
}}
value={provider.trim() || AUTO_PROVIDER_DROPDOWN_VALUE}
>
<SelectTrigger
className={PERSONA_DROPDOWN_TRIGGER_CLASS}
id="persona-llm-provider"
>
<SelectValue placeholder={selectedProviderLabel} />
</SelectTrigger>
<SelectContent
align="start"
onCloseAutoFocus={(event) => event.preventDefault()}
>
{providerOptions.map((option) => (
<SelectItem
key={option.id || AUTO_PROVIDER_DROPDOWN_VALUE}
value={option.id || AUTO_PROVIDER_DROPDOWN_VALUE}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<EnvVarsEditor
disabled={isPending}
onChange={setEnvVars}