Compare commits

...

12 Commits

Author SHA1 Message Date
Steffen Deusch
c40d7bc30f fix session close method name (#431)
that's the nice part about unit tests, all green and still broken...

Relates to https://github.com/zed-industries/claude-agent-acp/pull/409.
2026-03-16 19:06:55 +01:00
Ben Brandt
152fe91152 Prep 0.22.0 release (#430) 2026-03-16 15:48:04 +00:00
Ben Brandt
45de3d2847 Use stable list sessions method (#429) 2026-03-16 15:41:27 +00:00
Ben Brandt
c7ae07da6b Use correct Claude CLI path for static binaries (#428)
Closes #424
2026-03-16 15:30:24 +00:00
Ben Brandt
0af467c400 Update claude-agent-sdk to 0.2.76 (#427)
Closes #420
2026-03-16 15:18:51 +00:00
mudrii
f4ce899223 fix: resolve model aliases in setSessionConfigOption (#401) (#403)
## Summary
Fixes #401 by enabling model alias/fuzzy resolution in
`setSessionConfigOption` when updating the `model` config option.

## Root cause
`setSessionConfigOption` validated model values using an exact string
match against config option values (model IDs like `claude-opus-4-5`):

```ts
const validValue = allValues.find((o) => o.value === params.value);
```

Clients often send human-friendly model preferences (`opus`, `sonnet`,
`Claude Sonnet`), which failed this exact check and produced:

- `Invalid value for config option model: <value>`

The file already had `resolveModelPreference(...)`, which supports
exact/display/substring/tokenized matching, but that logic was only used
in model initialization paths and not in `setSessionConfigOption`.

## What changed
### 1) Fuzzy model resolution in `setSessionConfigOption`
- Kept the existing exact-match validation path.
- When `configId === "model"` and exact match fails, map config option
entries to `ModelInfo[]` (`value` + `displayName`) and call
`resolveModelPreference(...)`.
- If resolution succeeds, use the resolved canonical model ID
(`resolvedValue`) for:
  - `query.setModel(...)`
  - `session.configOptions` current value update
  - response payload (`configOptions`)

### 2) Notification behavior unchanged by design
- Intentionally **did not** add `updateConfigOption(...)` in
`setSessionConfigOption`.
- This preserves existing behavior/tests that verify no
`config_option_update` notification is sent for this RPC (response
already returns full `configOptions`).

## Tests
Added focused coverage in `src/tests/session-config-options.test.ts`
under `describe("setSessionConfigOption")`:
- resolves model alias `opus` -> `claude-opus-4-5`
- resolves model alias `sonnet` -> `claude-sonnet-4-5`
- resolves display name `Claude Sonnet` -> `claude-sonnet-4-5`
- still accepts exact model ID
- still throws for invalid model value (`gpt-4`)

## Behavior before / after
### Before
- `setSessionConfigOption({ configId: "model", value: "opus" })` ->
error

### After
- same call resolves to canonical ID and succeeds:
  - `setModel("claude-opus-4-5")`
  - returned `configOptions.model.currentValue === "claude-opus-4-5"`

## Verification
- `npm run build` ✅
- `npm test` ✅ (all tests passing)
2026-03-16 16:05:01 +01:00
Ben Brandt
31539b4ee9 Reuse existing sessions for load/resume if possible (#426)
Closes #415

Add `getOrCreateSession` so resume/load returns existing session config
without recreating the session. Replace ad-hoc permission tracking with
`modes.currentModeId` and centralize config updates through
`syncSessionConfigState` to keep `modes` and `models` in sync with
config option changes. Update tests for the new session shape.
2026-03-16 15:59:45 +01:00
Ben Brandt
61a39ea801 Remove interrupt flag from deny responses (#425)
Now that the models are doing more parallel tool calls, we don't
necessarily want to interrupt in all of these cases.

Closes #402
2026-03-16 14:04:38 +00:00
Shivansh
c0b7752b45 Allow Bypass permissions mode after Exiting plan (#410)
Adds a new `ExitPlanMode` choice so users can switch directly to
`bypassPermissions` after accepting a plan, instead of only
`acceptEdits` or `default`.

### Changes
- Updated the `ExitPlanMode` permission options in `src/acp-agent.ts` to
conditionally include:
- `Yes, and bypass permissions` (`optionId: "bypassPermissions"`)
- Reused existing `ALLOW_BYPASS` guard so the option is only shown when
bypass is supported.
- Updated selection handling so `bypassPermissions` is treated as a
valid accepted outcome and applies mode updates like the existing paths.
2026-03-13 16:01:46 +00:00
Ben Brandt
3002539e4a Fix test (#416) 2026-03-13 15:58:18 +00:00
Steffen Deusch
87a0886a34 Don't get out of sync when background task creates new init/result (try 2) (#400)
Followup for
https://github.com/zed-industries/claude-agent-acp/pull/386.

When sending `/compact`, there's no replay for the user message, so we
did not resolve the prompt. Same for `/context`.

I tried what happens when sending a `/context` or `/compact` while
another prompt is running and Claude does not properly handle it.
Instead, it responds with something like

> I also noticed you sent /compact while I was working. That's a
built-in CLI command (not a skill), which compacts the conversation
context. It should be invoked directly in the CLI rather than as a
message to me.

The important part is
f52f214937.
2026-03-13 16:55:42 +01:00
Steffen Deusch
2193a13a3b Add session/close support (#409)
~Waiting for
https://github.com/agentclientprotocol/agent-client-protocol/pull/723.~

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-03-13 16:53:22 +01:00
7 changed files with 1108 additions and 989 deletions

View File

@@ -1,5 +1,17 @@
# Changelog
## 0.22.0
- Use stable list sessions method (#429)
- Use correct Claude CLI path for static binaries (#428)
- Update claude-agent-sdk to 0.2.76 (#427)
- fix: resolve model aliases in setSessionConfigOption (#401) (#403)
- Reuse existing sessions for load/resume if possible (#426)
- Remove interrupt flag from deny responses (#425)
- Allow Bypass permissions mode after Exiting plan (#410)
- Don't get out of sync when background task creates new init/result (try 2) (#400)
- Add session/close support (#409)
## 0.21.0
- Update to claude-agent-sdk 0.2.71

1536
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.21.0",
"version": "0.22.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -60,23 +60,22 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.15.0",
"@anthropic-ai/claude-agent-sdk": "0.2.71",
"@agentclientprotocol/sdk": "0.16.1",
"@anthropic-ai/claude-agent-sdk": "0.2.76",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"@anthropic-ai/claude-code": "2.1.71",
"@anthropic-ai/sdk": "0.78.0",
"@eslint/js": "10.0.1",
"@types/node": "25.3.5",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"@types/node": "25.5.0",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@typescript-eslint/parser": "8.57.0",
"eslint": "10.0.3",
"eslint-config-prettier": "10.1.8",
"globals": "17.4.0",
"prettier": "3.8.1",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "4.0.18"
"vitest": "4.1.0"
}
}

View File

@@ -34,6 +34,8 @@ import {
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
CloseSessionRequest,
CloseSessionResponse,
TerminalHandle,
TerminalOutputResponse,
WriteTextFileRequest,
@@ -110,13 +112,15 @@ type Session = {
input: Pushable<SDKUserMessage>;
cancelled: boolean;
cwd: string;
permissionMode: PermissionMode;
settingsManager: SettingsManager;
accumulatedUsage: AccumulatedUsage;
modes: SessionModeState;
models: SessionModelState;
configOptions: SessionConfigOption[];
promptRunning: boolean;
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>;
nextPendingOrder: number;
abortController: AbortController;
};
type BackgroundTerminal =
@@ -338,6 +342,7 @@ export class ClaudeAcpAgent implements Agent {
fork: {},
list: {},
resume: {},
close: {},
},
},
agentInfo: {
@@ -394,34 +399,17 @@ export class ClaudeAcpAgent implements Agent {
}
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
const response = await this.createSession(
{
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
_meta: params._meta,
},
{
resume: params.sessionId,
},
);
const result = await this.getOrCreateSession(params);
// Needs to happen after we return the session
setTimeout(() => {
this.sendAvailableCommandsUpdate(response.sessionId);
this.sendAvailableCommandsUpdate(params.sessionId);
}, 0);
return response;
return result;
}
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
const response = await this.createSession(
{
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
_meta: params._meta,
},
{
resume: params.sessionId,
},
);
const result = await this.getOrCreateSession(params);
await this.replaySessionHistory(params.sessionId);
@@ -430,14 +418,10 @@ export class ClaudeAcpAgent implements Agent {
this.sendAvailableCommandsUpdate(params.sessionId);
}, 0);
return {
modes: response.modes,
models: response.models,
configOptions: response.configOptions,
};
return result;
}
async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
const sdk_sessions = await listSessions({ dir: params.cwd ?? undefined });
const sessions = [];
@@ -481,17 +465,23 @@ export class ClaudeAcpAgent implements Agent {
const userMessage = promptToClaude(params);
const promptUuid = randomUUID();
userMessage.uuid = promptUuid;
let promptReplayed = false;
if (session.promptRunning) {
const uuid = randomUUID();
userMessage.uuid = uuid;
session.input.push(userMessage);
const order = session.nextPendingOrder++;
const cancelled = await new Promise<boolean>((resolve) => {
session.pendingMessages.set(uuid, { resolve, order });
session.pendingMessages.set(promptUuid, { resolve, order });
});
if (cancelled) {
return { stopReason: "cancelled" };
}
// The replay resolved the promise, mark in this loop too,
// so we don't treat the next result as a background task's result.
promptReplayed = true;
} else {
session.input.push(userMessage);
}
@@ -538,6 +528,7 @@ export class ClaudeAcpAgent implements Agent {
content: { type: "text", text: "\n\nCompacting completed." },
},
});
promptReplayed = true;
break;
}
case "local_command_output": {
@@ -548,6 +539,7 @@ export class ClaudeAcpAgent implements Agent {
content: { type: "text", text: message.content },
},
});
promptReplayed = true;
break;
}
case "hook_started":
@@ -566,10 +558,6 @@ export class ClaudeAcpAgent implements Agent {
}
break;
case "result": {
if (session.cancelled) {
return { stopReason: "cancelled" };
}
// Accumulate usage from this result
session.accumulatedUsage.inputTokens += message.usage.input_tokens;
session.accumulatedUsage.outputTokens += message.usage.output_tokens;
@@ -597,6 +585,18 @@ export class ClaudeAcpAgent implements Agent {
});
}
if (!promptReplayed) {
// This result is from a background task that finished after
// the previous prompt loop ended. Consume it and continue
// waiting for our own prompt's result.
this.logger.log(`Session ${params.sessionId}: consuming background task result`);
break;
}
if (session.cancelled) {
return { stopReason: "cancelled" };
}
// Build the usage response
const usage: PromptResponse["usage"] = {
inputTokens: session.accumulatedUsage.inputTokens,
@@ -672,8 +672,14 @@ export class ClaudeAcpAgent implements Agent {
break;
}
// Check for queued prompt replay
// Check for prompt replay
if (message.type === "user" && "uuid" in message && message.uuid) {
if (message.uuid === promptUuid) {
// Our own prompt was replayed back — we're now processing
// our prompt's response (not a background task's).
promptReplayed = true;
break;
}
const pending = session.pendingMessages.get(message.uuid as string);
if (pending) {
pending.resolve(false);
@@ -844,6 +850,19 @@ export class ClaudeAcpAgent implements Agent {
await session.query.interrupt();
}
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
const session = this.sessions[params.sessionId];
if (!session) {
throw new Error("Session not found");
}
await this.cancel({ sessionId: params.sessionId });
session.abortController.abort();
delete this.sessions[params.sessionId];
return {};
}
async unstable_setSessionModel(
params: SetSessionModelRequest,
): Promise<SetSessionModelResponse | void> {
@@ -871,6 +890,9 @@ export class ClaudeAcpAgent implements Agent {
if (!session) {
throw new Error("Session not found");
}
if (typeof params.value !== "string") {
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
}
const option = session.configOptions.find((o) => o.id === params.configId);
if (!option) {
@@ -881,26 +903,50 @@ export class ClaudeAcpAgent implements Agent {
"options" in option && Array.isArray(option.options)
? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
: [];
const validValue = allValues.find((o) => o.value === params.value);
let validValue = allValues.find((o) => o.value === params.value);
// For model options, fall back to resolveModelPreference when the exact
// value doesn't match. This lets callers use human-friendly aliases like
// "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
if (!validValue && params.configId === "model") {
const modelInfos: ModelInfo[] = allValues.map((o) => ({
value: o.value,
displayName: o.name,
description: o.description ?? "",
}));
const resolved = resolveModelPreference(modelInfos, params.value);
if (resolved) {
validValue = allValues.find((o) => o.value === resolved.value);
}
}
if (!validValue) {
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
}
// Use the canonical option value so downstream code always receives the
// model ID rather than the caller-supplied alias.
const resolvedValue = validValue.value;
if (params.configId === "mode") {
await this.applySessionMode(params.sessionId, params.value);
await this.applySessionMode(params.sessionId, resolvedValue);
await this.client.sessionUpdate({
sessionId: params.sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: params.value,
currentModeId: resolvedValue,
},
});
} else if (params.configId === "model") {
await this.sessions[params.sessionId].query.setModel(params.value);
await this.sessions[params.sessionId].query.setModel(resolvedValue);
}
this.syncSessionConfigState(session, params.configId, params.value);
session.configOptions = session.configOptions.map((o) =>
o.id === params.configId ? { ...o, currentValue: params.value } : o,
o.id === params.configId && typeof o.currentValue === "string"
? { ...o, currentValue: resolvedValue }
: o,
);
return { configOptions: session.configOptions };
@@ -917,7 +963,6 @@ export class ClaudeAcpAgent implements Agent {
default:
throw new Error("Invalid Mode");
}
this.sessions[sessionId].permissionMode = modeId;
try {
await this.sessions[sessionId].query.setPermissionMode(modeId);
} catch (error) {
@@ -976,21 +1021,29 @@ export class ClaudeAcpAgent implements Agent {
return {
behavior: "deny",
message: "Session not found",
interrupt: true,
};
}
if (toolName === "ExitPlanMode") {
const options = [
{
kind: "allow_always",
name: "Yes, and auto-accept edits",
optionId: "acceptEdits",
},
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
];
if (ALLOW_BYPASS) {
options.unshift({
kind: "allow_always",
name: "Yes, and bypass permissions",
optionId: "bypassPermissions",
});
}
const response = await this.client.requestPermission({
options: [
{
kind: "allow_always",
name: "Yes, and auto-accept edits",
optionId: "acceptEdits",
},
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
],
options,
sessionId,
toolCall: {
toolCallId: toolUseID,
@@ -1008,9 +1061,10 @@ export class ClaudeAcpAgent implements Agent {
}
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
(response.outcome.optionId === "default" ||
response.outcome.optionId === "acceptEdits" ||
response.outcome.optionId === "bypassPermissions")
) {
session.permissionMode = response.outcome.optionId;
await this.client.sessionUpdate({
sessionId,
update: {
@@ -1031,12 +1085,11 @@ export class ClaudeAcpAgent implements Agent {
return {
behavior: "deny",
message: "User rejected request to exit plan mode.",
interrupt: true,
};
}
}
if (session.permissionMode === "bypassPermissions") {
if (session.modes.currentModeId === "bypassPermissions") {
return {
behavior: "allow",
updatedInput: toolInput,
@@ -1097,7 +1150,6 @@ export class ClaudeAcpAgent implements Agent {
return {
behavior: "deny",
message: "User refused permission to run tool",
interrupt: true,
};
}
};
@@ -1124,8 +1176,10 @@ export class ClaudeAcpAgent implements Agent {
const session = this.sessions[sessionId];
if (!session) return;
this.syncSessionConfigState(session, configId, value);
session.configOptions = session.configOptions.map((o) =>
o.id === configId ? { ...o, currentValue: value } : o,
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
await this.client.sessionUpdate({
@@ -1137,6 +1191,49 @@ export class ClaudeAcpAgent implements Agent {
});
}
private syncSessionConfigState(session: Session, configId: string, value: string): void {
if (configId === "mode") {
session.modes = { ...session.modes, currentModeId: value };
} else if (configId === "model") {
session.models = { ...session.models, currentModelId: value };
}
}
private async getOrCreateSession(params: {
sessionId: string;
cwd: string;
mcpServers?: NewSessionRequest["mcpServers"];
_meta?: NewSessionRequest["_meta"];
}): Promise<NewSessionResponse> {
const existingSession = this.sessions[params.sessionId];
if (existingSession) {
return {
sessionId: params.sessionId,
modes: existingSession.modes,
models: existingSession.models,
configOptions: existingSession.configOptions,
};
}
const response = await this.createSession(
{
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
_meta: params._meta,
},
{
resume: params.sessionId,
},
);
return {
sessionId: response.sessionId,
modes: response.modes,
models: response.models,
configOptions: response.configOptions,
};
}
private async createSession(
params: NewSessionRequest,
creationOpts: { resume?: string; forkSession?: boolean } = {},
@@ -1220,6 +1317,8 @@ export class ClaudeAcpAgent implements Agent {
userProvidedOptions?.tools ??
(params._meta?.disableBuiltInTools === true ? [] : { type: "preset", preset: "claude_code" });
const abortController = userProvidedOptions?.abortController || new AbortController();
const options: Options = {
systemPrompt,
settingSources: ["user", "project", "local"],
@@ -1245,7 +1344,7 @@ export class ClaudeAcpAgent implements Agent {
...(process.env.CLAUDE_CODE_EXECUTABLE
? { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE }
: isStaticBinary()
? { pathToClaudeCodeExecutable: process.execPath }
? { pathToClaudeCodeExecutable: await claudeCliPath() }
: {}),
extraArgs: {
...userProvidedOptions?.extraArgs,
@@ -1261,10 +1360,6 @@ export class ClaudeAcpAgent implements Agent {
hooks: [
createPostToolUseHook(this.logger, {
onEnterPlanMode: async () => {
const session = this.sessions[sessionId];
if (session) {
session.permissionMode = "plan";
}
await this.client.sessionUpdate({
sessionId,
update: {
@@ -1280,6 +1375,7 @@ export class ClaudeAcpAgent implements Agent {
],
},
...creationOpts,
abortController,
};
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
@@ -1288,7 +1384,6 @@ export class ClaudeAcpAgent implements Agent {
}
// Handle abort controller from meta options
const abortController = userProvidedOptions?.abortController;
if (abortController?.signal.aborted) {
throw new Error("Cancelled");
}
@@ -1364,7 +1459,6 @@ export class ClaudeAcpAgent implements Agent {
input: input,
cancelled: false,
cwd: params.cwd,
permissionMode,
settingsManager,
accumulatedUsage: {
inputTokens: 0,
@@ -1372,10 +1466,13 @@ export class ClaudeAcpAgent implements Agent {
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
modes,
models,
configOptions,
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController,
};
return {

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import { spawn, spawnSync } from "child_process";
import {
Agent,
@@ -1305,18 +1305,37 @@ describe("stop reason propagation", () => {
};
}
function* messageGenerator(messages: any[]) {
yield* messages;
}
function injectSession(agent: ClaudeAcpAgent, messages: any[]) {
const gen = messageGenerator(messages);
const input = new Pushable<any>();
async function* messageGenerator() {
// Wait for the prompt to push its user message so we can replay it
const iter = input[Symbol.asyncIterator]();
const { value: userMessage, done } = await iter.next();
if (!done && userMessage) {
yield {
type: "user",
message: userMessage.message,
parent_tool_use_id: null,
uuid: userMessage.uuid,
session_id: "test-session",
isReplay: true,
};
}
yield* messages;
}
agent.sessions["test-session"] = {
query: gen as any,
input: new Pushable(),
query: messageGenerator() as any,
input,
cancelled: false,
cwd: "/test",
permissionMode: "default",
modes: {
currentModeId: "default",
availableModes: [],
},
models: {
currentModelId: "default",
availableModels: [],
},
settingsManager: {} as any,
accumulatedUsage: {
inputTokens: 0,
@@ -1328,6 +1347,7 @@ describe("stop reason propagation", () => {
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
};
}
@@ -1397,6 +1417,88 @@ describe("stop reason propagation", () => {
expect(response.stopReason).toBe("end_turn");
});
it("should consume background task results and return the prompt's own result", async () => {
const agent = createMockAgent();
const input = new Pushable<any>();
const backgroundTaskResult = createResultMessage({
subtype: "success",
stop_reason: null,
is_error: false,
});
// Background task used some tokens
backgroundTaskResult.usage.input_tokens = 100;
backgroundTaskResult.usage.output_tokens = 50;
const promptResult = createResultMessage({
subtype: "success",
stop_reason: null,
is_error: false,
});
async function* messageGenerator() {
// Background task init + result arrive before our prompt's replay
yield { type: "system", subtype: "init", session_id: "test-session" };
yield backgroundTaskResult;
// Now the prompt's user message replay arrives
const iter = input[Symbol.asyncIterator]();
const { value: userMessage } = await iter.next();
yield {
type: "user",
message: userMessage.message,
parent_tool_use_id: null,
uuid: userMessage.uuid,
session_id: "test-session",
isReplay: true,
};
// Then the prompt's own result
yield promptResult;
}
agent.sessions["test-session"] = {
query: messageGenerator() as any,
input,
cwd: "/tmp/test",
cancelled: false,
modes: {
currentModeId: "default",
availableModes: [],
},
models: {
currentModelId: "default",
availableModels: [],
},
settingsManager: {} as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
abortController: new AbortController(),
configOptions: [],
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
};
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("end_turn");
// Usage should include both background task and prompt result tokens
expect(response.usage?.inputTokens).toBe(
backgroundTaskResult.usage.input_tokens + promptResult.usage.input_tokens,
);
expect(response.usage?.outputTokens).toBe(
backgroundTaskResult.usage.output_tokens + promptResult.usage.output_tokens,
);
});
it("should throw internal error for success with is_error true and no max_tokens", async () => {
const agent = createMockAgent();
injectSession(agent, [
@@ -1416,3 +1518,87 @@ describe("stop reason propagation", () => {
).rejects.toThrow("Internal error");
});
});
describe("session/close", () => {
function createMockAgent() {
const mockClient = {
sessionUpdate: async () => {},
} as unknown as AgentSideConnection;
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
}
function injectSession(agent: ClaudeAcpAgent, sessionId: string) {
function* empty() {}
const gen = Object.assign(empty(), { interrupt: vi.fn() });
agent.sessions[sessionId] = {
query: gen as any,
input: new Pushable(),
cancelled: false,
cwd: "/test",
modes: {
currentModeId: "default",
availableModes: [],
},
models: {
currentModelId: "default",
availableModels: [],
},
settingsManager: {} as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
configOptions: [],
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
};
return agent.sessions[sessionId]!;
}
it("should close an existing session and remove it", async () => {
const agent = createMockAgent();
const session = injectSession(agent, "session-1");
expect(agent.sessions["session-1"]).toBeDefined();
const result = await agent.unstable_closeSession({ sessionId: "session-1" });
expect(result).toEqual({});
expect(agent.sessions["session-1"]).toBeUndefined();
expect(session.query.interrupt).toHaveBeenCalled();
});
it("should abort the session's abort controller", async () => {
const agent = createMockAgent();
const session = injectSession(agent, "session-2");
expect(session.abortController.signal.aborted).toBe(false);
await agent.unstable_closeSession({ sessionId: "session-2" });
expect(session.abortController.signal.aborted).toBe(true);
});
it("should throw when closing a non-existent session", async () => {
const agent = createMockAgent();
await expect(agent.unstable_closeSession({ sessionId: "non-existent" })).rejects.toThrow(
"Session not found",
);
});
it("should not affect other sessions when closing one", async () => {
const agent = createMockAgent();
injectSession(agent, "session-a");
injectSession(agent, "session-b");
await agent.unstable_closeSession({ sessionId: "session-a" });
expect(agent.sessions["session-a"]).toBeUndefined();
expect(agent.sessions["session-b"]).toBeDefined();
});
});

View File

@@ -224,6 +224,61 @@ describe("session config options", () => {
expect(configUpdate).toBeUndefined();
});
it("resolves model alias 'opus' to full model ID", async () => {
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "opus",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-5");
const modelOption = response.configOptions.find((o) => o.id === "model");
expect(modelOption?.currentValue).toBe("claude-opus-4-5");
});
it("resolves model alias 'sonnet' to full model ID", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "sonnet",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
});
it("resolves display name to model ID", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "Claude Sonnet",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
});
it("still works with exact model ID", async () => {
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-5",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
const modelOption = response.configOptions.find((o) => o.id === "model");
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
});
it("throws for completely invalid model value", async () => {
await expect(
agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "gpt-4",
}),
).rejects.toThrow("Invalid value for config option model: gpt-4");
});
it("returns full configOptions in the response", async () => {
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import {
ContentBlock,
PlanEntry,
@@ -6,6 +5,19 @@ import {
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk";
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import {
AgentInput,
BashInput,
FileEditInput,
FileReadInput,
FileWriteInput,
GlobInput,
GrepInput,
TodoWriteInput,
WebFetchInput,
WebSearchInput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import {
ImageBlockParam,
TextBlockParam,
@@ -15,29 +27,31 @@ import {
WebSearchToolResultError,
} from "@anthropic-ai/sdk/resources";
import {
BetaBashCodeExecutionToolResultBlockParam,
BetaBashCodeExecutionResultBlock,
BetaBashCodeExecutionToolResultBlockParam,
BetaBashCodeExecutionToolResultError,
BetaCodeExecutionToolResultBlockParam,
BetaCodeExecutionResultBlock,
BetaCodeExecutionToolResultBlockParam,
BetaCodeExecutionToolResultError,
BetaImageBlockParam,
BetaRequestMCPToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaTextEditorCodeExecutionViewResultBlock,
BetaTextEditorCodeExecutionCreateResultBlock,
BetaTextEditorCodeExecutionStrReplaceResultBlock,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultError,
BetaTextEditorCodeExecutionViewResultBlock,
BetaToolReferenceBlock,
BetaToolResultBlockParam,
BetaToolSearchToolResultBlockParam,
BetaToolReferenceBlock,
BetaToolSearchToolSearchResultBlock,
BetaToolSearchToolResultError,
BetaWebFetchToolResultBlockParam,
BetaToolSearchToolSearchResultBlock,
BetaWebFetchBlock,
BetaWebFetchToolResultBlockParam,
BetaWebFetchToolResultErrorBlock,
BetaWebSearchToolResultBlockParam,
BetaImageBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import path from "node:path";
import { Logger } from "./acp-agent.js";
/**
* Union of all possible content types that can appear in tool results from the Anthropic SDK.
@@ -62,20 +76,6 @@ type ToolResultContent =
| BetaTextEditorCodeExecutionCreateResultBlock
| BetaTextEditorCodeExecutionStrReplaceResultBlock
| BetaTextEditorCodeExecutionToolResultError;
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import { Logger } from "./acp-agent.js";
import {
AgentInput,
BashInput,
FileEditInput,
FileReadInput,
FileWriteInput,
GlobInput,
GrepInput,
TodoWriteInput,
WebFetchInput,
WebSearchInput,
} from "@anthropic-ai/claude-code/sdk-tools.js";
interface ToolInfo {
title: string;