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>
This commit is contained in:
Steffen Deusch
2026-03-13 16:53:22 +01:00
committed by GitHub
parent c13edf3b02
commit 2193a13a3b
4 changed files with 112 additions and 9 deletions

8
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "0.21.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.15.0",
"@agentclientprotocol/sdk": "0.16.1",
"@anthropic-ai/claude-agent-sdk": "0.2.71",
"zod": "^3.25.0 || ^4.0.0"
},
@@ -33,9 +33,9 @@
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.15.0.tgz",
"integrity": "sha512-TH4utu23Ix8ec34srBHmDD4p3HI0cYleS1jN9lghRczPfhFlMBNrQgZWeBBe12DWy27L11eIrtciY2MXFSEiDg==",
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.16.1.tgz",
"integrity": "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"

View File

@@ -60,7 +60,7 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.15.0",
"@agentclientprotocol/sdk": "0.16.1",
"@anthropic-ai/claude-agent-sdk": "0.2.71",
"zod": "^3.25.0 || ^4.0.0"
},

View File

@@ -34,6 +34,8 @@ import {
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
CloseSessionRequest,
CloseSessionResponse,
TerminalHandle,
TerminalOutputResponse,
WriteTextFileRequest,
@@ -117,6 +119,7 @@ type Session = {
promptRunning: boolean;
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>;
nextPendingOrder: number;
abortController: AbortController;
};
type BackgroundTerminal =
@@ -338,6 +341,7 @@ export class ClaudeAcpAgent implements Agent {
fork: {},
list: {},
resume: {},
close: {},
},
},
agentInfo: {
@@ -844,6 +848,19 @@ export class ClaudeAcpAgent implements Agent {
await session.query.interrupt();
}
async unstable_sessionClose(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 +888,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) {
@@ -900,7 +920,9 @@ export class ClaudeAcpAgent implements Agent {
}
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: params.value }
: o,
);
return { configOptions: session.configOptions };
@@ -1125,7 +1147,7 @@ export class ClaudeAcpAgent implements Agent {
if (!session) return;
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({
@@ -1220,6 +1242,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"],
@@ -1280,6 +1304,7 @@ export class ClaudeAcpAgent implements Agent {
],
},
...creationOpts,
abortController,
};
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
@@ -1288,7 +1313,6 @@ export class ClaudeAcpAgent implements Agent {
}
// Handle abort controller from meta options
const abortController = userProvidedOptions?.abortController;
if (abortController?.signal.aborted) {
throw new Error("Cancelled");
}
@@ -1376,6 +1400,7 @@ export class ClaudeAcpAgent implements Agent {
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,
@@ -1328,6 +1328,7 @@ describe("stop reason propagation", () => {
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
};
}
@@ -1416,3 +1417,80 @@ 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",
permissionMode: "default",
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_sessionClose({ 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_sessionClose({ 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_sessionClose({ 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_sessionClose({ sessionId: "session-a" });
expect(agent.sessions["session-a"]).toBeUndefined();
expect(agent.sessions["session-b"]).toBeDefined();
});
});