From 96acfeccf9cd07e70e6139273fa7fdefc7ec388a Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 5 Oct 2026 03:29:03 -0700 Subject: [PATCH] refactor: report compaction and cover notices on the experimental ACP v2 surface (#1253) Part of step 6 of opt-in ACP v2 support. Compaction and session notices are both Preview features of the v2 draft that are expected to stabilize soon, and v2 requires no client capability for either. Nothing changes on ACP v1. ## Compaction The agent already implements the compaction RFD for v1 clients that declare `clientCapabilities.session.compaction`, live and on replay. A v2 client got the legacy "Compact conversation" tool call instead, because the v2 surface didn't declare it. - `v1InitializeRequest` now tells the agent that every v2 client takes compaction updates, as it already did for notices. - `v2SessionUpdate` passes `compaction_update` and `compaction_summary_chunk` through: their v1 and v2 shapes are identical. So a v2 client now gets: - **live:** `compaction_update` `in_progress`, the summary as `compaction_summary_chunk`, then `completed` (or `failed` with the standard `error`); - **on replay:** one completed `compaction_update` with the summary, at the summary's position in the transcript. That's the materialized form the v2 draft asks for, without the start/chunk/finish sequence. The token counts and duration that the tool call's `rawOutput` carried have no v2 field. Only AIR gets them, in `_meta`. ## Notices These were already sent to every v2 client, but no test covered one on v2. A new end-to-end test sends Claude Code's `informational` warning as a live `notice`. The agent sends notices only live, never during replay, which matches the draft's "SHOULD NOT be replayed". ## Not changed **A replayed compaction's id differs from its live id.** Live, the id is the uuid of Claude Code's `compacting` status, which the transcript doesn't keep. Replay uses the summary message's uuid. The draft and the RFD ask for the original, stable ids. As with replayed message ids, a client that kept its view would see the compaction twice. Matching them needs Claude Code to persist the live id. ## Verification - The 3 compaction scenarios change their v2 golden files: the tool call becomes the compaction entity. The scenario names still say `legacy` because they're named for the v1 capability. - Unit tests cover the pass-through and the capability. End-to-end tests cover the live notice, and the replayed compaction between the messages around it. - Removing the capability or the pass-through fails tests. - Build, lint and all 2409 tests pass. --- .../v2/compaction-failed-legacy.jsonl | 4 +- .../__snapshots__/v2/compaction-legacy.jsonl | 6 +- .../__snapshots__/v2/compaction-update.jsonl | 6 +- src/tests/acp-v2.test.ts | 115 +++++++++++++++++- src/v2/initialize.ts | 10 +- src/v2/session-update.ts | 2 + 6 files changed, 128 insertions(+), 15 deletions(-) diff --git a/src/tests/acp-scenarios/__snapshots__/v2/compaction-failed-legacy.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/compaction-failed-legacy.jsonl index 465859d9..3bf951ab 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/compaction-failed-legacy.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/compaction-failed-legacy.jsonl @@ -4,6 +4,6 @@ {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} {"kind":"response","method":"session/prompt","payload":{"messageId":""}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"compact"}},"kind":"think","sessionUpdate":"tool_call_update","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"compact"}},"content":[{"content":{"text":"Compaction failed: Context too small","type":"text"},"type":"content"}],"rawOutput":{"error":"Context too small"},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"00000000-0000-4000-8000-000000000001"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"compactionId":"00000000-0000-4000-8000-000000000001","error":"Context too small","sessionUpdate":"compaction_update","status":"failed"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/compaction-legacy.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/compaction-legacy.jsonl index adfe73d3..7b07d9a2 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/compaction-legacy.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/compaction-legacy.jsonl @@ -4,9 +4,9 @@ {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} {"kind":"response","method":"session/prompt","payload":{"messageId":""}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"compact"}},"kind":"think","sessionUpdate":"tool_call_update","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"compact"}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"00000000-0000-4000-8000-000000000001"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"compact"}},"rawOutput":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic"},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"00000000-0000-4000-8000-000000000001"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/compaction-update.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/compaction-update.jsonl index adfe73d3..7b07d9a2 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/compaction-update.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/compaction-update.jsonl @@ -4,9 +4,9 @@ {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} {"kind":"response","method":"session/prompt","payload":{"messageId":""}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"compact"}},"kind":"think","sessionUpdate":"tool_call_update","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"compact"}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"00000000-0000-4000-8000-000000000001"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"compact"}},"rawOutput":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic"},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"00000000-0000-4000-8000-000000000001"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}} diff --git a/src/tests/acp-v2.test.ts b/src/tests/acp-v2.test.ts index 17d6456e..8b9d3e7a 100644 --- a/src/tests/acp-v2.test.ts +++ b/src/tests/acp-v2.test.ts @@ -18,6 +18,7 @@ import { } from "@anthropic-ai/claude-agent-sdk"; import type { ClaudeAcpAgent } from "../acp-agent.js"; import type { AuthStatusUpdateNotification } from "../auth-status.js"; +import { clientSupportsCompactionUpdates } from "../context-compaction.js"; import { v2DiffContent } from "../diff.js"; import { acpProtocolRouter } from "../serve.js"; import { clientSupportsNotices } from "../session-notices.js"; @@ -407,13 +408,14 @@ describe("ACP v2 sessions", () => { expect(client.updates("current_mode_update")).toEqual([]); }); - it("tells the agent that a v2 client takes notices", async () => { + it("tells the agent that a v2 client takes notices and compaction updates", async () => { const { app, authUpdate } = v2Client(); await app.connectWith(connectRouter(), async (agent) => { await initializeV2(agent); await authUpdate(1); }); expect(clientSupportsNotices(agents[0].clientCapabilities)).toBe(true); + expect(clientSupportsCompactionUpdates(agents[0].clientCapabilities)).toBe(true); }); it("forwards the URL elicitation of MCP OAuth to a v2 client", async () => { @@ -790,6 +792,93 @@ describe("ACP v2 prompts", () => { ); }); + it("reports Claude Code's warnings as live notices", async () => { + scriptTurns(async function* (options) { + yield { + type: "system", + subtype: "informational", + level: "warning", + content: "Stop hook blocked continuation\nThe hook said no.", + uuid: randomUUID(), + session_id: options.sessionId, + }; + yield assistantText(options, "Done."); + yield result(options); + }); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") }); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn")); + await client.authUpdate(1); + }); + + expect(turnTrace(client.sessionUpdates)).toEqual([ + "user_message", + "running", + "notice", + "agent_message_chunk", + "idle end_turn", + ]); + expect(client.updates("notice")).toEqual([ + { + sessionUpdate: "notice", + severity: "warning", + title: "Stop hook blocked continuation", + description: "The hook said no.", + }, + ]); + }); + + it("replays a compaction as one completed compaction_update at its position", async () => { + const summary = + "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n" + + "Summary:\n1. Primary Request and Intent:\n Count upward.\n\n" + + "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary."; + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + vi.mocked(getSessionMessages).mockResolvedValueOnce([ + transcriptEntry("user", "prompt-uuid", { role: "user", content: text("count") }), + { + ...transcriptEntry("user", "summary-uuid", { role: "user", content: summary }), + isCompactSummary: true, + }, + transcriptEntry("assistant", randomUUID(), { + id: "msg_after", + role: "assistant", + content: text("4"), + }), + ] as unknown as Awaited>); + const before = client.sessionUpdates.length; + await agent.request(v2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: { type: "start" }, + }); + // The materialized form only: no in-progress update and no summary + // chunks, and the summary between the messages around it. + expect( + client.sessionUpdates + .slice(before) + .map(({ update }) => update) + .filter((update) => !update.sessionUpdate.endsWith("_chunk")), + ).toEqual([ + { sessionUpdate: "user_message", messageId: "prompt-uuid", content: [] }, + { + sessionUpdate: "compaction_update", + compactionId: "summary-uuid", + status: "completed", + summary: text("1. Primary Request and Intent:\n Count upward."), + }, + { sessionUpdate: "agent_message", messageId: "msg_after", content: [] }, + ]); + await client.authUpdate(1); + }); + }); + it("replays the history before it answers session/resume, clearing each message first", async () => { const echoed = scriptTurns( async function* (options) { @@ -1159,8 +1248,28 @@ describe("ACP v2 session translation", () => { it("fails on session updates that it does not translate yet", () => { expect(() => - v2SessionUpdate({ sessionUpdate: "compaction_update", compactionId: "c", status: "started" }), - ).toThrow("does not translate compaction_update session updates yet"); + v2SessionUpdate({ + sessionUpdate: "subagent_state_update", + subagentSessionId: "agent_n", + state: "failed", + }), + ).toThrow("does not translate subagent_state_update session updates yet"); + }); + + it("sends compaction updates as they are, which v1 and v2 share", () => { + const update = { + sessionUpdate: "compaction_update" as const, + compactionId: "c", + status: "completed", + summary: text("The summary."), + }; + expect(v2SessionUpdate(update)).toEqual(update); + const chunk = { + sessionUpdate: "compaction_summary_chunk" as const, + compactionId: "c", + content: { type: "text" as const, text: "The" }, + }; + expect(v2SessionUpdate(chunk)).toEqual(chunk); }); it("reports the plan of the session as one v2 plan", () => { diff --git a/src/v2/initialize.ts b/src/v2/initialize.ts index 3d3c57bf..1a36dcd4 100644 --- a/src/v2/initialize.ts +++ b/src/v2/initialize.ts @@ -26,10 +26,12 @@ export function v1InitializeRequest(request: v2.InitializeRequest): InitializeRe // reads, writes, and runs commands itself. fs: { readTextFile: false, writeTextFile: false }, terminal: false, - // Every v2 client handles boolean config options and notices; v1 asks - // for a marker. With notices, the agent reports advisories as notices - // rather than as transcript messages. - session: { configOptions: { boolean: {} }, notices: {} }, + // Every v2 client handles boolean config options, notices, and + // compaction updates; v1 asks for a marker. With them, the agent reports + // advisories as notices rather than as transcript messages, and a + // compaction as a compaction entity rather than as a "Compact + // conversation" tool call, also on replay. + session: { configOptions: { boolean: {} }, notices: {}, compaction: {} }, // v1 marks terminal auth support with a boolean, v2 with an object. auth: { terminal: capabilities?.auth?.terminal != null, diff --git a/src/v2/session-update.ts b/src/v2/session-update.ts index 2909ea9b..12877510 100644 --- a/src/v2/session-update.ts +++ b/src/v2/session-update.ts @@ -32,6 +32,8 @@ export function v2SessionUpdate( case "session_info_update": case "usage_update": case "notice": + case "compaction_update": + case "compaction_summary_chunk": return update; case "agent_message_chunk": case "agent_thought_chunk":