feat: allow clients to opt into receiving raw SDK messages (#527)

Relates to:
https://github.com/agentclientprotocol/claude-agent-acp/pull/508#discussion_r3050540393
This commit is contained in:
Steffen Deusch
2026-04-13 16:02:40 +02:00
committed by GitHub
parent fb9aced315
commit 403a668078
3 changed files with 238 additions and 0 deletions

View File

@@ -126,6 +126,7 @@ type Session = {
pendingMessages: Map<string, { resolve: (cancelled: boolean) => 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", {
sessionId: params.sessionId,
message: message as Record<string, unknown>,
});
}
switch (message.type) {
case "system":
switch (message.subtype) {
@@ -1607,6 +1631,7 @@ export class ClaudeAcpAgent implements Agent {
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController,
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
};
return {
@@ -1618,6 +1643,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,

View File

@@ -6,6 +6,7 @@ export {
streamEventToAcpNotifications,
type ToolUpdateMeta,
type NewSessionMeta,
type SDKMessageFilter,
} from "./acp-agent.js";
export {
loadManagedSettings,

View File

@@ -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]!;
}
@@ -1655,6 +1658,7 @@ describe("getOrCreateSession param change detection", () => {
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
emitRawSDKMessages: false,
};
return agent.sessions[sessionId]!;
}
@@ -1869,6 +1873,7 @@ describe("usage_update computation", () => {
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
emitRawSDKMessages: false,
};
}
@@ -2190,3 +2195,199 @@ 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<any>();
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",
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
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");
});
});