mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-07 18:01:07 +00:00
Compare commits
1 Commits
v0.76.0
...
v0.76.1-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b7473b1af |
113
src/acp-agent.ts
113
src/acp-agent.ts
@@ -56,7 +56,6 @@ import {
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
AccountInfo,
|
||||
AgentInfo,
|
||||
CanUseTool,
|
||||
deleteSession,
|
||||
FastModeDisabledReason,
|
||||
@@ -243,11 +242,8 @@ import {
|
||||
exitPlanModeRawOutput,
|
||||
observeExitPlanToolResults,
|
||||
} from "./exit-plan.js";
|
||||
import { DEFAULT_AGENT_ID } from "./session-config-ids.js";
|
||||
import { parseToolResultMeta } from "./tool-result-meta.js";
|
||||
import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "./usage-markdown.js";
|
||||
|
||||
export { DEFAULT_AGENT_ID } from "./session-config-ids.js";
|
||||
import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
|
||||
import {
|
||||
applyAvailableModelsAllowlist,
|
||||
@@ -814,14 +810,6 @@ export type Session = {
|
||||
/** Initial mode fallback is reported after session/new, on the first prompt. */
|
||||
autoModeFallbackWarningPending?: boolean;
|
||||
configOptions: SessionConfigOption[];
|
||||
/** Custom main-thread agent personas the user (or a plugin/project) has
|
||||
* configured, discovered via `supportedAgents()` with Claude Code's built-in
|
||||
* subagents filtered out. Empty when none are configured, in which case the
|
||||
* "agent" config option is omitted entirely. */
|
||||
agents: AgentInfo[];
|
||||
/** The currently selected main-thread agent name, or "default" for the
|
||||
* standard Claude Code agent (no `agent` flag applied). */
|
||||
currentAgent: string;
|
||||
/** Whether Fast mode is currently enabled for this session. Tracked as the
|
||||
* user's intent so it persists across model switches; the Fast mode config
|
||||
* option is only surfaced while the selected model supports it. */
|
||||
@@ -1172,13 +1160,15 @@ export type NewSessionMeta = {
|
||||
claudeCode?: {
|
||||
/**
|
||||
* Options forwarded to Claude Code when starting a new session.
|
||||
* Those parameters will be ignored and managed by ACP:
|
||||
* Those parameters will not be forwarded because they are managed by ACP:
|
||||
* - cwd
|
||||
* - includePartialMessages
|
||||
* - allowDangerouslySkipPermissions
|
||||
* - permissionMode
|
||||
* - canUseTool
|
||||
* - executable
|
||||
* The `agent` parameter is also ignored: main-thread agent selection is not
|
||||
* part of this adapter's ACP contract.
|
||||
* Those parameters will be used and updated to work with ACP:
|
||||
* - hooks (merged with ACP's hooks)
|
||||
* - mcpServers (merged with ACP's mcpServers)
|
||||
@@ -7434,8 +7424,6 @@ export class ClaudeAcpAgent {
|
||||
session.models,
|
||||
session.modelInfos,
|
||||
seedEffort,
|
||||
session.agents,
|
||||
session.currentAgent,
|
||||
{
|
||||
// The toggle follows the newly selected model: it disappears when the
|
||||
// model lacks fast support and reappears (with the retained user
|
||||
@@ -7522,19 +7510,6 @@ export class ClaudeAcpAgent {
|
||||
if (modeDowngraded) {
|
||||
await this.sessionModes.publishFallbackState(sessionId, session);
|
||||
}
|
||||
} else if (configId === AGENT_CONFIG_ID) {
|
||||
// Live agent switch — no subprocess restart needed. Apply the SDK flag
|
||||
// first so a rejected control request leaves both `currentAgent` and the
|
||||
// config option untouched (no UI/SDK desync). Passing `null` clears the
|
||||
// flag layer back to the standard Claude Code agent; the change takes
|
||||
// effect on the next turn (SDK >= 0.3.161).
|
||||
await session.query.applyFlagSettings({
|
||||
agent: value === DEFAULT_AGENT_ID ? null : value,
|
||||
});
|
||||
session.currentAgent = value;
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
||||
);
|
||||
} else if (configId === EFFORT_CONFIG_ID) {
|
||||
// Apply first so a rejected control request cannot leave the displayed
|
||||
// value ahead of the SDK flag layer.
|
||||
@@ -7898,7 +7873,13 @@ export class ClaudeAcpAgent {
|
||||
|
||||
// Extract options from _meta if provided
|
||||
const sessionMeta = params._meta as NewSessionMeta | undefined;
|
||||
const userProvidedOptions = sessionMeta?.claudeCode?.options;
|
||||
const userProvidedOptions = sessionMeta?.claudeCode?.options
|
||||
? { ...sessionMeta.claudeCode.options }
|
||||
: undefined;
|
||||
// Main-thread agent selection is intentionally not part of this adapter's
|
||||
// ACP contract. Ignore the provider-specific option instead of forwarding
|
||||
// hidden state that the Client cannot inspect or change.
|
||||
if (userProvidedOptions) delete userProvidedOptions.agent;
|
||||
const forwardSubagentText =
|
||||
clientSupportsSubagents(this.clientCapabilities) ||
|
||||
supportsSubagentTranscript(this.clientCapabilities) ||
|
||||
@@ -8304,19 +8285,6 @@ export class ClaudeAcpAgent {
|
||||
});
|
||||
timing.phase("modes");
|
||||
|
||||
const agents = await discoverCustomAgents(q);
|
||||
timing.phase("agents");
|
||||
// Only adopt the requested agent as the selected value if it's one we
|
||||
// actually surface in the picker. A built-in (filtered out above) or
|
||||
// otherwise-unknown name would leave the config option's `currentValue`
|
||||
// pointing at an entry not in its own `options` list, which clients render
|
||||
// as a blank/invalid selection.
|
||||
const requestedAgent = userProvidedOptions?.agent;
|
||||
const currentAgent =
|
||||
requestedAgent && agents.some((a) => a.name === requestedAgent)
|
||||
? requestedAgent
|
||||
: DEFAULT_AGENT_ID;
|
||||
|
||||
// Seed Fast mode from the SDK's reported state so the UI reflects reality
|
||||
// (the CLI may start a session with fast mode already on, or force it off
|
||||
// when `fastModePerSessionOptIn` is set). The toggle is only surfaced while
|
||||
@@ -8352,8 +8320,6 @@ export class ClaudeAcpAgent {
|
||||
mergeEffortSettings(settingsManager.getSettings(), configuredSettingsObject),
|
||||
currentModelInfo,
|
||||
),
|
||||
agents,
|
||||
currentAgent,
|
||||
fastMode,
|
||||
{
|
||||
useRecommendedValue,
|
||||
@@ -8426,8 +8392,6 @@ export class ClaudeAcpAgent {
|
||||
initialEffort?.currentValue === userProvidedOptions.effort
|
||||
? userProvidedOptions.effort
|
||||
: undefined,
|
||||
agents,
|
||||
currentAgent,
|
||||
fastModeEnabled,
|
||||
fastModeDisabledReason,
|
||||
abortController,
|
||||
@@ -8876,44 +8840,11 @@ function isValidBaseUrl(baseUrl: string | undefined): baseUrl is string {
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
}
|
||||
|
||||
// `supportedAgents()` always returns Claude Code's built-in subagents — the
|
||||
// ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
|
||||
// has configured none of their own. Those aren't meaningful *main-thread*
|
||||
// personas, so we filter them out and only surface the Agent picker when the
|
||||
// user (or a plugin/project) has configured custom agents. Update this set if
|
||||
// the SDK's built-in roster changes.
|
||||
export const BUILTIN_AGENT_NAMES = new Set([
|
||||
"claude",
|
||||
"general-purpose",
|
||||
"Explore",
|
||||
"Plan",
|
||||
"statusline-setup",
|
||||
]);
|
||||
|
||||
// Value of the synthetic "Default" entry in the agent picker, which maps to the
|
||||
// standard Claude Code agent (`applyFlagSettings({ agent: null })`). It is a
|
||||
// reserved sentinel: a custom agent named exactly this would collide with it
|
||||
// (two options sharing the value, selection silently routing to `null`), so we
|
||||
// exclude that name from discovery.
|
||||
/** Discover user/plugin/project-configured main-thread agents, excluding the
|
||||
* built-in subagents and the reserved "default" sentinel. Returns an empty
|
||||
* list if discovery fails so a flaky control request never blocks session
|
||||
* creation. */
|
||||
export async function discoverCustomAgents(q: Query): Promise<AgentInfo[]> {
|
||||
try {
|
||||
const agents = await q.supportedAgents();
|
||||
return agents.filter((a) => !BUILTIN_AGENT_NAMES.has(a.name) && a.name !== DEFAULT_AGENT_ID);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable ids for the session config options surfaced via `configOptions`.
|
||||
* Centralized so the option declarations in `buildConfigOptions` and the
|
||||
* handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
|
||||
* same identifiers and can't drift apart. */
|
||||
export { MODE_CONFIG_ID };
|
||||
export const AGENT_CONFIG_ID = "agent";
|
||||
export const FAST_MODE_CONFIG_ID = "fast";
|
||||
|
||||
/** Select-fallback values used when the client has not opted into boolean
|
||||
@@ -9059,8 +8990,6 @@ export function buildConfigOptions(
|
||||
models: SessionModelState,
|
||||
modelInfos: ModelInfo[],
|
||||
currentEffortLevel?: string,
|
||||
agents: AgentInfo[] = [],
|
||||
currentAgent: string = DEFAULT_AGENT_ID,
|
||||
fastMode?: FastModeOptionState,
|
||||
presentation?: ConfigOptionPresentation,
|
||||
): SessionConfigOption[] {
|
||||
@@ -9089,28 +9018,6 @@ export function buildConfigOptions(
|
||||
);
|
||||
}
|
||||
|
||||
// Only surface the Agent picker when there's a real choice — i.e. the user
|
||||
// has configured at least one custom agent (built-ins are filtered out in
|
||||
// discoverCustomAgents). With none configured, "Default" would be the only
|
||||
// entry, so we omit the option entirely.
|
||||
if (agents.length > 0) {
|
||||
options.push({
|
||||
id: AGENT_CONFIG_ID,
|
||||
name: "Agent",
|
||||
description: "Main-thread agent persona",
|
||||
type: "select",
|
||||
currentValue: currentAgent,
|
||||
options: [
|
||||
{ value: DEFAULT_AGENT_ID, name: "Default", description: "Standard Claude Code agent" },
|
||||
...agents.map((a) => ({
|
||||
value: a.name,
|
||||
name: a.name,
|
||||
description: a.description || undefined,
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
PermissionMode,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { DEFAULT_AGENT_ID, DEFAULT_MODEL_ID } from "./session-config-ids.js";
|
||||
import { DEFAULT_MODEL_ID } from "./session-config-ids.js";
|
||||
|
||||
export type ClearContextReset = {
|
||||
toolUseId: string;
|
||||
@@ -37,7 +37,6 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
|
||||
accumulatedModelUsage?: Record<string, Usage>;
|
||||
models: { currentModelId: string };
|
||||
configOptions: SessionConfigOption[];
|
||||
currentAgent: string;
|
||||
fastModeEnabled: boolean;
|
||||
effortPinnedLevel?: string;
|
||||
activeTurn?: Turn | null;
|
||||
@@ -89,7 +88,6 @@ function restartParams<Session extends ClearContextSession>(session: Session): N
|
||||
...(session.models.currentModelId !== DEFAULT_MODEL_ID
|
||||
? { model: session.models.currentModelId }
|
||||
: {}),
|
||||
...(session.currentAgent !== DEFAULT_AGENT_ID ? { agent: session.currentAgent } : {}),
|
||||
...(session.effortPinnedLevel !== undefined
|
||||
? { effort: session.effortPinnedLevel as EffortLevel }
|
||||
: {}),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/** Shared sentinels and ids for session preferences managed by the adapter. */
|
||||
export const DEFAULT_MODEL_ID = "default";
|
||||
export const DEFAULT_AGENT_ID = "default";
|
||||
export const EFFORT_CONFIG_ID = "effort";
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
messageIdForGrouping,
|
||||
buildConfigOptions,
|
||||
createFastModeConfigOption,
|
||||
discoverCustomAgents,
|
||||
runPromptWithCancellation,
|
||||
type AcpClient,
|
||||
type SDKMessageFilter,
|
||||
@@ -3221,8 +3220,6 @@ describe("permission request cancellation", () => {
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
@@ -8645,8 +8642,6 @@ describe("session/close", () => {
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
@@ -8741,8 +8736,6 @@ describe("session/delete", () => {
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
@@ -8854,8 +8847,6 @@ describe("getOrCreateSession param change detection", () => {
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
@@ -12082,8 +12073,6 @@ describe("post-error recovery", () => {
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
@@ -16655,8 +16644,6 @@ describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
@@ -18172,265 +18159,59 @@ describe("messageIdForGrouping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent selection config option", () => {
|
||||
const baseModes = { currentModeId: "default", availableModes: [] };
|
||||
const baseModels = { currentModelId: "default", availableModels: [] };
|
||||
describe("buildConfigOptions model option resolved description", () => {
|
||||
const modes = { currentModeId: "default", availableModes: [] };
|
||||
const models = {
|
||||
currentModelId: "default",
|
||||
availableModels: [{ modelId: "default", name: "Default", description: "" }],
|
||||
};
|
||||
|
||||
describe("discoverCustomAgents", () => {
|
||||
it("filters out Claude Code's built-in subagents", async () => {
|
||||
const q = {
|
||||
supportedAgents: async () => [
|
||||
{ name: "claude", description: "catch-all" },
|
||||
{ name: "Explore", description: "search" },
|
||||
{ name: "general-purpose", description: "gp" },
|
||||
{ name: "Plan", description: "architect" },
|
||||
{ name: "statusline-setup", description: "status" },
|
||||
{ name: "my-reviewer", description: "Reviews code" },
|
||||
{ name: "my-writer", description: "Writes docs" },
|
||||
],
|
||||
} as any;
|
||||
const agents = await discoverCustomAgents(q);
|
||||
expect(agents.map((a) => a.name)).toEqual(["my-reviewer", "my-writer"]);
|
||||
});
|
||||
|
||||
it("excludes a custom agent named 'default' (reserved sentinel)", async () => {
|
||||
const q = {
|
||||
supportedAgents: async () => [
|
||||
{ name: "default", description: "collides with the synthetic Default entry" },
|
||||
{ name: "my-reviewer", description: "Reviews code" },
|
||||
],
|
||||
} as any;
|
||||
const agents = await discoverCustomAgents(q);
|
||||
expect(agents.map((a) => a.name)).toEqual(["my-reviewer"]);
|
||||
});
|
||||
|
||||
it("returns an empty list when discovery throws", async () => {
|
||||
const q = {
|
||||
supportedAgents: async () => {
|
||||
throw new Error("control request failed");
|
||||
},
|
||||
} as any;
|
||||
expect(await discoverCustomAgents(q)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConfigOptions agent option", () => {
|
||||
it("omits the agent option when no custom agents are configured", () => {
|
||||
const options = buildConfigOptions(baseModes, baseModels, [], undefined, [], "default");
|
||||
expect(options.find((o) => o.id === "agent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds an agent option with a synthetic Default entry when custom agents exist", () => {
|
||||
const agents = [
|
||||
{ name: "my-reviewer", description: "Reviews code" },
|
||||
// empty description should normalize to undefined, not ""
|
||||
{ name: "my-writer", description: "" },
|
||||
];
|
||||
const options = buildConfigOptions(
|
||||
baseModes,
|
||||
baseModels,
|
||||
[],
|
||||
undefined,
|
||||
agents,
|
||||
"my-reviewer",
|
||||
);
|
||||
const agentOption = options.find((o) => o.id === "agent");
|
||||
expect(agentOption).toBeDefined();
|
||||
expect(agentOption!.currentValue).toBe("my-reviewer");
|
||||
expect(agentOption!.type).toBe("select");
|
||||
const entries = (agentOption as any).options;
|
||||
expect(entries.map((o: any) => o.value)).toEqual(["default", "my-reviewer", "my-writer"]);
|
||||
expect(entries[2].description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConfigOptions model option resolved description", () => {
|
||||
const modes = { currentModeId: "default", availableModes: [] };
|
||||
const models = {
|
||||
currentModelId: "default",
|
||||
availableModels: [{ modelId: "default", name: "Default", description: "" }],
|
||||
};
|
||||
|
||||
it("sets description to the named model's displayName when resolvedModel matches", () => {
|
||||
const modelInfos = [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
displayName: "Claude Sonnet 5",
|
||||
description: "Balanced",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
},
|
||||
];
|
||||
const options = buildConfigOptions(
|
||||
modes,
|
||||
models,
|
||||
modelInfos as any,
|
||||
undefined,
|
||||
[],
|
||||
"default",
|
||||
);
|
||||
const modelOption = options.find((o) => o.id === "model");
|
||||
const defaultEntry = (modelOption as any).options.find((o: any) => o.value === "default");
|
||||
expect(defaultEntry.description).toBe("Claude Sonnet 5");
|
||||
});
|
||||
|
||||
it("falls back to resolvedModel itself when no named model shares it", () => {
|
||||
const modelInfos = [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
resolvedModel: "claude-opus-5-20251201",
|
||||
},
|
||||
];
|
||||
const options = buildConfigOptions(
|
||||
modes,
|
||||
models,
|
||||
modelInfos as any,
|
||||
undefined,
|
||||
[],
|
||||
"default",
|
||||
);
|
||||
const modelOption = options.find((o) => o.id === "model");
|
||||
const defaultEntry = (modelOption as any).options.find((o: any) => o.value === "default");
|
||||
expect(defaultEntry.description).toBe("claude-opus-5-20251201");
|
||||
});
|
||||
|
||||
it("leaves description undefined when default model has no resolvedModel", () => {
|
||||
const modelsNoDesc = {
|
||||
currentModelId: "default",
|
||||
availableModels: [{ modelId: "default", name: "Default" }],
|
||||
};
|
||||
const modelInfos = [{ value: "default", displayName: "Default", description: "" }];
|
||||
const options = buildConfigOptions(
|
||||
modes,
|
||||
modelsNoDesc,
|
||||
modelInfos as any,
|
||||
undefined,
|
||||
[],
|
||||
"default",
|
||||
);
|
||||
const modelOption = options.find((o) => o.id === "model");
|
||||
const defaultEntry = (modelOption as any).options.find((o: any) => o.value === "default");
|
||||
expect(defaultEntry.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("switching the agent", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = { sessionUpdate: async () => {} } as unknown as AcpClient;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
const agents = [{ name: "my-reviewer", description: "Reviews code" }];
|
||||
|
||||
function injectSession(agent: ClaudeAcpAgent, sessionId: string) {
|
||||
function* empty() {}
|
||||
const applyFlagSettings = vi.fn(async () => {});
|
||||
const gen = Object.assign(empty(), {
|
||||
interrupt: vi.fn(),
|
||||
close: vi.fn(),
|
||||
applyFlagSettings,
|
||||
});
|
||||
agent.sessions[sessionId] = {
|
||||
query: gen as any,
|
||||
input: new Pushable(),
|
||||
cancelled: false,
|
||||
titles: new SessionTitles(agent, sessionId),
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: { currentModeId: "default", availableModes: [] },
|
||||
models: { currentModelId: "default", availableModels: [] },
|
||||
modelInfos: [],
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: buildConfigOptions(baseModes, baseModels, [], undefined, agents, "default"),
|
||||
agents,
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
contextWindowSize: 200000,
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
|
||||
fileChangeReportRequestIds: new Set(),
|
||||
};
|
||||
return { session: agent.sessions[sessionId]!, applyFlagSettings };
|
||||
}
|
||||
|
||||
it("applies the agent flag live without restarting the subprocess", async () => {
|
||||
const agent = createMockAgent();
|
||||
const { session, applyFlagSettings } = injectSession(agent, "s1");
|
||||
|
||||
const result = await agent.setSessionConfigOption({
|
||||
sessionId: "s1",
|
||||
configId: "agent",
|
||||
value: "my-reviewer",
|
||||
});
|
||||
|
||||
expect(applyFlagSettings).toHaveBeenCalledWith({ agent: "my-reviewer" });
|
||||
expect(session.currentAgent).toBe("my-reviewer");
|
||||
// The whole point of the SDK >= 0.3.161 approach: no process teardown.
|
||||
expect(session.query.interrupt).not.toHaveBeenCalled();
|
||||
expect(session.abortController.signal.aborted).toBe(false);
|
||||
expect(agent.sessions["s1"]).toBe(session);
|
||||
const agentOption = result.configOptions.find((o) => o.id === "agent");
|
||||
expect(agentOption?.currentValue).toBe("my-reviewer");
|
||||
});
|
||||
|
||||
it("clears the flag (agent: null) when switching back to default", async () => {
|
||||
const agent = createMockAgent();
|
||||
const { session, applyFlagSettings } = injectSession(agent, "s2");
|
||||
session.currentAgent = "my-reviewer";
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: "s2",
|
||||
configId: "agent",
|
||||
it("sets description to the named model's displayName when resolvedModel matches", () => {
|
||||
const modelInfos = [
|
||||
{
|
||||
value: "default",
|
||||
});
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
displayName: "Claude Sonnet 5",
|
||||
description: "Balanced",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
},
|
||||
];
|
||||
const options = buildConfigOptions(modes, models, modelInfos as any, undefined);
|
||||
const modelOption = options.find((o) => o.id === "model");
|
||||
const defaultEntry = (modelOption as any).options.find((o: any) => o.value === "default");
|
||||
expect(defaultEntry.description).toBe("Claude Sonnet 5");
|
||||
});
|
||||
|
||||
expect(applyFlagSettings).toHaveBeenCalledWith({ agent: null });
|
||||
expect(session.currentAgent).toBe("default");
|
||||
});
|
||||
it("falls back to resolvedModel itself when no named model shares it", () => {
|
||||
const modelInfos = [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
resolvedModel: "claude-opus-5-20251201",
|
||||
},
|
||||
];
|
||||
const options = buildConfigOptions(modes, models, modelInfos as any, undefined);
|
||||
const modelOption = options.find((o) => o.id === "model");
|
||||
const defaultEntry = (modelOption as any).options.find((o: any) => o.value === "default");
|
||||
expect(defaultEntry.description).toBe("claude-opus-5-20251201");
|
||||
});
|
||||
|
||||
it("leaves tracked state untouched when the live switch is rejected", async () => {
|
||||
const agent = createMockAgent();
|
||||
const { session, applyFlagSettings } = injectSession(agent, "s3");
|
||||
applyFlagSettings.mockRejectedValueOnce(new Error("control channel closed"));
|
||||
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: "s3",
|
||||
configId: "agent",
|
||||
value: "my-reviewer",
|
||||
}),
|
||||
).rejects.toThrow("control channel closed");
|
||||
|
||||
// The flag never applied, so neither currentAgent nor the config option
|
||||
// moves — no desync with the agent the SDK is actually running.
|
||||
expect(session.currentAgent).toBe("default");
|
||||
const agentOption = session.configOptions.find((o) => o.id === "agent");
|
||||
expect(agentOption?.currentValue).toBe("default");
|
||||
});
|
||||
it("leaves description undefined when default model has no resolvedModel", () => {
|
||||
const modelsNoDesc = {
|
||||
currentModelId: "default",
|
||||
availableModels: [{ modelId: "default", name: "Default" }],
|
||||
};
|
||||
const modelInfos = [{ value: "default", displayName: "Default", description: "" }];
|
||||
const options = buildConfigOptions(modes, modelsNoDesc, modelInfos as any, undefined);
|
||||
const modelOption = options.find((o) => o.id === "model");
|
||||
const defaultEntry = (modelOption as any).options.find((o: any) => o.value === "default");
|
||||
expect(defaultEntry.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { discoverCustomAgents, BUILTIN_AGENT_NAMES } from "../acp-agent.js";
|
||||
|
||||
// `discoverCustomAgents` distinguishes user/plugin-configured agents from
|
||||
// Claude Code's built-in subagents by name (BUILTIN_AGENT_NAMES), since the
|
||||
// SDK's `AgentInfo` carries no built-in/source flag. That hardcoded set drifts
|
||||
// silently if the SDK changes its default roster: a newly added built-in would
|
||||
// leak into the ACP "Agent" picker as if the user had configured it. This
|
||||
// integration test runs the real SDK so the set is flagged the moment it does.
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("agent discovery (SDK)", () => {
|
||||
it("BUILTIN_AGENT_NAMES exactly covers the SDK's default agent roster", async () => {
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
const q = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
sessionId: randomUUID(),
|
||||
// No setting sources → no user/project/local custom agents are loaded,
|
||||
// so `supportedAgents()` reports exactly the intrinsic built-ins
|
||||
// regardless of what the developer has in ~/.claude/agents.
|
||||
settingSources: [],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const reported = (await q.supportedAgents()).map((a) => a.name).sort();
|
||||
expect(reported).toEqual([...BUILTIN_AGENT_NAMES].sort());
|
||||
|
||||
// With the built-ins being the only agents present, discovery surfaces
|
||||
// nothing — the ACP "Agent" picker is correctly omitted.
|
||||
expect(await discoverCustomAgents(q)).toEqual([]);
|
||||
} finally {
|
||||
input.end();
|
||||
q.return(undefined);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -28,7 +28,6 @@ function testSession(overrides: Partial<TestSession> = {}): TestSession {
|
||||
},
|
||||
models: { currentModelId: "default" },
|
||||
configOptions: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
input: { push: vi.fn() },
|
||||
...overrides,
|
||||
@@ -77,7 +76,6 @@ describe("continuePlanInFreshContext", () => {
|
||||
turnQueue: [turn],
|
||||
pendingExitPlanContextReset: reset,
|
||||
models: { currentModelId: "claude-sonnet" },
|
||||
currentAgent: "reviewer",
|
||||
fastModeEnabled: true,
|
||||
effortPinnedLevel: "high",
|
||||
configOptions: [
|
||||
@@ -109,7 +107,6 @@ describe("continuePlanInFreshContext", () => {
|
||||
options: expect.objectContaining({
|
||||
env: { PRESERVED: "yes" },
|
||||
model: "claude-sonnet",
|
||||
agent: "reviewer",
|
||||
effort: "high",
|
||||
}),
|
||||
},
|
||||
@@ -189,7 +186,6 @@ describe("continuePlanInFreshContext", () => {
|
||||
const oldSession = testSession({
|
||||
activeTurn: turn,
|
||||
models: { currentModelId: "default" },
|
||||
currentAgent: "default",
|
||||
configOptions: [
|
||||
{
|
||||
id: "effort",
|
||||
|
||||
@@ -125,6 +125,21 @@ describe("createSession options merging", () => {
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("ignores the provider-specific main-thread agent option", async () => {
|
||||
const response = await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: { agent: "reviewer" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions).not.toHaveProperty("agent");
|
||||
expect(response.configOptions?.some((option) => option.id === "agent")).toBe(false);
|
||||
});
|
||||
|
||||
it("works when user provides empty disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
|
||||
@@ -151,7 +151,7 @@ describe("normalizeFastModeDisabledReason", () => {
|
||||
|
||||
describe("buildConfigOptions Fast mode", () => {
|
||||
it("omits the Fast mode option when the model does not support it", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, {
|
||||
supported: false,
|
||||
enabled: false,
|
||||
useBooleanOption: true,
|
||||
@@ -160,12 +160,12 @@ describe("buildConfigOptions Fast mode", () => {
|
||||
});
|
||||
|
||||
it("omits the Fast mode option when no fast mode state is provided", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default");
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined);
|
||||
expect(options.find((o) => o.id === FAST_MODE_CONFIG_ID)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces a boolean toggle when supported and the client opted in", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, {
|
||||
supported: true,
|
||||
enabled: true,
|
||||
useBooleanOption: true,
|
||||
@@ -174,7 +174,7 @@ describe("buildConfigOptions Fast mode", () => {
|
||||
});
|
||||
|
||||
it("surfaces a select fallback when supported but the client did not opt in", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, {
|
||||
supported: true,
|
||||
enabled: false,
|
||||
useBooleanOption: false,
|
||||
|
||||
@@ -136,8 +136,6 @@ describe("buildConfigOptions recommended values presentation", () => {
|
||||
MODELS,
|
||||
MODEL_INFOS,
|
||||
undefined,
|
||||
[],
|
||||
"default",
|
||||
undefined,
|
||||
RECOMMENDED_PRESENTATION,
|
||||
);
|
||||
@@ -176,8 +174,6 @@ describe("buildConfigOptions recommended values presentation", () => {
|
||||
{ ...MODELS, currentModelId: "opus" },
|
||||
MODEL_INFOS,
|
||||
"high",
|
||||
[],
|
||||
"default",
|
||||
undefined,
|
||||
RECOMMENDED_PRESENTATION,
|
||||
);
|
||||
@@ -198,8 +194,6 @@ describe("buildConfigOptions recommended values presentation", () => {
|
||||
MODELS,
|
||||
MODEL_INFOS.filter((model) => model.value === "default"),
|
||||
undefined,
|
||||
[],
|
||||
"default",
|
||||
undefined,
|
||||
RECOMMENDED_PRESENTATION,
|
||||
);
|
||||
@@ -223,8 +217,6 @@ describe("buildConfigOptions recommended values presentation", () => {
|
||||
MODELS,
|
||||
infos,
|
||||
undefined,
|
||||
[],
|
||||
"default",
|
||||
undefined,
|
||||
RECOMMENDED_PRESENTATION,
|
||||
);
|
||||
@@ -248,8 +240,6 @@ describe("buildConfigOptions recommended values presentation", () => {
|
||||
MODELS,
|
||||
infos,
|
||||
undefined,
|
||||
[],
|
||||
"default",
|
||||
undefined,
|
||||
RECOMMENDED_PRESENTATION,
|
||||
);
|
||||
@@ -269,8 +259,6 @@ describe("buildConfigOptions recommended values presentation", () => {
|
||||
MODELS,
|
||||
infos,
|
||||
undefined,
|
||||
[],
|
||||
"default",
|
||||
undefined,
|
||||
RECOMMENDED_PRESENTATION,
|
||||
);
|
||||
|
||||
@@ -74,8 +74,6 @@ export function mockSessionState(
|
||||
accumulatedModelUsage: {},
|
||||
lastModelUsageReading: {},
|
||||
configOptions: [],
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
|
||||
Reference in New Issue
Block a user