diff --git a/package-lock.json b/package-lock.json index e34e93cf..9f5f6be5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 05770970..b23c6cf3 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 04badb0d..e1fab165 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -34,6 +34,8 @@ import { SetSessionModelResponse, SetSessionModeRequest, SetSessionModeResponse, + CloseSessionRequest, + CloseSessionResponse, TerminalHandle, TerminalOutputResponse, WriteTextFileRequest, @@ -117,6 +119,7 @@ type Session = { promptRunning: boolean; pendingMessages: Map 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 { + 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 { @@ -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 { diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index d665b739..ebdd52f1 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -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(); + }); +});