feat: Add experimental ACP compaction update support (#1134)

This commit is contained in:
Ben Brandt
2026-09-14 08:38:11 -07:00
committed by GitHub
parent 8823ea6fcb
commit 543a9a2f97
5 changed files with 1347 additions and 105 deletions

View File

@@ -211,7 +211,9 @@ import {
} from "./file-change-audit.js";
import {
ContextCompactionLifecycle,
clientSupportsCompactionUpdates,
contextCompactionMetadataFromBoundary,
isCompactSummaryMessage,
} from "./context-compaction.js";
import {
applyTaskCreate,
@@ -996,6 +998,9 @@ export type Session = {
/** Session-owned async task controller. Prompt cancellation intentionally
* does not finish it because background work may outlive a prompt. */
asyncTaskRuntime?: AsyncTaskRuntime;
/** The consumer's compaction lifecycle, exposed so the PostCompact hook can
* hand it the retained summary. */
contextCompaction?: ContextCompactionLifecycle;
/** Whether any top-level assistant text reached the client since the last
* stretch boundary. Set as a side effect of sending in the consumer's
* `sendUpdate`, never at an emission site; read at the terminal `result`
@@ -3194,7 +3199,14 @@ export class ClaudeAcpAgent {
async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)),
));
const compaction = new ContextCompactionLifecycle((notification) => sendUpdate(notification));
const compaction = new ContextCompactionLifecycle((notification) => sendUpdate(notification), {
sessionId: params.sessionId,
presentation: clientSupportsCompactionUpdates(this.clientCapabilities)
? "compaction_update"
: "tool_call",
logError: (message, error) => this.logger.error(message, error),
});
session.contextCompaction = compaction;
const sendUpdate = async (notification: AcpSessionNotification) => {
const { update } = notification;
const claudeMeta = update._meta?.claudeCode as
@@ -3226,7 +3238,9 @@ export class ClaudeAcpAgent {
update.sessionUpdate === "agent_thought_chunk" ||
update.sessionUpdate === "user_message_chunk" ||
update.sessionUpdate === "tool_call" ||
update.sessionUpdate === "tool_call_update")
update.sessionUpdate === "tool_call_update" ||
update.sessionUpdate === "compaction_update" ||
update.sessionUpdate === "compaction_summary_chunk")
) {
return;
}
@@ -4136,11 +4150,11 @@ export class ClaudeAcpAgent {
break;
case "status": {
if (message.status === "compacting") {
await compaction.start(message.session_id, message.uuid);
await compaction.start(message.uuid);
} else if (message.compact_result === "success") {
await compaction.finish(message.session_id, message.uuid, "completed");
await compaction.finish(message.uuid, "completed");
} else if (message.compact_result === "failed") {
await compaction.finish(message.session_id, message.uuid, "failed", {
await compaction.finish(message.uuid, "failed", {
...(message.compact_error ? { error: message.compact_error } : {}),
});
}
@@ -4164,7 +4178,6 @@ export class ClaudeAcpAgent {
//
const compactMetadata = message.compact_metadata;
await compaction.finish(
message.session_id,
message.uuid,
"completed",
compactMetadata ? contextCompactionMetadataFromBoundary(compactMetadata) : {},
@@ -4258,7 +4271,7 @@ export class ClaudeAcpAgent {
// the interrupted turn's tokens entirely (issue #844). Zero
// when the cancel pre-empted the result (wedge/force-cancel).
if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
compaction.reset();
await compaction.reset();
settleActive(turnOutcome(session, "cancelled"));
// An interrupt can pre-empt the turn's result entirely
// (nothing ran the result-case `finally`), so close the
@@ -4321,7 +4334,7 @@ export class ClaudeAcpAgent {
session.activeTurn &&
!session.activeTurn.settled
) {
compaction.reset();
await compaction.reset();
// Deliberately only the ACTIVE turn: a queued turn that
// was never echoed is NOT failed here, because an idle
// can legitimately precede the SDK picking up freshly
@@ -5343,29 +5356,47 @@ export class ClaudeAcpAgent {
// then, so both branches no-op); cancellation is left to the
// idle/abort path. settleActive is idempotent, so a duplicate
// idle is a no-op.
//
// A result also closes this compaction lifecycle — before the
// settle, so a `cancelled` terminal for an entity the runtime
// left open lands inside the prompt response rather than after
// it (clients may stop consuming at the response). Reset here
// rather than at idle: an owed idle from this turn can arrive
// after the next turn has already started and must not erase
// that turn's compaction state.
if (!isAutonomousResult) {
await compaction.reset();
}
if (!session.cancelled) {
settleOrDefer(turnOutcome(session, stopReason));
}
} finally {
if (!isAutonomousResult) {
session.emittedAssistantText = false;
// A result closes this compaction lifecycle. Reset here rather
// than at idle: an owed idle from this turn can arrive after
// the next turn has already started and must not erase that
// turn's compaction state.
compaction.reset();
// The early exits above (cancelled guard, refusal) skip the
// pre-settle reset; idempotent, so a no-op on the normal path.
await compaction.reset();
}
}
break;
}
case "stream_event": {
const isCompactionProgress =
(message.event.type === "content_block_start" &&
message.event.content_block.type === "compaction") ||
(message.event.type === "content_block_delta" &&
message.event.delta.type === "compaction_delta");
if (isCompactionProgress) {
await compaction.heartbeat(message.session_id, message.uuid);
// The API's compaction block streams the retained summary text;
// `content` is null on the opening block and on a failed compaction.
// Only the root conversation's compaction is the session's: a
// subagent compacting its own context must not touch it.
const compactionBlock =
message.parent_tool_use_id !== null
? undefined
: message.event.type === "content_block_start" &&
message.event.content_block.type === "compaction"
? message.event.content_block
: message.event.type === "content_block_delta" &&
message.event.delta.type === "compaction_delta"
? message.event.delta
: undefined;
if (compactionBlock) {
await compaction.heartbeat(message.uuid, compactionBlock.content ?? undefined);
}
// `message_start` carries the Anthropic API message id; capture it
// so the streamed chunks that follow (whose delta events don't carry
@@ -6400,6 +6431,7 @@ export class ClaudeAcpAgent {
}
session.queryClosed = true;
session.consumer = undefined;
session.contextCompaction = undefined;
session.settingsManager.dispose();
session.input.end();
session.query.close();
@@ -6599,6 +6631,7 @@ export class ClaudeAcpAgent {
let replayingFileChangeAudit = false;
const replayFileChangeAuditToolUseIds = new Set<string>();
const nativeReplayEnabled = clientSupportsSubagents(this.clientCapabilities);
const replayCompactionUpdates = clientSupportsCompactionUpdates(this.clientCapabilities);
const replayTerminalStates = new Map<string, "completed" | "failed" | "cancelled">();
const replayChildren = new Map<
string,
@@ -6851,6 +6884,35 @@ export class ClaudeAcpAgent {
}
}
// Claude persists the retained summary as a user message framed with
// continuation instructions for the model. Clients on the compaction
// contract get it materialized as the completed compaction entity at
// this transcript position (the boundary's own system record comes back
// from getSessionMessages without its metadata, so the summary message
// is the one durable marker); other clients keep the transcript text.
// The entity is keyed by the summary message's uuid, which differs from
// the live id (the `compacting` status uuid) — replay is terminal-first
// by the RFD, and no client merges a replay into a live entity store.
// A subagent's own compaction summary is not the root session's, and a
// summary whose framing is unrecognized stays visible as transcript
// text rather than vanishing behind a summary-less entity.
if (
replayCompactionUpdates &&
isCompactSummaryMessage(message) &&
parentToolUseId === null &&
typeof message.uuid === "string" &&
message.uuid.length > 0
) {
const replayCompaction = new ContextCompactionLifecycle(
(notification) => this.client.sessionUpdate(notification),
{ sessionId, presentation: "compaction_update" },
);
if (replayCompaction.recordSummary(assistantMessageText(message.message))) {
await replayCompaction.finish(message.uuid, "completed");
continue;
}
}
for (const notification of toAcpNotifications(
// @ts-expect-error - untyped in SDK but we handle all of these
content,
@@ -8176,6 +8238,26 @@ export class ClaudeAcpAgent {
],
},
],
// The retained summary reaches the SDK stream only as the persisted
// user message that frames it with model-facing continuation
// instructions; the hook carries the raw summary for the ACP
// compaction_update. The CLI awaits this hook before emitting the
// compaction's terminal frames, so the summary is normally recorded
// while the entity is still in progress and rides on the terminal
// update. Subagent compactions stay out of the root session's entity.
PostCompact: [
...(userProvidedOptions?.hooks?.PostCompact || []),
{
hooks: [
async (input) => {
if (input.hook_event_name === "PostCompact" && !input.agent_id) {
this.sessions[sessionId]?.contextCompaction?.recordSummary(input.compact_summary);
}
return { continue: true };
},
],
},
],
...(fileChangeAuditSupport
? {
Stop: [

View File

@@ -1,21 +1,133 @@
import { SessionNotification } from "@agentclientprotocol/sdk";
import { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
import {
ContextCompactionMetadata,
createContextCompactionMeta,
} from "./context-compaction-meta.js";
type CompactionStatus = "completed" | "failed";
type TerminalStatus = CompactionStatus | "cancelled";
/**
* How compaction reaches the client.
*
* - `compaction_update`: the ACP session-compaction contract (ID-addressed
* `compaction_update` / `compaction_summary_chunk`), used when the v1 client
* advertises `clientCapabilities.session.compaction`.
* - `tool_call`: the legacy synthetic "Compact conversation" tool call, kept
* for clients that do not advertise the capability.
*/
export type CompactionPresentation = "tool_call" | "compaction_update";
type CompactionState = {
toolCallId: string;
terminalStatus?: CompactionStatus;
/** Doubles as the toolCallId under the legacy presentation. */
compactionId: string;
terminalStatus?: TerminalStatus;
heartbeatSent: boolean;
/** User-displayable summary reported by the PostCompact hook. While the
* compaction is in progress it awaits the terminal update; on a `completed`
* state it has been sent. */
summary?: string;
};
type SendUpdate = (notification: SessionNotification) => Promise<void>;
export type ContextCompactionLifecycleOptions = {
sessionId: string;
presentation?: CompactionPresentation;
/** Receives failures of the send that must not propagate: the turn-boundary
* `cancelled` terminal. */
logError?: (message: string, error: unknown) => void;
};
export function clientSupportsCompactionUpdates(capabilities?: ClientCapabilities | null): boolean {
const compaction = capabilities?.session?.compaction;
return typeof compaction === "object" && compaction !== null && !Array.isArray(compaction);
}
/** Tags on their own line, as the model emits them; a summary that merely
* quotes `<summary>` mid-sentence must not be truncated at the mention. */
const SUMMARY_OPEN_LINE = /^[ \t]*<summary>[ \t]*\r?\n/gim;
const SUMMARY_CLOSE_LINE = /^[ \t]*<\/summary>[ \t]*$/gim;
/** The model's private reasoning block that precedes the summary; stripped
* first so a `<summary>` it merely mentions cannot anchor the summary span,
* and even when the summary tags themselves are missing. Own-line tags
* anywhere; inline tags only as a leading block, since a summary body may
* quote both tag names. */
const ANALYSIS_BLOCKS = [
/^[ \t]*<analysis>[ \t]*\r?\n[\s\S]*?\n[ \t]*<\/analysis>[ \t]*$/gim,
/^\s*<analysis>[\s\S]*?<\/analysis>/i,
];
/** The framing Claude Code wraps around the retained summary when it persists
* it as the post-compaction user message. */
const CONTINUATION_PREAMBLE = /^\s*This session is being continued from a previous conversation/i;
const PERSISTED_PREAMBLE =
/^\s*This session is being continued from a previous conversation[^\n]*\n+Summary:[ \t]*\r?\n/;
const PERSISTED_TRAILERS = [
/\n+If you need specific details from before compaction[\s\S]*$/,
/\n+(?:Please )?[Cc]ontinue the conversation from where it left (?:it )?off[\s\S]*$/,
];
/**
* Translates Claude's compaction signals into one idempotent ACP tool lifecycle.
* Reduce Claude's compaction output to the user-displayable summary.
*
* The PostCompact hook reports the model's raw output, which wraps the
* summary in `<summary>` tags (often preceded by an `<analysis>` block). The
* persisted transcript instead frames the same summary with continuation
* instructions for the model. Neither framing belongs in the ACP `summary`,
* and a continuation prompt whose framing is not recognized yields no summary
* at all rather than model-facing instructions.
*/
export function compactionSummaryText(raw: string): string | undefined {
let text = raw;
for (const block of ANALYSIS_BLOCKS) {
text = text.replace(block, "");
}
const tagged = taggedSummary(text);
if (tagged !== undefined) {
text = tagged;
} else if (CONTINUATION_PREAMBLE.test(text)) {
const body = text.replace(PERSISTED_PREAMBLE, "");
if (body === text) return undefined;
text = body;
for (const trailer of PERSISTED_TRAILERS) {
text = text.replace(trailer, "");
}
}
text = text.trim();
return text.length > 0 ? text : undefined;
}
function taggedSummary(raw: string): string | undefined {
const close = [...raw.matchAll(SUMMARY_CLOSE_LINE)].at(-1);
if (close?.index !== undefined) {
const open = [...raw.slice(0, close.index).matchAll(SUMMARY_OPEN_LINE)].at(-1);
if (open?.index !== undefined) {
return raw.slice(open.index + open[0].length, close.index);
}
}
// Inline tags: the widest span, so a quoted `</summary>` inside the body
// does not end it early. Plain index scans keep an unterminated tag linear.
const openInline = raw.search(/<summary>/i);
const closeInline = raw.toLowerCase().lastIndexOf("</summary>");
if (openInline >= 0 && closeInline > openInline) {
return raw.slice(openInline + "<summary>".length, closeInline);
}
return undefined;
}
/** True for the post-compaction user message Claude Code persists (and
* `getSessionMessages` returns) with the summary of the dropped history. */
export function isCompactSummaryMessage(message: unknown): boolean {
return (
typeof message === "object" &&
message !== null &&
(message as { type?: unknown }).type === "user" &&
(message as { isCompactSummary?: unknown }).isCompactSummary === true
);
}
/**
* Translates Claude's compaction signals into one idempotent ACP lifecycle.
*
* The SDK can duplicate terminal compact_result messages and can omit the
* opening status on replay. State therefore lives until the owning turn's
@@ -26,22 +138,59 @@ export class ContextCompactionLifecycle {
private activeCompaction: CompactionState | undefined;
private outputDelivered = false;
private duplicateErrorOutput: string | undefined;
/** Summary reported by PostCompact before the opening status was consumed.
* Deliberately survives `reset()`: the CLI awaits the hook before emitting
* the compaction's terminal frames, so a pending summary always belongs to
* a compaction whose frames are still ahead of the consumer — possibly
* queued behind a previous turn's result. */
private pendingSummary: string | undefined;
private readonly sessionId: string;
readonly presentation: CompactionPresentation;
private readonly logError: (message: string, error: unknown) => void;
constructor(private readonly sendUpdate: SendUpdate) {}
constructor(
private readonly sendUpdate: SendUpdate,
options: ContextCompactionLifecycleOptions,
) {
this.sessionId = options.sessionId;
this.presentation = options.presentation ?? "tool_call";
this.logError = options.logError ?? (() => {});
}
get hasDeliveredOutput(): boolean {
return this.outputDelivered;
}
reset(): void {
/**
* Close the lifecycle at a turn boundary (result, cancel, idle-abandon).
*
* A `compaction_update` entity still `in_progress` here never received its
* terminal signal from the runtime and gets its one terminal status,
* `cancelled`. The legacy tool call is deliberately left as it was (an
* `in_progress` call): ACP `ToolCallStatus` has no cancelled state and that
* presentation's behavior predates this lifecycle. Never throws — a failed
* send is logged, so callers can settle the turn unconditionally.
*/
async reset(): Promise<void> {
const state = this.activeCompaction;
this.activeCompaction = undefined;
this.outputDelivered = false;
this.duplicateErrorOutput = undefined;
if (!state || state.terminalStatus || this.presentation !== "compaction_update") return;
state.terminalStatus = "cancelled";
await this.sendQuietly(
{
sessionUpdate: "compaction_update",
compactionId: state.compactionId,
status: "cancelled",
},
"cancelled compaction",
);
}
/**
* Claude also emits a failed manual compaction's error as local-command
* stdout. Consume that one duplicate after the tool lifecycle carried it,
* stdout. Consume that one duplicate after the lifecycle carried it,
* without hiding unrelated command output.
*/
consumeDuplicateErrorOutput(content: string): boolean {
@@ -55,101 +204,184 @@ export class ContextCompactionLifecycle {
return true;
}
async start(sessionId: string, toolCallId: string): Promise<CompactionState> {
async start(compactionId: string): Promise<CompactionState> {
if (this.activeCompaction && !this.activeCompaction.terminalStatus) {
return this.activeCompaction;
}
this.activeCompaction = { toolCallId, heartbeatSent: false };
this.outputDelivered = true;
await this.sendUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId,
title: "Compact conversation",
kind: "think",
const state = this.open(compactionId);
if (this.presentation === "compaction_update") {
await this.send({
sessionUpdate: "compaction_update",
compactionId,
status: "in_progress",
_meta: compactionToolMeta(),
},
_meta: createContextCompactionMeta(),
});
return state;
}
await this.send({
sessionUpdate: "tool_call",
toolCallId: compactionId,
title: "Compact conversation",
kind: "think",
status: "in_progress",
_meta: compactionToolMeta(),
});
return this.activeCompaction;
return state;
}
async heartbeat(sessionId: string, fallbackId: string): Promise<void> {
const state = this.activeCompaction ?? (await this.start(sessionId, fallbackId));
if (state.terminalStatus || state.heartbeatSent) return;
state.heartbeatSent = true;
await this.sendUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: state.toolCallId,
status: "in_progress",
_meta: compactionToolMeta(),
},
});
}
async finish(
sessionId: string,
fallbackId: string,
status: CompactionStatus,
metadata: Omit<ContextCompactionMetadata, "version"> = {},
enrichTerminal = false,
): Promise<void> {
const rawOutput = Object.keys(metadata).length > 0 ? metadata : undefined;
if (!this.activeCompaction) {
this.activeCompaction = {
toolCallId: fallbackId,
heartbeatSent: false,
terminalStatus: status,
};
this.outputDelivered = true;
if (status === "failed" && metadata.error) {
this.duplicateErrorOutput = metadata.error;
}
await this.sendUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: fallbackId,
title: "Compact conversation",
kind: "think",
status,
...(status === "failed" && metadata.error
? { content: [compactionErrorContent(metadata.error)] }
: {}),
...(rawOutput ? { rawOutput } : {}),
_meta: compactionToolMeta(metadata),
},
/**
* Streaming progress from the API's compaction content block.
*
* Under `compaction_update` the block's text is the retained summary and
* streams as `compaction_summary_chunk`s — but only into an entity the CLI
* announced with its `compacting` status. The block alone carries no
* terminal signal, so opening an entity from it would leave nothing but the
* turn-boundary `cancelled` to close a compaction that succeeded. The legacy
* tool call keeps its historical behavior: open on first sight, re-assert
* `in_progress` once.
*/
async heartbeat(fallbackId: string, summaryChunk?: string): Promise<void> {
if (this.presentation === "compaction_update") {
const state = this.activeCompaction;
if (!state || state.terminalStatus || !summaryChunk) return;
await this.send({
sessionUpdate: "compaction_summary_chunk",
compactionId: state.compactionId,
content: { type: "text", text: summaryChunk },
});
return;
}
const state = this.activeCompaction;
if (state.terminalStatus && !enrichTerminal) return;
const state = this.activeCompaction ?? (await this.start(fallbackId));
if (state.terminalStatus || state.heartbeatSent) return;
state.heartbeatSent = true;
await this.send({
sessionUpdate: "tool_call_update",
toolCallId: state.compactionId,
status: "in_progress",
_meta: compactionToolMeta(),
});
}
const firstTerminal = state.terminalStatus === undefined;
if (firstTerminal) state.terminalStatus = status;
/**
* The PostCompact hook's summary. The CLI awaits the hook before emitting
* the compaction's terminal frames, so the summary lands while its
* compaction is `in_progress` and rides on the terminal update. Anything
* else — no entity yet, or the entity is a previous, already-terminal
* compaction of the same turn whose status frame the consumer is still
* catching up to — belongs to the compaction whose frames are ahead of the
* consumer, and waits for it. Only the `compaction_update` presentation
* carries the summary; the legacy tool call never exposed it. Never throws:
* it runs detached from a hook callback. Returns whether a summary was
* retained.
*/
recordSummary(rawSummary: unknown): boolean {
if (this.presentation !== "compaction_update" || typeof rawSummary !== "string") return false;
const summary = compactionSummaryText(rawSummary);
if (!summary) return false;
const state = this.activeCompaction;
if (state && !state.terminalStatus) {
state.summary = summary;
} else {
this.pendingSummary = summary;
}
return true;
}
async finish(
compactionId: string,
status: CompactionStatus,
metadata: Omit<ContextCompactionMetadata, "version"> = {},
enrichTerminal = false,
): Promise<void> {
// The opening status can be missed (replay, or a terminal-only runtime):
// the first update for the ID is then already terminal.
const opened = this.activeCompaction === undefined;
const firstTerminal = !this.activeCompaction?.terminalStatus;
const state = this.activeCompaction ?? this.open(compactionId, status);
if (firstTerminal) {
state.terminalStatus = status;
} else if (!enrichTerminal) {
return;
}
if (status === "failed" && metadata.error) {
this.duplicateErrorOutput = metadata.error;
}
await this.sendUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: state.toolCallId,
...(firstTerminal ? { status } : {}),
...(status === "failed" && metadata.error
? { content: [compactionErrorContent(metadata.error)] }
: {}),
const terminalStatus = state.terminalStatus ?? status;
const hasMetadata = Object.keys(metadata).length > 0;
if (this.presentation === "compaction_update") {
const summary = firstTerminal && terminalStatus === "completed" ? state.summary : undefined;
await this.send({
sessionUpdate: "compaction_update",
compactionId: state.compactionId,
status: terminalStatus,
...(summary !== undefined ? { summary: [{ type: "text", text: summary }] } : {}),
...(terminalStatus === "failed" && metadata.error ? { error: metadata.error } : {}),
// `_meta` is a replace-patch: seed it with the first terminal, then
// only re-send it when the boundary adds facts, so a status-only
// duplicate can't wipe the token counts.
...(firstTerminal || hasMetadata ? { _meta: createContextCompactionMeta(metadata) } : {}),
});
return;
}
const rawOutput = hasMetadata ? metadata : undefined;
const errorContent =
status === "failed" && metadata.error
? { content: [compactionErrorContent(metadata.error)] }
: {};
if (opened) {
await this.send({
sessionUpdate: "tool_call",
toolCallId: state.compactionId,
title: "Compact conversation",
kind: "think",
status,
...errorContent,
...(rawOutput ? { rawOutput } : {}),
_meta: compactionToolMeta(metadata),
},
});
return;
}
await this.send({
sessionUpdate: "tool_call_update",
toolCallId: state.compactionId,
...(firstTerminal ? { status } : {}),
...errorContent,
...(rawOutput ? { rawOutput } : {}),
_meta: compactionToolMeta(metadata),
});
}
/** Make `compactionId` the active entity, moving a pending hook summary onto
* it, and mark compaction output as delivered for the owning turn. */
private open(compactionId: string, terminalStatus?: TerminalStatus): CompactionState {
const state: CompactionState = {
compactionId,
heartbeatSent: false,
...(terminalStatus ? { terminalStatus } : {}),
...(this.pendingSummary !== undefined ? { summary: this.pendingSummary } : {}),
};
this.pendingSummary = undefined;
this.activeCompaction = state;
this.outputDelivered = true;
return state;
}
private send(update: SessionNotification["update"]): Promise<void> {
return this.sendUpdate({ sessionId: this.sessionId, update });
}
private async sendQuietly(update: SessionNotification["update"], what: string): Promise<void> {
try {
await this.send(update);
} catch (error) {
this.logError(`Failed to send the ${what} update for session ${this.sessionId}`, error);
}
}
}
export function contextCompactionMetadataFromBoundary(compactMetadata: {

View File

@@ -450,7 +450,10 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
prompt(params: PromptRequest): Promise<PromptResponse>;
};
async function setupTestSession(cwd: string): Promise<{
async function setupTestSession(
cwd: string,
extraClientCapabilities: Partial<ClientCapabilities> = {},
): Promise<{
client: TestClient;
connection: TestConnection;
newSessionResponse: NewSessionResponse;
@@ -482,6 +485,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
elicitation: {
form: {},
},
...extraClientCapabilities,
},
});
@@ -602,12 +606,77 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
status: "in_progress",
_meta: { contextCompaction: { version: 1 } },
});
expect(compactionUpdates.at(-1)).toMatchObject({
// The terminal status lands on the compact_result frame; the boundary that
// follows only enriches the call with token counts (no status field).
const terminal = compactionUpdates.filter((update) => "status" in update && update.status);
expect(terminal.at(-1)).toMatchObject({
sessionUpdate: "tool_call_update",
status: "completed",
_meta: { contextCompaction: { version: 1 } },
});
}, 60000);
expect(compactionUpdates.at(-1)).toMatchObject({
sessionUpdate: "tool_call_update",
_meta: { contextCompaction: { version: 1, trigger: "manual" } },
});
}, 90000);
it("/compact reports the ACP compaction lifecycle to a capable client", async () => {
const { client, connection, newSessionResponse } = await setupTestSession(__dirname, {
session: { compaction: {} },
});
for (let i = 0; i < 6; i++) {
await connection.prompt({
prompt: [{ type: "text", text: `Reply with just the number ${i}.` }],
sessionId: newSessionResponse.sessionId,
});
client.takeReceivedText();
}
await connection.prompt({
prompt: [{ type: "text", text: "/compact" }],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toBe("");
const sessionUpdates = client.updates.map((notification) => notification.update);
// No legacy synthetic tool call for a client on the compaction contract.
expect(
sessionUpdates.some(
(update) =>
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
update._meta?.contextCompaction,
),
).toBe(false);
const compactionUpdates = sessionUpdates.filter(
(update) => update.sessionUpdate === "compaction_update",
);
expect(compactionUpdates[0]).toMatchObject({
status: "in_progress",
_meta: { contextCompaction: { version: 1 } },
});
const compactionId = compactionUpdates[0].compactionId;
expect(compactionUpdates.every((update) => update.compactionId === compactionId)).toBe(true);
expect(compactionUpdates.slice(1).every((update) => update.status === "completed")).toBe(true);
// The PostCompact hook's summary rides on the terminal update, stripped of
// the model's <analysis> block and <summary> tags.
const withSummary = compactionUpdates.find((update) => update.summary?.length);
expect(withSummary).toBeDefined();
const summaryText = withSummary!.summary![0];
expect(summaryText.type).toBe("text");
const text = summaryText.type === "text" ? summaryText.text : "";
expect(text.length).toBeGreaterThan(20);
// Tags can legitimately be quoted inside the summary; only the wrapper goes.
expect(text.startsWith("<analysis>")).toBe(false);
expect(text.startsWith("<summary>")).toBe(false);
expect(text.endsWith("</summary>")).toBe(false);
// The boundary enriches the entity with token counts.
expect(compactionUpdates.at(-1)).toMatchObject({
status: "completed",
_meta: { contextCompaction: { version: 1, trigger: "manual" } },
});
expect((compactionUpdates.at(-1)!._meta as any).contextCompaction.preTokens).toBeGreaterThan(0);
}, 90000);
// Regression guard for the SDK's AskUserQuestion routing. The built-in
// AskUserQuestion tool is delivered to us through `canUseTool` (not the
@@ -11311,6 +11380,356 @@ describe("assembled assistant text fallback", () => {
);
});
/** An agent whose client advertises the ACP session-compaction contract. */
function compactionCapableAgent() {
const capture = createMockAgentWithCapture();
(capture.agent as any).clientCapabilities = { session: { compaction: {} } };
return capture;
}
function compactingStatus(uuid = "compact-start") {
return {
type: "system",
subtype: "status",
status: "compacting",
uuid,
session_id: "test-session",
};
}
function compactResult(
result: "success" | "failed",
uuid: string,
error?: string,
): Record<string, unknown> {
return {
type: "system",
subtype: "status",
status: null,
compact_result: result,
...(error ? { compact_error: error } : {}),
uuid,
session_id: "test-session",
};
}
function compactionUpdates(updates: any[]) {
return updates
.map((notification) => notification.update)
.filter((update) => update.sessionUpdate === "compaction_update");
}
function hasSyntheticCompactionToolCall(updates: any[]) {
return updates
.map((notification) => notification.update)
.some(
(update) =>
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
update._meta?.contextCompaction,
);
}
it("emits the ACP compaction lifecycle for a client that advertises session.compaction", async () => {
const { agent, updates } = compactionCapableAgent();
injectSession(agent, [
compactingStatus(),
compactResult("success", "compact-completed"),
{
type: "system",
subtype: "compact_boundary",
uuid: "compact-boundary",
session_id: "test-session",
compact_metadata: {
trigger: "manual",
pre_tokens: 180000,
post_tokens: 12345,
duration_ms: 2500,
},
},
replayedResult("conversation summarized"),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "/compact" }] });
expect(compactionUpdates(updates)).toEqual([
{
sessionUpdate: "compaction_update",
compactionId: "compact-start",
status: "in_progress",
_meta: { contextCompaction: { version: 1 } },
},
{
sessionUpdate: "compaction_update",
compactionId: "compact-start",
status: "completed",
_meta: { contextCompaction: { version: 1 } },
},
{
sessionUpdate: "compaction_update",
compactionId: "compact-start",
status: "completed",
_meta: {
contextCompaction: {
version: 1,
trigger: "manual",
preTokens: 180000,
postTokens: 12345,
durationMs: 2500,
},
},
},
]);
// The synthetic tool call is the fallback for clients without the capability.
expect(hasSyntheticCompactionToolCall(updates)).toBe(false);
// The compaction entity counts as the turn's delivered output: the result
// text (the generated summary) is not forwarded as an agent message.
expect(messageChunkTexts(updates)).toEqual([]);
});
it("reports a failed compaction through compaction_update and swallows the duplicated stdout", async () => {
const { agent, updates } = compactionCapableAgent();
injectSession(agent, [
compactingStatus(),
compactResult("failed", "compact-failed-1", "summary rejected"),
compactResult("failed", "compact-failed-2", "summary rejected"),
{
type: "system",
subtype: "local_command_output",
content: "summary rejected",
uuid: "compact-local-output",
session_id: "test-session",
},
{
type: "system",
subtype: "local_command_output",
content: "additional diagnostic",
uuid: "compact-distinct-local-output",
session_id: "test-session",
},
replayedResult("summary rejected"),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "/compact" }] });
expect(compactionUpdates(updates)).toEqual([
{
sessionUpdate: "compaction_update",
compactionId: "compact-start",
status: "in_progress",
_meta: { contextCompaction: { version: 1 } },
},
{
sessionUpdate: "compaction_update",
compactionId: "compact-start",
status: "failed",
error: "summary rejected",
_meta: { contextCompaction: { version: 1, error: "summary rejected" } },
},
]);
expect(messageChunkTexts(updates)).toEqual(["additional diagnostic"]);
});
it("closes a compaction the turn abandoned as cancelled, before the prompt settles", async () => {
const { agent, updates } = compactionCapableAgent();
injectSession(agent, [compactingStatus(), replayedResult(""), idle]);
let updatesAtSettle: string[] | undefined;
const settled = agent
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "/compact" }] })
.then(() => {
updatesAtSettle = compactionUpdates(updates).map((update) => update.status);
});
await settled;
expect(updatesAtSettle).toEqual(["in_progress", "cancelled"]);
});
it("does not open a compaction entity from API compaction stream blocks alone", async () => {
// The API block carries no terminal signal; without the CLI's compacting
// status there is nothing to close an entity but the turn boundary, which
// would misreport a successful compaction as cancelled.
const { agent, updates } = compactionCapableAgent();
const compactionDelta = (uuid: string, content: string, parentToolUseId: string | null) => ({
type: "stream_event" as const,
parent_tool_use_id: parentToolUseId,
uuid,
session_id: "test-session",
event: {
type: "content_block_delta" as const,
index: 0,
delta: { type: "compaction_delta" as const, content, encrypted_content: null },
},
});
injectSession(agent, [
compactionDelta("compact-delta-1", "orphan ", null),
compactionDelta("compact-delta-2", "text", null),
compactingStatus(),
// A subagent compacting its own context never touches the root entity.
compactionDelta("subagent-delta", "child summary", "toolu_child"),
compactionDelta("compact-delta-3", "## Retained", null),
compactResult("success", "compact-completed"),
replayedResult(""),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
const compactionFrames = updates
.map((notification) => notification.update)
.filter(
(update) =>
update.sessionUpdate === "compaction_update" ||
update.sessionUpdate === "compaction_summary_chunk",
);
expect(compactionFrames).toEqual([
{
sessionUpdate: "compaction_update",
compactionId: "compact-start",
status: "in_progress",
_meta: { contextCompaction: { version: 1 } },
},
{
sessionUpdate: "compaction_summary_chunk",
compactionId: "compact-start",
content: { type: "text", text: "## Retained" },
},
{
sessionUpdate: "compaction_update",
compactionId: "compact-start",
status: "completed",
_meta: { contextCompaction: { version: 1 } },
},
]);
expect(JSON.stringify(updates)).not.toContain("child summary");
});
it("replays a persisted compaction summary as a completed compaction_update for capable clients", async () => {
const framedSummary =
"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" +
"If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /tmp/session.jsonl\n" +
"Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary.";
const summaryMessage = (uuid: string | undefined) => ({
type: "user",
uuid,
session_id: "test-session",
parent_tool_use_id: null,
parent_agent_id: null,
isCompactSummary: true,
message: { role: "user", content: framedSummary },
});
const replay = async (capable: boolean, messages: unknown[]) => {
const { agent, updates } = capable ? compactionCapableAgent() : createMockAgentWithCapture();
agent.sessions["test-session"] = mockSessionState();
vi.mocked(getSessionMessages).mockResolvedValueOnce(messages as any);
await (agent as any).replaySessionHistory("test-session");
return updates.map((notification) => notification.update);
};
expect(await replay(true, [summaryMessage("compact-summary")])).toEqual([
{
sessionUpdate: "compaction_update",
compactionId: "compact-summary",
status: "completed",
summary: [{ type: "text", text: "1. Primary Request and Intent:\n Count upward." }],
_meta: { contextCompaction: { version: 1 } },
},
]);
// Without the capability the transcript text keeps replaying as before.
const legacy = await replay(false, [summaryMessage("compact-summary")]);
expect(legacy.some((update) => update.sessionUpdate === "compaction_update")).toBe(false);
expect(legacy.some((update) => update.sessionUpdate === "user_message_chunk")).toBe(true);
// A record without a uuid cannot be an entity; it stays transcript text.
const noId = await replay(true, [summaryMessage(undefined)]);
expect(noId.some((update) => update.sessionUpdate === "compaction_update")).toBe(false);
expect(noId.some((update) => update.sessionUpdate === "user_message_chunk")).toBe(true);
// A subagent's own compaction summary is not the root session's entity.
const child = await replay(true, [
{ ...summaryMessage("child-summary"), parent_tool_use_id: "toolu_child" },
]);
expect(child.some((update) => update.sessionUpdate === "compaction_update")).toBe(false);
// Unrecognized framing: the text stays visible rather than vanishing
// behind a summary-less entity.
const unknownFraming = await replay(true, [
{
...summaryMessage("odd-summary"),
message: {
role: "user",
content:
"This session is being continued from a previous conversation that ran out of context. Here is what happened:\n\nCounted.",
},
},
]);
expect(unknownFraming.some((update) => update.sessionUpdate === "compaction_update")).toBe(
false,
);
expect(unknownFraming.some((update) => update.sessionUpdate === "user_message_chunk")).toBe(
true,
);
});
it("ends a dangling replayed audit lane at a compaction summary", async () => {
// A Stop-hook audit whose result was never persisted must not hide the
// post-compaction history from a client on the compaction contract.
const { agent, updates } = compactionCapableAgent();
agent.sessions["test-session"] = mockSessionState();
vi.mocked(getSessionMessages).mockResolvedValueOnce([
{
type: "user",
uuid: "audit-marker",
session_id: "test-session",
parent_tool_use_id: null,
parent_agent_id: null,
message: {
role: "user",
content: [
{
type: "text",
text: "<claude-agent-acp-file-change-audit>report now</claude-agent-acp-file-change-audit>",
},
],
},
},
{
type: "user",
uuid: "compact-summary",
session_id: "test-session",
parent_tool_use_id: null,
parent_agent_id: null,
isCompactSummary: true,
message: {
role: "user",
content:
"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\nSummary:\nCounted.",
},
},
{
type: "assistant",
uuid: "after",
session_id: "test-session",
parent_tool_use_id: null,
parent_agent_id: null,
message: {
role: "assistant",
model: "claude-sonnet-4-6",
content: [{ type: "text", text: "Back after compaction." }],
},
},
] as any);
await (agent as any).replaySessionHistory("test-session");
const kinds = updates.map((notification) => notification.update.sessionUpdate);
expect(kinds).toContain("compaction_update");
expect(messageChunkTexts(updates)).toEqual(["Back after compaction."]);
});
it("preserves the model response after multiple compactions in one turn", async () => {
const { agent, updates } = createMockAgentWithCapture();
injectSession(agent, [

View File

@@ -0,0 +1,466 @@
import { describe, expect, it, vi } from "vitest";
import type { SessionNotification } from "@agentclientprotocol/sdk";
import {
ContextCompactionLifecycle,
clientSupportsCompactionUpdates,
compactionSummaryText,
isCompactSummaryMessage,
} from "../context-compaction.js";
const PERSISTED_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\n2. Optional Next Step:\n None.\n\n" +
"If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /tmp/session.jsonl\n" +
"Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening.";
function lifecycle(
presentation: "tool_call" | "compaction_update",
sendUpdate?: (notification: SessionNotification) => Promise<void>,
) {
const sent: SessionNotification["update"][] = [];
const logError = vi.fn();
const compaction = new ContextCompactionLifecycle(
sendUpdate ??
(async (notification) => {
sent.push(notification.update);
}),
{ sessionId: "s", presentation, logError },
);
return { sent, compaction, logError };
}
describe("clientSupportsCompactionUpdates", () => {
it("requires the v1 session.compaction object", () => {
expect(clientSupportsCompactionUpdates({ session: { compaction: {} } })).toBe(true);
expect(clientSupportsCompactionUpdates({ session: { compaction: { extra: true } } })).toBe(
true,
);
expect(clientSupportsCompactionUpdates({ session: { compaction: null } })).toBe(false);
expect(clientSupportsCompactionUpdates({ session: null })).toBe(false);
expect(clientSupportsCompactionUpdates({ session: {} })).toBe(false);
expect(clientSupportsCompactionUpdates({})).toBe(false);
expect(clientSupportsCompactionUpdates(undefined)).toBe(false);
expect(clientSupportsCompactionUpdates(null)).toBe(false);
});
});
describe("compactionSummaryText", () => {
it("keeps only the <summary> body of the hook's raw model output", () => {
expect(
compactionSummaryText(
"<analysis>\nThinking about it.\n</analysis>\n\n<summary>\n## Retained\n\nThe user counted.\n</summary>",
),
).toBe("## Retained\n\nThe user counted.");
});
it("is not fooled by tag names quoted inside the analysis or the summary", () => {
expect(
compactionSummaryText(
"<analysis>\nThe user asked for an <analysis> block followed by a <summary> block.\n</analysis>\n\n" +
"<summary>\n1. Intent:\n Produce an `<analysis>` and a `<summary>` block, then </summary> nothing.\n</summary>\n",
),
).toBe(
"1. Intent:\n Produce an `<analysis>` and a `<summary>` block, then </summary> nothing.",
);
});
it("takes the widest inline span and stays linear without a closing tag", () => {
expect(compactionSummaryText("<summary>Inline </summary> quoted.</summary>")).toBe(
"Inline </summary> quoted.",
);
const unterminated = "<summary> mention ".repeat(5000);
const started = performance.now();
expect(compactionSummaryText(unterminated)).toBe(unterminated.trim());
expect(performance.now() - started).toBeLessThan(200);
});
it("strips the persisted continuation framing", () => {
expect(compactionSummaryText(PERSISTED_SUMMARY)).toBe(
"1. Primary Request and Intent:\n Count upward.\n\n2. Optional Next Step:\n None.",
);
});
it("strips the older tagged transcript framing", () => {
expect(
compactionSummaryText(
"This session is being continued from a previous conversation that ran out of context. The conversation is summarized below:\n<summary>\nOld style body.\n</summary>\nPlease continue the conversation from where it left it off without asking the user any further questions.",
),
).toBe("Old style body.");
});
it("fails closed on a continuation prompt whose framing it does not recognize", () => {
expect(
compactionSummaryText(
"This session is being continued from a previous conversation that ran out of context. Here is what happened:\n\nThe user counted.\n\nResume directly.",
),
).toBeUndefined();
});
it("strips a leading analysis block even when the summary tags are missing", () => {
expect(
compactionSummaryText(
"<analysis>\nPrivate reasoning about <summary> tags.\n</analysis>\n\n1. Primary Request:\n Count.",
),
).toBe("1. Primary Request:\n Count.");
expect(compactionSummaryText("<analysis>inline</analysis> Body.")).toBe("Body.");
// A body quoting both tag names inline is not an analysis block.
expect(compactionSummaryText("Body mentions <analysis> and </analysis> inline.")).toBe(
"Body mentions <analysis> and </analysis> inline.",
);
});
it("does not let a <summary> mention inside an inline analysis anchor the span", () => {
expect(
compactionSummaryText(
"<analysis>Wants a <summary> block.</analysis> <summary>Real body.</summary>",
),
).toBe("Real body.");
});
it("passes plain text through and drops empty summaries", () => {
expect(compactionSummaryText(" Just a summary. ")).toBe("Just a summary.");
expect(compactionSummaryText("")).toBeUndefined();
expect(compactionSummaryText("<summary> </summary>")).toBeUndefined();
});
});
describe("isCompactSummaryMessage", () => {
it("matches only user messages flagged by Claude Code", () => {
expect(isCompactSummaryMessage({ type: "user", isCompactSummary: true })).toBe(true);
expect(isCompactSummaryMessage({ type: "user" })).toBe(false);
expect(isCompactSummaryMessage({ type: "assistant", isCompactSummary: true })).toBe(false);
expect(isCompactSummaryMessage(null)).toBe(false);
});
});
describe("ContextCompactionLifecycle (compaction_update)", () => {
it("opens in_progress, carries the hook summary on the terminal update, then enriches from the boundary", async () => {
const { sent, compaction } = lifecycle("compaction_update");
await compaction.start("cmp-1");
compaction.recordSummary("<analysis>x</analysis><summary>\nRetained.\n</summary>");
await compaction.finish("cmp-status", "completed");
await compaction.finish(
"cmp-boundary",
"completed",
{ trigger: "manual", preTokens: 100, postTokens: 10, durationMs: 5 },
true,
);
// A duplicated terminal status neither re-sends nor clears the metadata.
await compaction.finish("cmp-status-dup", "completed");
expect(sent).toEqual([
{
sessionUpdate: "compaction_update",
compactionId: "cmp-1",
status: "in_progress",
_meta: { contextCompaction: { version: 1 } },
},
{
sessionUpdate: "compaction_update",
compactionId: "cmp-1",
status: "completed",
summary: [{ type: "text", text: "Retained." }],
_meta: { contextCompaction: { version: 1 } },
},
{
sessionUpdate: "compaction_update",
compactionId: "cmp-1",
status: "completed",
_meta: {
contextCompaction: {
version: 1,
trigger: "manual",
preTokens: 100,
postTokens: 10,
durationMs: 5,
},
},
},
]);
expect(compaction.hasDeliveredOutput).toBe(true);
});
it("holds a summary recorded before the opening status and attaches it once", async () => {
const { sent, compaction } = lifecycle("compaction_update");
compaction.recordSummary("<summary>Early.</summary>");
expect(sent).toEqual([]);
await compaction.start("cmp-1");
await compaction.finish("cmp-1", "completed");
await compaction.finish("cmp-b", "completed", { trigger: "automatic", preTokens: 1 }, true);
expect(sent.map((u) => ("summary" in u ? u.summary : undefined))).toEqual([
undefined,
[{ type: "text", text: "Early." }],
undefined,
]);
});
it("keeps a pending summary across a previous turn's reset", async () => {
// The hook fires before the compaction's terminal frames, so a summary
// that arrives while the consumer is still draining the previous turn
// belongs to the compaction whose frames are still ahead of it.
const { sent, compaction } = lifecycle("compaction_update");
compaction.recordSummary("<summary>Next turn's.</summary>");
await compaction.reset();
await compaction.start("cmp-2");
await compaction.finish("cmp-2", "completed");
expect(sent.at(-1)).toMatchObject({
compactionId: "cmp-2",
status: "completed",
summary: [{ type: "text", text: "Next turn's." }],
});
});
it("holds a summary that arrives after a terminal entity for the next compaction", async () => {
// The hook precedes its own compaction's terminal frames, so a summary
// seen after compaction A's terminal is B's, whose `compacting` status the
// consumer has not reached yet.
const { sent, compaction } = lifecycle("compaction_update");
await compaction.start("cmp-a");
await compaction.finish("cmp-a", "completed");
compaction.recordSummary("<summary>B's summary.</summary>");
expect(sent).toHaveLength(2);
await compaction.start("cmp-b");
await compaction.finish("cmp-b", "completed");
expect(sent.at(-1)).toMatchObject({
compactionId: "cmp-b",
status: "completed",
summary: [{ type: "text", text: "B's summary." }],
});
});
it("ignores a malformed or empty hook payload and reports whether a summary was kept", () => {
const { compaction } = lifecycle("compaction_update");
expect(compaction.recordSummary(undefined)).toBe(false);
expect(compaction.recordSummary(null)).toBe(false);
expect(compaction.recordSummary({ text: "x" })).toBe(false);
expect(compaction.recordSummary("<summary> </summary>")).toBe(false);
expect(compaction.recordSummary("<summary>Kept.</summary>")).toBe(true);
});
it("materializes a terminal-only compaction as a single completed update", async () => {
const { sent, compaction } = lifecycle("compaction_update");
compaction.recordSummary("<summary>Only terminal.</summary>");
await compaction.finish("cmp-boundary", "completed", {
trigger: "automatic",
preTokens: 50,
});
expect(sent).toEqual([
{
sessionUpdate: "compaction_update",
compactionId: "cmp-boundary",
status: "completed",
summary: [{ type: "text", text: "Only terminal." }],
_meta: { contextCompaction: { version: 1, trigger: "automatic", preTokens: 50 } },
},
]);
});
it("seeds _meta on a boundary-first terminal even without metadata", async () => {
const { sent, compaction } = lifecycle("compaction_update");
await compaction.finish("cmp-boundary", "completed", {}, true);
expect(sent).toEqual([
{
sessionUpdate: "compaction_update",
compactionId: "cmp-boundary",
status: "completed",
_meta: { contextCompaction: { version: 1 } },
},
]);
});
it("reports failures with the error and consumes the duplicated stdout once", async () => {
const { sent, compaction } = lifecycle("compaction_update");
await compaction.start("cmp-1");
compaction.recordSummary("<summary>Never shown.</summary>");
await compaction.finish("cmp-1", "failed", { error: "summary rejected" });
expect(sent[1]).toEqual({
sessionUpdate: "compaction_update",
compactionId: "cmp-1",
status: "failed",
error: "summary rejected",
_meta: { contextCompaction: { version: 1, error: "summary rejected" } },
});
expect(compaction.consumeDuplicateErrorOutput("summary rejected\n")).toBe(true);
expect(compaction.consumeDuplicateErrorOutput("summary rejected")).toBe(false);
// A summary never patches a failed entity.
compaction.recordSummary("<summary>Still never.</summary>");
expect(sent).toHaveLength(2);
});
it("streams API compaction text as summary chunks only into an announced entity", async () => {
const { sent, compaction } = lifecycle("compaction_update");
// The API block alone never opens an entity: nothing would close it.
await compaction.heartbeat("cmp-stream", undefined);
await compaction.heartbeat("cmp-stream", "orphan text");
expect(sent).toEqual([]);
expect(compaction.hasDeliveredOutput).toBe(false);
await compaction.start("cmp-1");
await compaction.heartbeat("cmp-stream", undefined);
await compaction.heartbeat("cmp-stream", "## Retained\n");
await compaction.heartbeat("cmp-stream", "The user counted.");
await compaction.finish("cmp-1", "completed");
await compaction.heartbeat("cmp-stream", "ignored after terminal");
expect(sent).toEqual([
{
sessionUpdate: "compaction_update",
compactionId: "cmp-1",
status: "in_progress",
_meta: { contextCompaction: { version: 1 } },
},
{
sessionUpdate: "compaction_summary_chunk",
compactionId: "cmp-1",
content: { type: "text", text: "## Retained\n" },
},
{
sessionUpdate: "compaction_summary_chunk",
compactionId: "cmp-1",
content: { type: "text", text: "The user counted." },
},
{
sessionUpdate: "compaction_update",
compactionId: "cmp-1",
status: "completed",
_meta: { contextCompaction: { version: 1 } },
},
]);
});
it("closes an in-progress entity as cancelled on reset and starts fresh afterwards", async () => {
const { sent, compaction } = lifecycle("compaction_update");
await compaction.start("cmp-1");
await compaction.reset();
expect(sent.at(-1)).toEqual({
sessionUpdate: "compaction_update",
compactionId: "cmp-1",
status: "cancelled",
});
expect(compaction.hasDeliveredOutput).toBe(false);
// Terminal entities are left alone.
await compaction.start("cmp-2");
await compaction.finish("cmp-2", "completed");
const before = sent.length;
await compaction.reset();
expect(sent).toHaveLength(before);
});
it("never throws from reset", async () => {
const failing = vi.fn<(notification: SessionNotification) => Promise<void>>(async () => {
throw new Error("client gone");
});
const { compaction, logError } = lifecycle("compaction_update", failing);
// start() itself propagates like every other consumer send.
await expect(compaction.start("cmp-1")).rejects.toThrow("client gone");
await expect(compaction.reset()).resolves.toBeUndefined();
expect(logError).toHaveBeenCalledTimes(1);
});
it("starts a new entity for a compaction that follows a terminal one", async () => {
const { sent, compaction } = lifecycle("compaction_update");
await compaction.start("cmp-1");
await compaction.finish("cmp-1", "completed");
await compaction.start("cmp-2");
expect(sent.at(-1)).toMatchObject({ compactionId: "cmp-2", status: "in_progress" });
});
});
describe("ContextCompactionLifecycle (tool_call)", () => {
it("keeps the legacy synthetic tool call and never exposes the summary", async () => {
const { sent, compaction } = lifecycle("tool_call");
await compaction.start("compact-start");
await compaction.heartbeat("compact-start", "streamed summary text");
await compaction.heartbeat("compact-start", "more");
compaction.recordSummary("<summary>Hidden.</summary>");
await compaction.finish("compact-start", "completed");
await compaction.reset();
expect(sent).toEqual([
{
sessionUpdate: "tool_call",
toolCallId: "compact-start",
title: "Compact conversation",
kind: "think",
status: "in_progress",
_meta: { contextCompaction: { version: 1 }, claudeCode: { toolName: "compact" } },
},
{
sessionUpdate: "tool_call_update",
toolCallId: "compact-start",
status: "in_progress",
_meta: { contextCompaction: { version: 1 }, claudeCode: { toolName: "compact" } },
},
{
sessionUpdate: "tool_call_update",
toolCallId: "compact-start",
status: "completed",
_meta: { contextCompaction: { version: 1 }, claudeCode: { toolName: "compact" } },
},
]);
});
it("materializes a missed opening as a terminal tool_call with the failure content", async () => {
const { sent, compaction } = lifecycle("tool_call");
await compaction.finish("compact-failed", "failed", { error: "summary rejected" });
// Legacy duplicates are ignored, boundary enrichment carries no status.
await compaction.finish("compact-failed", "failed", { error: "summary rejected" });
await compaction.finish(
"compact-boundary",
"completed",
{ trigger: "manual", preTokens: 3 },
true,
);
expect(sent).toEqual([
{
sessionUpdate: "tool_call",
toolCallId: "compact-failed",
title: "Compact conversation",
kind: "think",
status: "failed",
content: [
{
type: "content",
content: { type: "text", text: "Compaction failed: summary rejected" },
},
],
rawOutput: { error: "summary rejected" },
_meta: {
contextCompaction: { version: 1, error: "summary rejected" },
claudeCode: { toolName: "compact" },
},
},
{
sessionUpdate: "tool_call_update",
toolCallId: "compact-failed",
rawOutput: { trigger: "manual", preTokens: 3 },
_meta: {
contextCompaction: { version: 1, trigger: "manual", preTokens: 3 },
claudeCode: { toolName: "compact" },
},
},
]);
});
});

View File

@@ -1170,6 +1170,49 @@ describe("createSession options merging", () => {
);
});
it("routes the PostCompact hook's summary into the session's compaction lifecycle", async () => {
// The retained summary only reaches the SDK stream framed as the
// model-facing continuation prompt; the hook is the adapter's source for
// the ACP compaction_update summary.
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
const sessionId = response.sessionId;
const matchers = capturedOptions!.hooks?.PostCompact;
expect(matchers).toBeDefined();
const callback = (matchers!.at(-1) as any).hooks[0];
// The lifecycle belongs to the stream consumer, which the first prompt
// starts; a compaction can only ever fire while it is running.
(agent as any).ensureConsumer((agent as any).sessions[sessionId], sessionId);
const lifecycle = (agent as any).sessions[sessionId].contextCompaction;
expect(lifecycle).toBeDefined();
const recordSummary = vi.spyOn(lifecycle, "recordSummary");
const base = {
hook_event_name: "PostCompact",
session_id: sessionId,
transcript_path: "",
cwd: process.cwd(),
trigger: "manual",
};
// A subagent's compaction is not the root session's entity.
await callback(
{ ...base, agent_id: "agent-1", compact_summary: "<summary>child</summary>" },
undefined,
{ signal: new AbortController().signal },
);
expect(recordSummary).not.toHaveBeenCalled();
const out = await callback(
{ ...base, compact_summary: "<analysis>x</analysis><summary>Retained.</summary>" },
undefined,
{ signal: new AbortController().signal },
);
expect(out).toEqual({ continue: true });
expect(recordSummary).toHaveBeenCalledWith(
"<analysis>x</analysis><summary>Retained.</summary>",
);
});
it("syncs adapter model state when a PostModelSwitch hook reports an external switch", async () => {
// A `/model <name>` command typed as a prompt switches the session's
// model with no refusal-fallback frame; the registered PostModelSwitch