Compare commits

...

4 Commits

Author SHA1 Message Date
acp-release-bot[bot]
505f9c13c0 chore(main): release 0.36.0 (#683)
🤖 I have created a release *beep* *boop*
---


##
[0.36.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.35.0...v0.36.0)
(2026-05-18)


### Features

* Add experimental session delete support
([#682](https://github.com/agentclientprotocol/claude-agent-acp/issues/682))
([2162d5a](2162d5af62))
* Support experimental additionalDirectories field
([#684](https://github.com/agentclientprotocol/claude-agent-acp/issues/684))
([f37e9a0](f37e9a0d47))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-05-18 13:02:28 +02:00
Ben Brandt
f37e9a0d47 feat: Support experimental additionalDirectories field (#684) 2026-05-18 10:51:45 +00:00
Ben Brandt
2162d5af62 feat: Add experimental session delete support (#682)
- **Update Agent Client Protocol SDK to 0.22.0**
- **feat: Add session delete support**
2026-05-18 10:43:07 +00:00
Ben Brandt
798844726f chore: tighten local-command-stdout handler comment and pass full options (#678)
## Summary

Follow-up cleanup to #649. Two small changes:

- **Tighten the comment block** above the marker-strip branch in
`acp-agent.ts:1119`. The original 15-line block restated history that
already lives in the PR description; replaced with a 5-line "what this
does, and why the no-op case is preserved" comment that points at the
replay path for parallel context.
- **Forward `clientCapabilities`, `parentToolUseId`, `cwd`, and
`taskState`** to `toAcpNotifications` so the new strip-and-render call
matches the sibling call below it at `acp-agent.ts:1192`. Without these
the rendered output loses threading under a wrapping tool use and skips
terminal-output detection.
- **Only log the raw payload when nothing renders** — previously every
marker-containing message was logged AND rendered. Now we render the
stripped prose if anything remains, and fall back to logging only the
pure-marker case (e.g. `/compact`).

## Test plan

- [x] `npm run check` (lint + format) — passes
- [x] `npm run build` (tsc) — passes
- [x] `stripLocalCommandMetadata` unit tests — 8 passed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:45:33 +02:00
7 changed files with 184 additions and 30 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.35.0"
".": "0.36.0"
}

View File

@@ -1,5 +1,13 @@
# Changelog
## [0.36.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.35.0...v0.36.0) (2026-05-18)
### Features
* Add experimental session delete support ([#682](https://github.com/agentclientprotocol/claude-agent-acp/issues/682)) ([2162d5a](https://github.com/agentclientprotocol/claude-agent-acp/commit/2162d5af62d493a82381bd88bc8fc67d376e358b))
* Support experimental additionalDirectories field ([#684](https://github.com/agentclientprotocol/claude-agent-acp/issues/684)) ([f37e9a0](https://github.com/agentclientprotocol/claude-agent-acp/commit/f37e9a0d47d8d201d98b2177083664a6781895cc))
## [0.35.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.34.1...v0.35.0) (2026-05-16)

12
package-lock.json generated
View File

@@ -1,15 +1,15 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.35.0",
"version": "0.36.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.35.0",
"version": "0.36.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.21.1",
"@agentclientprotocol/sdk": "0.22.0",
"@anthropic-ai/claude-agent-sdk": "0.3.143",
"zod": "^3.25.0 || ^4.0.0"
},
@@ -32,9 +32,9 @@
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.21.1.tgz",
"integrity": "sha512-ZTLH+o9QxcZDLX/9ww+W7C2iExnXFM+vD/uGFVSlR61Kzj9FaxUqBC6Rv/kwgA7qVWYUEI9c5ZNqCuO9PM4rKg==",
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.22.0.tgz",
"integrity": "sha512-mnkpiuEfGm7/snhWbrRhe3Hk+WDDjv7U2N/Ql0XiThl9zg7T984dPH8Tich4wlBQnaaXTV4Fl5OQSe2pa9hoFA==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.35.0",
"version": "0.36.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -60,7 +60,7 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.21.1",
"@agentclientprotocol/sdk": "0.22.0",
"@anthropic-ai/claude-agent-sdk": "0.3.143",
"zod": "^3.25.0 || ^4.0.0"
},

View File

@@ -37,6 +37,8 @@ import {
SetSessionModeResponse,
CloseSessionRequest,
CloseSessionResponse,
DeleteSessionRequest,
DeleteSessionResponse,
TerminalHandle,
TerminalOutputResponse,
WriteTextFileRequest,
@@ -45,6 +47,7 @@ import {
} from "@agentclientprotocol/sdk";
import {
CanUseTool,
deleteSession,
getSessionMessages,
listSessions,
McpServerConfig,
@@ -625,10 +628,12 @@ export class ClaudeAcpAgent implements Agent {
},
loadSession: true,
sessionCapabilities: {
additionalDirectories: {},
close: {},
delete: {},
fork: {},
list: {},
resume: {},
close: {},
},
},
agentInfo: {
@@ -660,6 +665,7 @@ export class ClaudeAcpAgent implements Agent {
{
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
additionalDirectories: params.additionalDirectories,
_meta: params._meta,
},
{
@@ -1116,26 +1122,15 @@ export class ClaudeAcpAgent implements Agent {
}
}
// Slash commands like /compact can generate invalid output... doesn't match
// their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
//
// Strip local-command marker tags from the content and render whatever
// real prose remains, so that custom slash commands / user-defined
// skills (whose bodies arrive wrapped in <command-*> / <local-command-stdout>
// markers) and built-in commands that emit textual output reach the UI.
// Previously this branch dropped every message containing a stdout
// marker, which silently swallowed every successful skill invocation
// and any built-in command that emits output through these tags.
// strip-and-render is safe because stripLocalCommandMetadata returns
// null when nothing renderable remains (e.g. pure-marker /compact
// payloads), so the no-render no-op path is preserved for those cases.
//
// Refs zed-industries/claude-code-acp#624, #642.
// Strip <command-*>/<local-command-stdout> markers and render any
// remaining prose. Skill bodies and built-in slash commands (e.g.
// /usage, /status, /model) arrive wrapped in these tags; pure-marker
// payloads (e.g. /compact's malformed output) strip to null and are
// skipped. Mirrors the replay path at replaySessionHistory.
if (
typeof message.message.content === "string" &&
message.message.content.includes("<local-command-stdout>")
) {
this.logger.log(message.message.content);
const stripped = stripLocalCommandMetadata(message.message.content);
if (typeof stripped === "string") {
for (const notification of toAcpNotifications(
@@ -1145,9 +1140,17 @@ export class ClaudeAcpAgent implements Agent {
this.toolUseCache,
this.client,
this.logger,
{
clientCapabilities: this.clientCapabilities,
parentToolUseId: message.parent_tool_use_id,
cwd: session.cwd,
taskState: session.taskState,
},
)) {
await this.client.sessionUpdate(notification);
}
} else {
this.logger.log(message.message.content);
}
break;
}
@@ -1300,6 +1303,16 @@ export class ClaudeAcpAgent implements Agent {
return {};
}
async unstable_deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
// Tear down any active in-memory state first so the on-disk file isn't
// recreated by an outstanding query writing to it.
if (this.sessions[params.sessionId]) {
await this.teardownSession(params.sessionId);
}
await deleteSession(params.sessionId);
return {};
}
async unstable_setSessionModel(
params: SetSessionModelRequest,
): Promise<SetSessionModelResponse | void> {
@@ -1764,6 +1777,7 @@ export class ClaudeAcpAgent implements Agent {
sessionId: string;
cwd: string;
mcpServers?: NewSessionRequest["mcpServers"];
additionalDirectories?: NewSessionRequest["additionalDirectories"];
_meta?: NewSessionRequest["_meta"];
}): Promise<NewSessionResponse> {
const existingSession = this.sessions[params.sessionId];
@@ -1788,6 +1802,7 @@ export class ClaudeAcpAgent implements Agent {
{
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
additionalDirectories: params.additionalDirectories,
_meta: params._meta,
},
{
@@ -1837,7 +1852,7 @@ export class ClaudeAcpAgent implements Agent {
? Object.fromEntries(server.headers.map((e) => [e.name, e.value]))
: undefined,
};
} else {
} else if (!("type" in server)) {
// Stdio type MCP server (with or without explicit type field)
mcpServers[server.name] = {
type: "stdio",
@@ -2009,9 +2024,15 @@ export class ClaudeAcpAgent implements Agent {
abortController,
};
// Prefer the official ACP `additionalDirectories` field. Fall back to the
// legacy `_meta.additionalRoots` extension for clients that haven't been
// updated yet. Either source is merged with directories supplied via
// `_meta.claudeCode.options.additionalDirectories` (SDK pass-through).
const acpAdditionalDirectories =
params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
options.additionalDirectories = [
...(userProvidedOptions?.additionalDirectories ?? []),
...(sessionMeta?.additionalRoots ?? []),
...acpAdditionalDirectories,
];
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest";
import { spawn, spawnSync } from "child_process";
import {
Agent,
@@ -35,8 +35,16 @@ import {
type SDKMessageFilter,
} from "../acp-agent.js";
import { Pushable } from "../utils.js";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { deleteSession, query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { randomUUID } from "crypto";
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
return {
...actual,
deleteSession: vi.fn(),
};
});
import type {
BetaToolResultBlockParam,
BetaToolSearchToolResultBlockParam,
@@ -1888,6 +1896,94 @@ describe("session/close", () => {
});
});
describe("session/delete", () => {
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(), close: vi.fn() });
agent.sessions[sessionId] = {
query: gen as any,
input: new Pushable(),
cancelled: false,
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: [],
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
};
return agent.sessions[sessionId]!;
}
beforeEach(() => {
vi.mocked(deleteSession).mockReset();
vi.mocked(deleteSession).mockResolvedValue(undefined);
});
it("tears down the active session and deletes it from disk", async () => {
const agent = createMockAgent();
const session = injectSession(agent, "session-1");
const result = await agent.unstable_deleteSession({ sessionId: "session-1" });
expect(result).toEqual({});
expect(agent.sessions["session-1"]).toBeUndefined();
expect(session.query.interrupt).toHaveBeenCalled();
expect(session.settingsManager.dispose).toHaveBeenCalled();
expect(session.abortController.signal.aborted).toBe(true);
expect(deleteSession).toHaveBeenCalledWith("session-1");
});
it("deletes a session from disk that is not currently active", async () => {
const agent = createMockAgent();
const result = await agent.unstable_deleteSession({ sessionId: "not-active" });
expect(result).toEqual({});
expect(deleteSession).toHaveBeenCalledWith("not-active");
});
it("propagates errors from the SDK delete call", async () => {
const agent = createMockAgent();
vi.mocked(deleteSession).mockRejectedValueOnce(new Error("Session not found on disk"));
await expect(agent.unstable_deleteSession({ sessionId: "missing" })).rejects.toThrow(
"Session not found on disk",
);
});
it("does not affect other sessions when deleting one", async () => {
const agent = createMockAgent();
injectSession(agent, "session-a");
injectSession(agent, "session-b");
await agent.unstable_deleteSession({ sessionId: "session-a" });
expect(agent.sessions["session-a"]).toBeUndefined();
expect(agent.sessions["session-b"]).toBeDefined();
});
});
describe("getOrCreateSession param change detection", () => {
function createMockAgent() {
const mockClient = {

View File

@@ -76,4 +76,33 @@ describe("additionalRoots", () => {
});
expect(capturedOptions!.additionalDirectories).toEqual(["/workspace/shared", "", root]);
});
it("prefers the official ACP additionalDirectories field over _meta.additionalRoots", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
additionalDirectories: ["/from/official"],
_meta: { additionalRoots: ["/from/meta"] },
});
expect(capturedOptions!.additionalDirectories).toEqual(["/from/official"]);
});
it("merges official ACP additionalDirectories with claudeCode SDK additionalDirectories", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
additionalDirectories: ["/from/official"],
_meta: { claudeCode: { options: { additionalDirectories: ["/from/sdk"] } } },
});
expect(capturedOptions!.additionalDirectories).toEqual(["/from/sdk", "/from/official"]);
});
it("falls back to _meta.additionalRoots when the official field is omitted", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: { additionalRoots: ["/from/meta"] },
});
expect(capturedOptions!.additionalDirectories).toEqual(["/from/meta"]);
});
});