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"; } from "./file-change-audit.js";
import { import {
ContextCompactionLifecycle, ContextCompactionLifecycle,
clientSupportsCompactionUpdates,
contextCompactionMetadataFromBoundary, contextCompactionMetadataFromBoundary,
isCompactSummaryMessage,
} from "./context-compaction.js"; } from "./context-compaction.js";
import { import {
applyTaskCreate, applyTaskCreate,
@@ -996,6 +998,9 @@ export type Session = {
/** Session-owned async task controller. Prompt cancellation intentionally /** Session-owned async task controller. Prompt cancellation intentionally
* does not finish it because background work may outlive a prompt. */ * does not finish it because background work may outlive a prompt. */
asyncTaskRuntime?: AsyncTaskRuntime; 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 /** 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 * stretch boundary. Set as a side effect of sending in the consumer's
* `sendUpdate`, never at an emission site; read at the terminal `result` * `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)), 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 sendUpdate = async (notification: AcpSessionNotification) => {
const { update } = notification; const { update } = notification;
const claudeMeta = update._meta?.claudeCode as const claudeMeta = update._meta?.claudeCode as
@@ -3226,7 +3238,9 @@ export class ClaudeAcpAgent {
update.sessionUpdate === "agent_thought_chunk" || update.sessionUpdate === "agent_thought_chunk" ||
update.sessionUpdate === "user_message_chunk" || update.sessionUpdate === "user_message_chunk" ||
update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call" ||
update.sessionUpdate === "tool_call_update") update.sessionUpdate === "tool_call_update" ||
update.sessionUpdate === "compaction_update" ||
update.sessionUpdate === "compaction_summary_chunk")
) { ) {
return; return;
} }
@@ -4136,11 +4150,11 @@ export class ClaudeAcpAgent {
break; break;
case "status": { case "status": {
if (message.status === "compacting") { if (message.status === "compacting") {
await compaction.start(message.session_id, message.uuid); await compaction.start(message.uuid);
} else if (message.compact_result === "success") { } 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") { } 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 } : {}), ...(message.compact_error ? { error: message.compact_error } : {}),
}); });
} }
@@ -4164,7 +4178,6 @@ export class ClaudeAcpAgent {
// //
const compactMetadata = message.compact_metadata; const compactMetadata = message.compact_metadata;
await compaction.finish( await compaction.finish(
message.session_id,
message.uuid, message.uuid,
"completed", "completed",
compactMetadata ? contextCompactionMetadataFromBoundary(compactMetadata) : {}, compactMetadata ? contextCompactionMetadataFromBoundary(compactMetadata) : {},
@@ -4258,7 +4271,7 @@ export class ClaudeAcpAgent {
// the interrupted turn's tokens entirely (issue #844). Zero // the interrupted turn's tokens entirely (issue #844). Zero
// when the cancel pre-empted the result (wedge/force-cancel). // when the cancel pre-empted the result (wedge/force-cancel).
if (session.cancelled && session.activeTurn && !session.activeTurn.settled) { if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
compaction.reset(); await compaction.reset();
settleActive(turnOutcome(session, "cancelled")); settleActive(turnOutcome(session, "cancelled"));
// An interrupt can pre-empt the turn's result entirely // An interrupt can pre-empt the turn's result entirely
// (nothing ran the result-case `finally`), so close the // (nothing ran the result-case `finally`), so close the
@@ -4321,7 +4334,7 @@ export class ClaudeAcpAgent {
session.activeTurn && session.activeTurn &&
!session.activeTurn.settled !session.activeTurn.settled
) { ) {
compaction.reset(); await compaction.reset();
// Deliberately only the ACTIVE turn: a queued turn that // Deliberately only the ACTIVE turn: a queued turn that
// was never echoed is NOT failed here, because an idle // was never echoed is NOT failed here, because an idle
// can legitimately precede the SDK picking up freshly // 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 // then, so both branches no-op); cancellation is left to the
// idle/abort path. settleActive is idempotent, so a duplicate // idle/abort path. settleActive is idempotent, so a duplicate
// idle is a no-op. // 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) { if (!session.cancelled) {
settleOrDefer(turnOutcome(session, stopReason)); settleOrDefer(turnOutcome(session, stopReason));
} }
} finally { } finally {
if (!isAutonomousResult) { if (!isAutonomousResult) {
session.emittedAssistantText = false; session.emittedAssistantText = false;
// A result closes this compaction lifecycle. Reset here rather // The early exits above (cancelled guard, refusal) skip the
// than at idle: an owed idle from this turn can arrive after // pre-settle reset; idempotent, so a no-op on the normal path.
// the next turn has already started and must not erase that await compaction.reset();
// turn's compaction state.
compaction.reset();
} }
} }
break; break;
} }
case "stream_event": { case "stream_event": {
const isCompactionProgress = // The API's compaction block streams the retained summary text;
(message.event.type === "content_block_start" && // `content` is null on the opening block and on a failed compaction.
message.event.content_block.type === "compaction") || // Only the root conversation's compaction is the session's: a
(message.event.type === "content_block_delta" && // subagent compacting its own context must not touch it.
message.event.delta.type === "compaction_delta"); const compactionBlock =
if (isCompactionProgress) { message.parent_tool_use_id !== null
await compaction.heartbeat(message.session_id, message.uuid); ? 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 // `message_start` carries the Anthropic API message id; capture it
// so the streamed chunks that follow (whose delta events don't carry // so the streamed chunks that follow (whose delta events don't carry
@@ -6400,6 +6431,7 @@ export class ClaudeAcpAgent {
} }
session.queryClosed = true; session.queryClosed = true;
session.consumer = undefined; session.consumer = undefined;
session.contextCompaction = undefined;
session.settingsManager.dispose(); session.settingsManager.dispose();
session.input.end(); session.input.end();
session.query.close(); session.query.close();
@@ -6599,6 +6631,7 @@ export class ClaudeAcpAgent {
let replayingFileChangeAudit = false; let replayingFileChangeAudit = false;
const replayFileChangeAuditToolUseIds = new Set<string>(); const replayFileChangeAuditToolUseIds = new Set<string>();
const nativeReplayEnabled = clientSupportsSubagents(this.clientCapabilities); const nativeReplayEnabled = clientSupportsSubagents(this.clientCapabilities);
const replayCompactionUpdates = clientSupportsCompactionUpdates(this.clientCapabilities);
const replayTerminalStates = new Map<string, "completed" | "failed" | "cancelled">(); const replayTerminalStates = new Map<string, "completed" | "failed" | "cancelled">();
const replayChildren = new Map< const replayChildren = new Map<
string, 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( for (const notification of toAcpNotifications(
// @ts-expect-error - untyped in SDK but we handle all of these // @ts-expect-error - untyped in SDK but we handle all of these
content, 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 ...(fileChangeAuditSupport
? { ? {
Stop: [ Stop: [

View File

@@ -1,21 +1,133 @@
import { SessionNotification } from "@agentclientprotocol/sdk"; import { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
import { import {
ContextCompactionMetadata, ContextCompactionMetadata,
createContextCompactionMeta, createContextCompactionMeta,
} from "./context-compaction-meta.js"; } from "./context-compaction-meta.js";
type CompactionStatus = "completed" | "failed"; 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 = { type CompactionState = {
toolCallId: string; /** Doubles as the toolCallId under the legacy presentation. */
terminalStatus?: CompactionStatus; compactionId: string;
terminalStatus?: TerminalStatus;
heartbeatSent: boolean; 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>; 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 * The SDK can duplicate terminal compact_result messages and can omit the
* opening status on replay. State therefore lives until the owning turn's * opening status on replay. State therefore lives until the owning turn's
@@ -26,22 +138,59 @@ export class ContextCompactionLifecycle {
private activeCompaction: CompactionState | undefined; private activeCompaction: CompactionState | undefined;
private outputDelivered = false; private outputDelivered = false;
private duplicateErrorOutput: string | undefined; 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 { get hasDeliveredOutput(): boolean {
return this.outputDelivered; 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.activeCompaction = undefined;
this.outputDelivered = false; this.outputDelivered = false;
this.duplicateErrorOutput = undefined; 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 * 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. * without hiding unrelated command output.
*/ */
consumeDuplicateErrorOutput(content: string): boolean { consumeDuplicateErrorOutput(content: string): boolean {
@@ -55,101 +204,184 @@ export class ContextCompactionLifecycle {
return true; return true;
} }
async start(sessionId: string, toolCallId: string): Promise<CompactionState> { async start(compactionId: string): Promise<CompactionState> {
if (this.activeCompaction && !this.activeCompaction.terminalStatus) { if (this.activeCompaction && !this.activeCompaction.terminalStatus) {
return this.activeCompaction; return this.activeCompaction;
} }
this.activeCompaction = { toolCallId, heartbeatSent: false }; const state = this.open(compactionId);
this.outputDelivered = true; if (this.presentation === "compaction_update") {
await this.sendUpdate({ await this.send({
sessionId, sessionUpdate: "compaction_update",
update: { compactionId,
sessionUpdate: "tool_call",
toolCallId,
title: "Compact conversation",
kind: "think",
status: "in_progress", 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)); * Streaming progress from the API's compaction content block.
if (state.terminalStatus || state.heartbeatSent) return; *
* Under `compaction_update` the block's text is the retained summary and
state.heartbeatSent = true; * streams as `compaction_summary_chunk`s — but only into an entity the CLI
await this.sendUpdate({ * announced with its `compacting` status. The block alone carries no
sessionId, * terminal signal, so opening an entity from it would leave nothing but the
update: { * turn-boundary `cancelled` to close a compaction that succeeded. The legacy
sessionUpdate: "tool_call_update", * tool call keeps its historical behavior: open on first sight, re-assert
toolCallId: state.toolCallId, * `in_progress` once.
status: "in_progress", */
_meta: compactionToolMeta(), 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({
async finish( sessionUpdate: "compaction_summary_chunk",
sessionId: string, compactionId: state.compactionId,
fallbackId: string, content: { type: "text", text: summaryChunk },
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),
},
}); });
return; return;
} }
const state = this.activeCompaction; const state = this.activeCompaction ?? (await this.start(fallbackId));
if (state.terminalStatus && !enrichTerminal) return; 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) { if (status === "failed" && metadata.error) {
this.duplicateErrorOutput = metadata.error; this.duplicateErrorOutput = metadata.error;
} }
await this.sendUpdate({ const terminalStatus = state.terminalStatus ?? status;
sessionId, const hasMetadata = Object.keys(metadata).length > 0;
update: {
sessionUpdate: "tool_call_update", if (this.presentation === "compaction_update") {
toolCallId: state.toolCallId, const summary = firstTerminal && terminalStatus === "completed" ? state.summary : undefined;
...(firstTerminal ? { status } : {}), await this.send({
...(status === "failed" && metadata.error sessionUpdate: "compaction_update",
? { content: [compactionErrorContent(metadata.error)] } 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 } : {}), ...(rawOutput ? { rawOutput } : {}),
_meta: compactionToolMeta(metadata), _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: { 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>; prompt(params: PromptRequest): Promise<PromptResponse>;
}; };
async function setupTestSession(cwd: string): Promise<{ async function setupTestSession(
cwd: string,
extraClientCapabilities: Partial<ClientCapabilities> = {},
): Promise<{
client: TestClient; client: TestClient;
connection: TestConnection; connection: TestConnection;
newSessionResponse: NewSessionResponse; newSessionResponse: NewSessionResponse;
@@ -482,6 +485,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
elicitation: { elicitation: {
form: {}, form: {},
}, },
...extraClientCapabilities,
}, },
}); });
@@ -602,12 +606,77 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
status: "in_progress", status: "in_progress",
_meta: { contextCompaction: { version: 1 } }, _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", sessionUpdate: "tool_call_update",
status: "completed", status: "completed",
_meta: { contextCompaction: { version: 1 } }, _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 // Regression guard for the SDK's AskUserQuestion routing. The built-in
// AskUserQuestion tool is delivered to us through `canUseTool` (not the // 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 () => { it("preserves the model response after multiple compactions in one turn", async () => {
const { agent, updates } = createMockAgentWithCapture(); const { agent, updates } = createMockAgentWithCapture();
injectSession(agent, [ 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 () => { 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 // A `/model <name>` command typed as a prompt switches the session's
// model with no refusal-fallback frame; the registered PostModelSwitch // model with no refusal-fallback frame; the registered PostModelSwitch