mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
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.
This commit is contained in:
@@ -4,6 +4,6 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","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":"<session>","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":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","error":"Context too small","sessionUpdate":"compaction_update","status":"failed"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","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}}}}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","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":"<session>","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":"<session>","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":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","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}}}}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","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":"<session>","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":"<session>","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":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","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}}}}
|
||||
|
||||
@@ -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<ReturnType<typeof getSessionMessages>>);
|
||||
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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user