From b978b92a39acded9390bd520985df2f54488bfb5 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Wed, 8 Apr 2026 13:28:37 +0200 Subject: [PATCH 1/3] allow clients to opt into receiving raw SDK messages Relates to: https://github.com/agentclientprotocol/claude-agent-acp/pull/508#discussion_r3050540393 --- src/acp-agent.ts | 36 +++++++ src/lib.ts | 1 + src/tests/acp-agent.test.ts | 199 ++++++++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 955e419f..c2e9e1a9 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -126,6 +126,7 @@ type Session = { pendingMessages: Map void; order: number }>; nextPendingOrder: number; abortController: AbortController; + emitRawSDKMessages: boolean | SDKMessageFilter[]; }; /** Compute a stable fingerprint of the session-defining params so we can @@ -151,6 +152,11 @@ type BackgroundTerminal = pendingOutput: TerminalOutputResponse; }; +export type SDKMessageFilter = { + type: string; + subtype?: string; +}; + /** * Extra metadata that can be given when creating a new session. */ @@ -172,6 +178,14 @@ export type NewSessionMeta = { * - tools (passed through; defaults to claude_code preset if not provided) */ options?: Options; + /** + * When set, raw SDK messages are emitted as extNotification("_claude/sdkMessage", message) + * in addition to normal processing. + * - true: emit all messages + * - false/undefined: emit nothing (default) + * - SDKMessageFilter[]: emit only messages matching at least one filter + */ + emitRawSDKMessages?: boolean | SDKMessageFilter[]; }; additionalRoots?: string[]; }; @@ -575,6 +589,16 @@ export class ClaudeAcpAgent implements Agent { break; } + if ( + session.emitRawSDKMessages && + shouldEmitRawMessage(session.emitRawSDKMessages, message) + ) { + await this.client.extNotification( + "_claude/sdkMessage", + message as Record, + ); + } + switch (message.type) { case "system": switch (message.subtype) { @@ -1605,6 +1629,7 @@ export class ClaudeAcpAgent implements Agent { pendingMessages: new Map(), nextPendingOrder: 0, abortController, + emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false, }; return { @@ -1616,6 +1641,17 @@ export class ClaudeAcpAgent implements Agent { } } +function shouldEmitRawMessage( + config: boolean | SDKMessageFilter[], + message: { type: string; subtype?: string }, +): boolean { + if (config === true) return true; + if (config === false) return false; + return config.some( + (f) => f.type === message.type && (f.subtype === undefined || f.subtype === message.subtype), + ); +} + function sessionUsage(session: Session) { return { inputTokens: session.accumulatedUsage.inputTokens, diff --git a/src/lib.ts b/src/lib.ts index 984e358e..f3cc5426 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -6,6 +6,7 @@ export { streamEventToAcpNotifications, type ToolUpdateMeta, type NewSessionMeta, + type SDKMessageFilter, } from "./acp-agent.js"; export { loadManagedSettings, diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index b8e5d04e..0a3ea7b3 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -1349,6 +1349,7 @@ describe("stop reason propagation", () => { pendingMessages: new Map(), nextPendingOrder: 0, abortController: new AbortController(), + emitRawSDKMessages: false, }; } @@ -1489,6 +1490,7 @@ describe("stop reason propagation", () => { promptRunning: false, pendingMessages: new Map(), nextPendingOrder: 0, + emitRawSDKMessages: false, }; const response = await agent.prompt({ @@ -1563,6 +1565,7 @@ describe("session/close", () => { pendingMessages: new Map(), nextPendingOrder: 0, abortController: new AbortController(), + emitRawSDKMessages: false, }; return agent.sessions[sessionId]!; } @@ -1869,6 +1872,7 @@ describe("usage_update computation", () => { pendingMessages: new Map(), nextPendingOrder: 0, abortController: new AbortController(), + emitRawSDKMessages: false, }; } @@ -2190,3 +2194,198 @@ describe("usage_update computation", () => { expect(usageUpdate.update.size).toBe(1000000); }); }); + +describe("emitRawSDKMessages", () => { + function createMockAgentWithExtNotification() { + const updates: any[] = []; + const extNotifications: { method: string; params: any }[] = []; + const mockClient = { + sessionUpdate: async (notification: any) => { + updates.push(notification); + }, + extNotification: async (method: string, params: any) => { + extNotifications.push({ method, params }); + }, + } as unknown as AgentSideConnection; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + return { agent, updates, extNotifications }; + } + + function injectSession( + agent: ClaudeAcpAgent, + messages: any[], + emitRawSDKMessages: boolean | { type: string; subtype?: string }[], + ) { + const input = new Pushable(); + async function* messageGenerator() { + 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: messageGenerator() as any, + input, + cancelled: false, + cwd: "/test", + modes: { currentModeId: "default", availableModes: [] }, + models: { currentModelId: "default", availableModels: [] }, + 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, + }; + } + + function createResultMessage() { + return { + type: "result" as const, + subtype: "success" as const, + is_error: false, + result: "", + errors: [], + stop_reason: "end_turn" as const, + cost_usd: 0, + duration_ms: 0, + duration_api_ms: 0, + num_turns: 1, + total_cost_usd: 0, + usage: { + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: randomUUID(), + session_id: "test-session", + }; + } + + it("emits all raw messages when set to true", async () => { + const { agent, extNotifications } = createMockAgentWithExtNotification(); + const systemMsg = { + type: "system", + subtype: "status", + status: "compacting", + session_id: "test-session", + }; + injectSession( + agent, + [ + systemMsg, + createResultMessage(), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ], + true, + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + // Should have emitted extNotifications for all messages (user replay + system + result + session_state_changed) + expect(extNotifications.length).toBeGreaterThanOrEqual(3); + expect(extNotifications.every((n) => n.method === "_claude/sdkMessage")).toBe(true); + }); + + it("does not emit when set to false", async () => { + const { agent, extNotifications } = createMockAgentWithExtNotification(); + injectSession( + agent, + [ + { type: "system", subtype: "status", status: "compacting", session_id: "test-session" }, + createResultMessage(), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ], + false, + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + expect(extNotifications).toHaveLength(0); + }); + + it("emits only messages matching a filter array", async () => { + const { agent, extNotifications } = createMockAgentWithExtNotification(); + injectSession( + agent, + [ + { type: "system", subtype: "compact_boundary", session_id: "test-session" }, + { type: "system", subtype: "status", status: "compacting", session_id: "test-session" }, + createResultMessage(), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ], + [{ type: "system", subtype: "compact_boundary" }], + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + // Only the compact_boundary message should have been emitted + const sdkMessages = extNotifications.filter((n) => n.method === "_claude/sdkMessage"); + expect(sdkMessages).toHaveLength(1); + expect(sdkMessages[0].params.sessionId).toBe("test-session"); + expect(sdkMessages[0].params.message.type).toBe("system"); + expect(sdkMessages[0].params.message.subtype).toBe("compact_boundary"); + }); + + it("filter without subtype matches all messages of that type", async () => { + const { agent, extNotifications } = createMockAgentWithExtNotification(); + injectSession( + agent, + [ + { type: "system", subtype: "compact_boundary", session_id: "test-session" }, + { type: "system", subtype: "status", status: "compacting", session_id: "test-session" }, + createResultMessage(), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ], + [{ type: "system" }], + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const sdkMessages = extNotifications.filter((n) => n.method === "_claude/sdkMessage"); + // All system messages should match (compact_boundary + status + session_state_changed) + const systemMessages = sdkMessages.filter((n) => n.params.message.type === "system"); + expect(systemMessages).toHaveLength(3); + }); + + it("supports multiple filters", async () => { + const { agent, extNotifications } = createMockAgentWithExtNotification(); + injectSession( + agent, + [ + { type: "system", subtype: "compact_boundary", session_id: "test-session" }, + { type: "system", subtype: "status", status: "compacting", session_id: "test-session" }, + createResultMessage(), + { type: "system", subtype: "session_state_changed", state: "idle" }, + ], + [{ type: "system", subtype: "compact_boundary" }, { type: "result" }], + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const sdkMessages = extNotifications.filter((n) => n.method === "_claude/sdkMessage"); + expect(sdkMessages).toHaveLength(2); + expect(sdkMessages[0].params.message.type).toBe("system"); + expect(sdkMessages[0].params.message.subtype).toBe("compact_boundary"); + expect(sdkMessages[1].params.message.type).toBe("result"); + }); +}); From 1f146d06e03f2922071fc5794f5d76599bfdc088 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Wed, 8 Apr 2026 13:44:16 +0200 Subject: [PATCH 2/3] fixup --- src/tests/acp-agent.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 0a3ea7b3..1f9d43b8 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -1658,6 +1658,7 @@ describe("getOrCreateSession param change detection", () => { pendingMessages: new Map(), nextPendingOrder: 0, abortController: new AbortController(), + emitRawSDKMessages: false, }; return agent.sessions[sessionId]!; } @@ -2237,6 +2238,7 @@ describe("emitRawSDKMessages", () => { input, cancelled: false, cwd: "/test", + sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }), modes: { currentModeId: "default", availableModes: [] }, models: { currentModelId: "default", availableModels: [] }, settingsManager: { dispose: vi.fn() } as any, From 5e063d9e1d5c81e1406798974fbaaae071a333e1 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Wed, 8 Apr 2026 13:48:54 +0200 Subject: [PATCH 3/3] fix wrapping lost when rebasing --- src/acp-agent.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index c2e9e1a9..7e724419 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -593,10 +593,10 @@ export class ClaudeAcpAgent implements Agent { session.emitRawSDKMessages && shouldEmitRawMessage(session.emitRawSDKMessages, message) ) { - await this.client.extNotification( - "_claude/sdkMessage", - message as Record, - ); + await this.client.extNotification("_claude/sdkMessage", { + sessionId: params.sessionId, + message: message as Record, + }); } switch (message.type) {