Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*/
Expand All @@ -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[];
};
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1605,6 +1629,7 @@ export class ClaudeAcpAgent implements Agent {
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController,
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
};

return {
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
streamEventToAcpNotifications,
type ToolUpdateMeta,
type NewSessionMeta,
type SDKMessageFilter,
} from "./acp-agent.js";
export {
loadManagedSettings,
Expand Down
201 changes: 201 additions & 0 deletions src/tests/acp-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1349,6 +1349,7 @@ describe("stop reason propagation", () => {
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
emitRawSDKMessages: false,
};
}

Expand Down Expand Up @@ -1489,6 +1490,7 @@ describe("stop reason propagation", () => {
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
emitRawSDKMessages: false,
};

const response = await agent.prompt({
Expand Down Expand Up @@ -1563,6 +1565,7 @@ describe("session/close", () => {
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
emitRawSDKMessages: false,
};
return agent.sessions[sessionId]!;
}
Expand Down Expand Up @@ -1655,6 +1658,7 @@ describe("getOrCreateSession param change detection", () => {
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
emitRawSDKMessages: false,
};
return agent.sessions[sessionId]!;
}
Expand Down Expand Up @@ -1869,6 +1873,7 @@ describe("usage_update computation", () => {
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
emitRawSDKMessages: false,
};
}

Expand Down Expand Up @@ -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");
});
});