mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-28 04:51:50 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
streamEventToAcpNotifications,
|
||||
type ToolUpdateMeta,
|
||||
type NewSessionMeta,
|
||||
type SDKMessageFilter,
|
||||
} from "./acp-agent.js";
|
||||
export {
|
||||
loadManagedSettings,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user