Files
claude-agent-acp/src/tests/context-compaction.test.ts
Ben Brandt a05aca6b81 fix: close interrupted compaction lifecycles (#1154)
## Summary

Close unfinished ACP compactions before interrupted prompts settle.
Previously, force cancellation could return `stopReason: cancelled`
while leaving the compaction `in_progress`; SDK stream teardown and
conversation reset had similar cleanup gaps.

- Add shared, idempotent interruption cleanup for cancellation,
force-cancel recovery, provider failures, SDK EOF/errors, and
conversation reset.
- Close compactions before resolving echo-less `/compact` commands,
which may still be queued while their compaction is running.
- Discard abandoned pending summaries and ignore late compaction
openings, completions, chunks, and hooks until an identified live turn
resumes.
- Preserve normal early-summary buffering across ordinary result
boundaries.
- Add regression coverage for terminal-before-response ordering,
echo-less cancellation, late SDK events, fresh-turn recovery,
EOF/errors, reset, and best-effort delivery failures.

After interruption, hooks without a command identity are conservatively
omitted until a new live turn is identified, rather than risking
attribution to the wrong compaction. Older echo-less commands without
dispatch identity may likewise omit the ambiguous compaction lifecycle
until turn activation.

This fixes an implementation gap found while auditing the [Session
Compaction
RFD](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/session-compaction.mdx).
No protocol or RFD changes are included.

## Validation

- [x] `npm run test:run` — 1,397 passed, 28 skipped
- [x] `npm run build`
- [x] `npm run check` — ESLint and Prettier
- [x] `git diff --check`

Tests use mocked SDK streams; no live provider calls were made.
2026-09-22 14:19:42 +02:00

541 lines
20 KiB
TypeScript

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("ignores interrupted work's late frames and hooks until a new turn begins", async () => {
const { sent, compaction } = lifecycle("compaction_update");
await compaction.start("cmp-1");
compaction.recordSummary("<summary>Abandoned.</summary>");
await compaction.interrupt();
await compaction.interrupt();
// An orphaned result still calls reset; it must not reopen the tail.
await compaction.reset();
await compaction.start("cmp-1");
await compaction.start("unseen-late-opening");
await compaction.heartbeat("late-stream", "late chunk");
await compaction.finish("late-status", "completed");
await compaction.finish("late-boundary", "completed", { preTokens: 100 }, true);
expect(compaction.recordSummary("<summary>Late hook.</summary>")).toBe(false);
expect(sent).toHaveLength(2);
expect(sent[1]).toEqual({
sessionUpdate: "compaction_update",
compactionId: "cmp-1",
status: "cancelled",
});
compaction.resume();
await compaction.start("cmp-2");
await compaction.finish("cmp-2", "completed");
expect(sent.at(-1)).toEqual({
sessionUpdate: "compaction_update",
compactionId: "cmp-2",
status: "completed",
_meta: { contextCompaction: { version: 1 } },
});
});
it("discards pending summaries on interruption and accepts terminal-only output in a new turn", async () => {
const { sent, compaction } = lifecycle("compaction_update");
compaction.recordSummary("<summary>Pending abandoned summary.</summary>");
await compaction.interrupt();
expect(compaction.recordSummary("<summary>Late abandoned summary.</summary>")).toBe(false);
await compaction.finish("abandoned-terminal", "completed");
expect(sent).toEqual([]);
compaction.resume();
await compaction.finish("next-terminal", "completed");
expect(sent).toEqual([
{
sessionUpdate: "compaction_update",
compactionId: "next-terminal",
status: "completed",
_meta: { contextCompaction: { version: 1 } },
},
]);
});
it("leaves completed entities terminal when their surrounding work is interrupted", async () => {
const { sent, compaction } = lifecycle("compaction_update");
await compaction.start("cmp-1");
await compaction.finish("cmp-1", "completed");
compaction.recordSummary("<summary>Pending hook.</summary>");
await compaction.interrupt();
expect(sent).toHaveLength(2);
compaction.resume();
await compaction.start("cmp-2");
expect(compaction.recordSummary("<summary>Fresh.</summary>")).toBe(true);
await compaction.finish("cmp-2", "completed");
expect(sent.at(-1)).toMatchObject({
compactionId: "cmp-2",
status: "completed",
summary: [{ type: "text", text: "Fresh." }],
});
});
it.each(["reset", "interrupt"] as const)("never throws from %s", async (method) => {
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[method]()).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" },
},
},
]);
});
});