mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Honor persona agent overrides and provider edits
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user