mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: Add experimental ACP compaction update support (#1134)
This commit is contained in:
122
src/acp-agent.ts
122
src/acp-agent.ts
@@ -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: [
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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, [
|
||||
|
||||
466
src/tests/context-compaction.test.ts
Normal file
466
src/tests/context-compaction.test.ts
Normal 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" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user