Files
can1357--oh-my-pi/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts
2026-07-13 12:22:59 +08:00

1299 lines
53 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction";
import { resolveThresholdTokens, shouldCompact } from "@oh-my-pi/pi-agent-core/compaction";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { CompactionEntry } from "@oh-my-pi/pi-coding-agent/session/session-entries";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils";
/**
* Regression test for the auto-compaction thrash loop.
*
* When the most-recent kept turn alone exceeds the compaction threshold,
* `prepareCompaction` keeps it verbatim (findCutPoint never cuts at tool
* results), so a "successful" compaction leaves context still above threshold.
* The snapcompact strategy makes this visible: it projects over budget, falls
* back to a context-full summary ("could not bring the context under the
* limit"), and the success tail used to schedule the auto-continue regardless —
* the next agent_end re-entered #checkCompaction over the same oversized tail and
* re-fired forever.
*
* The fix gates the auto-continue (and the overflow/incomplete retry) on a
* post-maintenance headroom check; with no headroom it pauses and emits a single
* warning notice instead of looping.
*/
describe("AgentSession auto-compaction progress guard", () => {
let tempDir: TempDir;
let session: AgentSession;
let sessionManager: SessionManager;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
const NOTICE_SOURCE = "compaction";
const NO_PROGRESS_FRAGMENT = "Compaction freed too little context to make progress";
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-auto-compaction-progress-");
// Short-circuit the actual summarization so the test makes no LLM call: the
// hook supplies the compaction result, then the production tail (events,
// progress guard, continuation scheduling) runs exactly as in a real pass.
const extensionsDir = path.join(getProjectAgentDir(tempDir.path()), "extensions");
fs.mkdirSync(extensionsDir, { recursive: true });
const extensionPath = path.join(extensionsDir, "compaction-short-circuit.ts");
fs.writeFileSync(
extensionPath,
[
"export default function(pi) {",
'\tpi.on("session_before_compact", async (event) => {',
"\t\treturn {",
"\t\t\tcompaction: {",
'\t\t\t\tsummary: "compacted",',
"\t\t\t\tshortSummary: undefined,",
"\t\t\t\tfirstKeptEntryId: event.preparation.firstKeptEntryId,",
"\t\t\t\ttokensBefore: event.preparation.tokensBefore,",
"\t\t\t\tdetails: {},",
"\t\t\t},",
"\t\t};",
"\t});",
"}",
].join("\n"),
);
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
const extensionsResult = await loadExtensions([extensionPath], tempDir.path());
const extensionRunner = new ExtensionRunner(
extensionsResult.extensions,
extensionsResult.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const bundled = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!bundled) {
throw new Error("Expected built-in anthropic model to exist");
}
// Pin the window and output reservation: every usage figure below is tuned
// to a 200k/64k threshold, so catalog regeneration must not shift the
// headroom math.
const model = { ...bundled, contextWindow: 200_000, maxTokens: 64_000 };
const agent = new Agent({
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
});
// Seed a minimal branch so prepareCompaction() returns a preparation.
sessionManager.appendMessage({
role: "user",
content: "hello",
timestamp: Date.now(),
});
session = new AgentSession({
agent,
sessionManager,
settings: Settings.isolated({
// Auto-continue ON so the guarded auto-continue path is exercised.
"compaction.autoContinue": true,
}),
modelRegistry,
extensionRunner,
});
});
afterEach(async () => {
try {
await session?.dispose();
} finally {
authStorage?.close();
await tempDir?.remove();
vi.restoreAllMocks();
}
});
/** Build a threshold-tripping assistant turn (contextWindow 200k, ~80% threshold). */
function highUsageAssistant() {
return {
role: "assistant" as const,
content: [{ type: "text" as const, text: "Done." }],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
stopReason: "stop" as const,
usage: {
input: 190000,
output: 1000,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 191000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
};
}
/** Build a context-overflow assistant turn (input exceeds the 200k window). */
function overflowAssistant(content = [{ type: "text" as const, text: "" }]) {
return {
role: "assistant" as const,
content,
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
stopReason: "error" as const,
errorMessage: "prompt is too long: 250000 tokens > 200000 maximum",
usage: {
input: 250000,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 250000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
};
}
function contentfulOverflowAssistant() {
return overflowAssistant([{ type: "text" as const, text: "prompt is too long" }]);
}
function collectNotices() {
const notices: { level: string; message: string; source?: string }[] = [];
session.subscribe(event => {
if (event.type === "notice") {
notices.push({ level: event.level, message: event.message, source: event.source });
}
});
return notices;
}
function countCompactionStarts() {
let starts = 0;
session.subscribe(event => {
if (event.type === "auto_compaction_start") starts++;
});
return () => starts;
}
it("pauses (no continuation, single warning) when compaction creates no headroom", async () => {
session.setTodoPhases([{ name: "Work", tasks: [{ content: "Finish task", status: "in_progress" }] }]);
const todoReminders: unknown[] = [];
session.subscribe(event => {
if (event.type === "todo_reminder") todoReminders.push(event);
});
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
// Auto-continue runs through agent.prompt (#promptWithMessage), not
// agent.continue — spy both so "no continuation" is actually proven.
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
// Residual context stays above the recovery band after the rewrite: the most
// recent turn alone is too large to reduce.
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 });
const notices = collectNotices();
const startCount = countCompactionStarts();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = highUsageAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
// Compaction ran exactly once and did not schedule a continuation turn
// (neither the auto-continue prompt nor a queued-message continue).
expect(startCount()).toBe(1);
expect(promptSpy).not.toHaveBeenCalled();
expect(continueSpy).not.toHaveBeenCalled();
expect(todoReminders.length).toBe(0);
expect(session.isStreaming).toBe(false);
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(1);
expect(noProgress[0].level).toBe("warning");
});
it("clamps a reserve exceeding the window for small-window threshold recovery bands", () => {
const settings = {
enabled: true,
strategy: "context-full" as const,
thresholdTokens: -1,
thresholdPercent: -1,
reserveTokens: 16384,
keepRecentTokens: 10000,
autoContinue: true,
};
const threshold = resolveThresholdTokens(4096, settings);
expect(threshold).toBe(3482);
expect(Math.floor(threshold * 0.8)).toBe(2785);
expect(shouldCompact(3600, 4096, settings)).toBe(true);
});
it("blocks todo continuations after no-headroom compaction when auto-continue is disabled", async () => {
session.settings.set("compaction.autoContinue", false);
session.setTodoPhases([{ name: "Work", tasks: [{ content: "Finish task", status: "in_progress" }] }]);
const todoReminders: unknown[] = [];
session.subscribe(event => {
if (event.type === "todo_reminder") todoReminders.push(event);
});
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 });
const notices = collectNotices();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = highUsageAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(promptSpy).not.toHaveBeenCalled();
expect(continueSpy).not.toHaveBeenCalled();
expect(todoReminders.length).toBe(0);
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(1);
});
it("drains queued messages when no-headroom compaction pauses auto-continue", async () => {
session.agent.followUp({
role: "custom",
customType: "test",
content: [{ type: "text", text: "Queued while compacting" }],
display: false,
timestamp: Date.now(),
});
const continueSpy = vi.spyOn(session.agent, "continue").mockImplementation(async () => {
session.agent.clearAllQueues();
});
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 });
const notices = collectNotices();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = highUsageAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(promptSpy).not.toHaveBeenCalled();
expect(continueSpy).toHaveBeenCalledTimes(1);
expect(session.agent.hasQueuedMessages()).toBe(false);
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(1);
});
it("blocks automatic maintenance when threshold compaction has nothing to summarize", async () => {
session.setTodoPhases([{ name: "Work", tasks: [{ content: "Finish task", status: "in_progress" }] }]);
const todoReminders: unknown[] = [];
session.subscribe(event => {
if (event.type === "todo_reminder") todoReminders.push(event);
});
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(undefined);
const notices = collectNotices();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = highUsageAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(promptSpy).not.toHaveBeenCalled();
expect(continueSpy).not.toHaveBeenCalled();
expect(todoReminders.length).toBe(0);
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(1);
});
it("drains queued messages when no-op threshold compaction pauses automatic maintenance", async () => {
session.agent.followUp({
role: "custom",
customType: "test",
content: [{ type: "text", text: "Queued while compacting" }],
display: false,
timestamp: Date.now(),
});
const continueSpy = vi.spyOn(session.agent, "continue").mockImplementation(async () => {
session.agent.clearAllQueues();
});
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(undefined);
const notices = collectNotices();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = highUsageAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(promptSpy).not.toHaveBeenCalled();
expect(continueSpy).toHaveBeenCalledTimes(1);
expect(session.agent.hasQueuedMessages()).toBe(false);
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(1);
});
it("auto-continues (no warning) when compaction creates headroom", async () => {
// The auto-continue path runs #scheduleAutoContinuePrompt → #promptWithMessage
// → agent.prompt. Stub both prompt and continue so no real agent loop runs.
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session.agent, "continue").mockResolvedValue();
// Residual context drops well under the threshold: real reduction happened.
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 1000, contextWindow: 200000, percent: 0.5 });
const notices = collectNotices();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = highUsageAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
// Headroom was created, so the guard scheduled the agent-authored
// continuation prompt and stayed silent.
expect(promptSpy).toHaveBeenCalledTimes(1);
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(0);
});
it("rebases the in-flight prompt snapshot so mid-run compaction is not misread as a dead-end", async () => {
// Regression: the pending context snapshot is set once per prompt and
// lives for the whole run. A fresh compaction entry hides every earlier
// usage anchor from getContextBreakdown, which then fell back to the
// stale run-start figure until the next provider response — a run
// submitted above the recovery band (0.8 × 170k = 136k here) tripped the
// "freed too little context" warning even though compaction had
// genuinely shrunk the context (observed live: 312k → 86k real tokens,
// warning still emitted).
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
// Hold the initial prompt in flight so the pending snapshot stays alive
// through the compaction, exactly like a live tool-loop run. The second
// agent.prompt call is the scheduled auto-continue — the "headroom was
// seen" signal the test awaits.
const gate = Promise.withResolvers<void>();
const firstPromptCall = Promise.withResolvers<void>();
const secondPromptCall = Promise.withResolvers<void>();
let promptCalls = 0;
const promptSpy = vi.spyOn(session.agent, "prompt").mockImplementation(() => {
promptCalls++;
if (promptCalls === 1) firstPromptCall.resolve();
if (promptCalls === 2) secondPromptCall.resolve();
return gate.promise as never;
});
const notices = collectNotices();
// The dead-end warning is the "no headroom was seen" signal: the headroom
// tail runs AFTER auto_compaction_end is emitted, so the test awaits one
// of the tail's two observable outcomes instead of the end event.
const noProgressSeen = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "notice" && event.message.includes(NO_PROGRESS_FRAGMENT)) noProgressSeen.resolve();
});
// ~150k-token prompt: above the recovery band, below the 170k threshold,
// so the pre-prompt maintenance pass stays quiet and the snapshot records
// the run-start size. agent.prompt is mocked, so the text never reaches
// the branch — it exists only in the in-flight snapshot.
const inFlightPrompt = session.prompt("x".repeat(600_000));
// The snapshot is written immediately before agent.prompt; awaiting the
// first (gated) call guarantees it is in place before the threshold turn
// lands — emitting earlier would race the submission pipeline and let
// compaction run against an unset snapshot.
await firstPromptCall.promise;
// Mid-run, the billed context crosses the threshold and compaction fires;
// the rewritten context (summary only) is tiny.
const assistantMsg = highUsageAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
// Wait for the headroom verdict while the prompt is still gated —
// releasing the gate earlier would clear the snapshot and mask the
// regression. Fixed behavior schedules the auto-continue (second prompt
// call); the regression emits the dead-end warning instead.
await Promise.race([secondPromptCall.promise, noProgressSeen.promise]);
gate.resolve();
await inFlightPrompt;
await session.waitForIdle();
// The stale 150k run-start snapshot must not be measured as residual
// context: no dead-end warning, and the auto-continue prompt ran
// (initial call + continuation).
expect(promptSpy).toHaveBeenCalledTimes(2);
expect(continueSpy).not.toHaveBeenCalled();
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(0);
});
/**
* Seed several large prior turns into the session branch so `prepareCompaction`
* returns a real preparation after the overflow recovery drops the failed
* assistant from active context. The drop only touches agent state, and a
* branch under `keepRecentTokens` (20k) has nothing to summarize, so each
* turn carries enough text (~10k tokens) to push older turns past the cut.
*/
function seedPriorTurns() {
const bigText = "lorem ipsum ".repeat(4000); // ~10k tokens of summarizable text
for (let i = 0; i < 4; i++) {
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: bigText }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1000,
output: 50,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 1050,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
});
sessionManager.appendMessage({ role: "user", content: "next", timestamp: Date.now() });
}
}
it("retries an overflow recovery that fits the window but stays inside the recovery band", async () => {
// Regression for the band-vs-fit conflation (#3412 review): the overflow
// retry only needs the rebuilt prompt to fit the window, NOT to drop under
// `COMPACTION_RECOVERY_BAND × threshold`. Residual lands at 150k on a 200k
// window — above the 0.8×170k≈136k recovery band, but comfortably under the
// usable budget — so the retry MUST proceed instead of dead-ending.
seedPriorTurns();
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 150000, contextWindow: 200000, percent: 75 });
const notices = collectNotices();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = overflowAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(continueSpy).toHaveBeenCalledTimes(1);
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(0);
});
it("removes the visible overflow error before retrying after compaction", async () => {
seedPriorTurns();
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 150000, contextWindow: 200000, percent: 75 });
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = overflowAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(continueSpy).toHaveBeenCalledTimes(1);
expect(sessionManager.getBranch()).not.toContainEqual(
expect.objectContaining({
type: "message",
message: expect.objectContaining({
role: "assistant",
stopReason: "error",
errorMessage: assistantMsg.errorMessage,
}),
}),
);
});
it("drops a content-less overflow error when no recovery path is available", async () => {
// Content-less provider rejection turns are live UI only: persisting them
// writes an empty assistant turn that replays on reload and re-sends the
// rejected context.
session.settings.set("contextPromotion.enabled", false);
session.settings.set("compaction.enabled", false);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
const assistantMsg = overflowAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await session.waitForIdle();
expect(promptSpy).not.toHaveBeenCalled();
expect(continueSpy).not.toHaveBeenCalled();
expect(sessionManager.getBranch()).not.toContainEqual(
expect.objectContaining({
type: "message",
message: expect.objectContaining({
role: "assistant",
stopReason: "error",
errorMessage: assistantMsg.errorMessage,
}),
}),
);
});
it("restores the persisted overflow error when compaction skips without committing", async () => {
// `#runAutoCompaction` returning COMPACTION_CHECK_NONE without writing a
// compaction summary (no available model, hook cancel, compaction error)
// MUST NOT erase the user-visible assistant error: the transcript would
// otherwise lose the only explanation of why the turn stopped.
seedPriorTurns();
vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([]);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = contentfulOverflowAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(promptSpy).not.toHaveBeenCalled();
expect(continueSpy).not.toHaveBeenCalled();
expect(sessionManager.getBranch()).toContainEqual(
expect.objectContaining({
type: "message",
message: expect.objectContaining({
role: "assistant",
stopReason: "error",
errorMessage: assistantMsg.errorMessage,
}),
}),
);
});
it("does not restore a content-less overflow error when compaction skips without committing", async () => {
seedPriorTurns();
vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([]);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = overflowAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(promptSpy).not.toHaveBeenCalled();
expect(continueSpy).not.toHaveBeenCalled();
expect(sessionManager.getBranch()).not.toContainEqual(
expect.objectContaining({
type: "message",
message: expect.objectContaining({
role: "assistant",
stopReason: "error",
errorMessage: assistantMsg.errorMessage,
}),
}),
);
});
it("restores the persisted overflow error when no-op recovery blocks automatic continuation", async () => {
// A no-preparation overflow recovery returns the blocked-continuation result
// to prevent a no-op compaction loop, but it has not written a compaction
// summary. The failed assistant must be restored so the transcript keeps the
// error that explains why the turn stopped.
vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(undefined);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = contentfulOverflowAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(promptSpy).not.toHaveBeenCalled();
expect(continueSpy).not.toHaveBeenCalled();
expect(sessionManager.getBranch()).toContainEqual(
expect.objectContaining({
type: "message",
message: expect.objectContaining({
role: "assistant",
stopReason: "error",
errorMessage: assistantMsg.errorMessage,
}),
}),
);
});
it("restores the persisted overflow error before draining queued no-op recovery", async () => {
// A queued user turn still deserves a follow-up, but no-op recovery has not
// written a compaction summary. Restore the failed assistant before the queue
// drains so the transcript keeps the reason recovery stopped.
session.agent.followUp({
role: "custom",
customType: "test",
content: [{ type: "text", text: "Queued while recovering" }],
display: false,
timestamp: Date.now(),
});
vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(undefined);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
const continueSpy = vi.spyOn(session.agent, "continue").mockImplementation(async () => {
expect(sessionManager.getBranch()).toContainEqual(
expect.objectContaining({
type: "message",
message: expect.objectContaining({
role: "assistant",
stopReason: "error",
errorMessage: assistantMsg.errorMessage,
}),
}),
);
expect(session.agent.state.messages.at(-1)).toMatchObject({
role: "assistant",
stopReason: "error",
errorMessage: assistantMsg.errorMessage,
});
session.agent.clearAllQueues();
});
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = contentfulOverflowAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(promptSpy).not.toHaveBeenCalled();
expect(continueSpy).toHaveBeenCalledTimes(1);
expect(session.agent.hasQueuedMessages()).toBe(false);
expect(sessionManager.getBranch()).toContainEqual(
expect.objectContaining({
type: "message",
message: expect.objectContaining({
role: "assistant",
stopReason: "error",
errorMessage: assistantMsg.errorMessage,
}),
}),
);
});
it("does not restore a length stop after handoff recovery commits", async () => {
session.settings.set("compaction.strategy", "handoff");
session.settings.set("contextPromotion.enabled", false);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
const handoffSpy = vi.spyOn(session, "handoff").mockResolvedValue({ document: "handoff document" });
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = {
role: "assistant" as const,
content: [{ type: "text" as const, text: "unfinished" }],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
stopReason: "length" as const,
usage: {
input: 10_000,
output: 1_000,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 11_000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
};
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(promptSpy).toHaveBeenCalledTimes(1);
expect(handoffSpy).toHaveBeenCalledTimes(1);
expect(continueSpy).not.toHaveBeenCalled();
expect(sessionManager.getBranch()).not.toContainEqual(
expect.objectContaining({
type: "message",
message: expect.objectContaining({
role: "assistant",
stopReason: "length",
}),
}),
);
});
it("retries a small-window overflow when the reserve exceeds the model window", async () => {
// Bundled 4k/8k models can be smaller than the absolute reserve (16,384,
// explicit or defaulted). Retry fit must clamp that reserve; otherwise the
// budget goes negative and a prompt that fits the actual model window dead-ends.
session.settings.set("compaction.keepRecentTokens", 100);
const smallText = "lorem ipsum ".repeat(100);
for (let i = 0; i < 4; i++) {
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: smallText }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 100,
output: 10,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 110,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
});
sessionManager.appendMessage({ role: "user", content: "next", timestamp: Date.now() });
}
session.agent.replaceMessages(session.buildDisplaySessionContext().messages);
const currentModel = session.agent.state.model;
session.agent.setModel({ ...currentModel, contextWindow: 4096, maxTokens: 1024 });
session.settings.set("contextPromotion.enabled", false);
session.settings.set("compaction.reserveTokens", 16384);
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 1000, contextWindow: 4096, percent: 24.4 });
const notices = collectNotices();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = overflowAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(continueSpy).toHaveBeenCalledTimes(1);
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(0);
});
it("retries a near-16k-window overflow when the default reserve leaves no usable budget", async () => {
// GPT-3.5 variants ship with a 16,385-token context window; the default
// absolute reserve is 16,384. Retry fit must treat that reserve as
// effectively impossible for the window, otherwise any realistic compacted
// prompt dead-ends behind a one-token budget.
session.settings.set("compaction.keepRecentTokens", 100);
const smallText = "lorem ipsum ".repeat(100);
for (let i = 0; i < 4; i++) {
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: smallText }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 100,
output: 10,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 110,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
});
sessionManager.appendMessage({ role: "user", content: "next", timestamp: Date.now() });
}
session.agent.replaceMessages(session.buildDisplaySessionContext().messages);
const currentModel = session.agent.state.model;
session.agent.setModel({ ...currentModel, contextWindow: 16385, maxTokens: 1024 });
session.settings.set("contextPromotion.enabled", false);
// compaction.reserveTokens stays unset: the DEFAULTED reserve is the
// scenario — an explicit 16384 would be honored and leave a 1-token
// budget on purpose (see "pauses an overflow retry when it only fits
// after ignoring a configured reserve" below for the explicit contract).
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 10000, contextWindow: 16385, percent: 61 });
const notices = collectNotices();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = overflowAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(continueSpy).toHaveBeenCalledTimes(1);
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(0);
});
it("pauses an overflow retry when it only fits after ignoring a configured reserve", async () => {
// Retry fit may clamp an impossible reserve that exceeds the model window,
// but must respect a valid user reserve above the 15% default. Otherwise a
// prompt in the reserved headroom band can retry straight into overflow.
seedPriorTurns();
const currentModel = session.agent.state.model;
session.agent.setModel({ ...currentModel, contextWindow: 20000, maxTokens: 1024 });
session.settings.set("contextPromotion.enabled", false);
session.settings.set("compaction.reserveTokens", 5000);
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 16000, contextWindow: 20000, percent: 80 });
const notices = collectNotices();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = overflowAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(continueSpy).not.toHaveBeenCalled();
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(1);
});
it("pauses an overflow retry when a large valid configured reserve leaves a small usable budget", async () => {
// A 90k reserve on a 100k model is valid: the retry prompt must fit inside
// the remaining ~10k usable budget. The proportional fallback is only for
// default/impossible reserves, not explicit large reserves.
seedPriorTurns();
const currentModel = session.agent.state.model;
session.agent.setModel({ ...currentModel, contextWindow: 100000, maxTokens: 1024 });
session.settings.set("contextPromotion.enabled", false);
session.settings.set("compaction.reserveTokens", 90000);
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 15000, contextWindow: 100000, percent: 15 });
const notices = collectNotices();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = overflowAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(continueSpy).not.toHaveBeenCalled();
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(1);
});
/**
* Seed a single large `useless` tool result (plus tiny follow-up turns that
* keep its suffix inside the cache-warm window) so the per-turn maintenance
* passes free ~40k tokens before compaction runs — the same shape as the
* #3174 pruning regression. This drives `postMaintenanceContextTokens` (the
* trigger handed to the headroom guard) well below the recovery band.
*/
function seedPrunableMaintenance(now: number) {
sessionManager.appendMessage({ role: "user", content: "Investigate everything.", timestamp: now - 200 });
const bigCallId = "call-big-useless";
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "toolCall", id: bigCallId, name: "grep", arguments: { pattern: "TODO" } }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "toolUse",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: now - 180,
});
sessionManager.appendMessage({
role: "toolResult",
toolCallId: bigCallId,
toolName: "grep",
content: [{ type: "text", text: "match line\n".repeat(20000) }], // ~40k+ tokens
isError: false,
useless: true,
timestamp: now - 170,
});
for (let i = 0; i < 4; i++) {
const smallId = `call-small-${i}`;
const ts = now - 160 + i * 2;
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "toolCall", id: smallId, name: "read", arguments: { path: `note-${i}.md` } }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "toolUse",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: ts,
});
sessionManager.appendMessage({
role: "toolResult",
toolCallId: smallId,
toolName: "read",
content: [{ type: "text", text: `tiny note ${i}` }],
isError: false,
timestamp: ts + 1,
});
}
session.agent.replaceMessages(session.buildDisplaySessionContext().messages);
}
it("auto-continues when residual sits at the recovery band but the trigger was already sub-band", async () => {
// Regression for the #3412 review: when stale/tool-output pruning already
// dropped context under the recovery band BEFORE this pass, the trigger
// (postMaintenanceContextTokens) is itself sub-band. The old guard returned
// `residual < trigger`, so a residual that merely held the line at/under the
// band — not strictly smaller than the already-safe trigger — was reported
// as no-progress and the auto-continue was suppressed with a false warning,
// even though the next turn could no longer re-trip threshold compaction.
const now = Date.now();
// Pin the threshold so the recovery band is exact: floor(76384 * 0.8) = 61107.
session.settings.set("compaction.thresholdTokens", 76384);
session.settings.set("compaction.thresholdPercent", -1);
session.settings.set("compaction.strategy", "context-full");
session.settings.set("compaction.dropUseless", true);
session.settings.set("compaction.supersedeReads", true);
session.settings.set("compaction.keepRecentTokens", 10000);
session.settings.set("compaction.reserveTokens", 16384);
seedPrunableMaintenance(now);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session.agent, "continue").mockResolvedValue();
// Residual lands AT the band (61000 <= 61107). Maintenance pruning already
// drove the trigger below this, so the old strict-less guard would have
// suppressed; the band check proves headroom and continues.
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 61000, contextWindow: 200000, percent: 30.5 });
const notices = collectNotices();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
// Final turn billed above the 76384 threshold so threshold compaction fires.
const finalAssistant = {
role: "assistant" as const,
content: [{ type: "text" as const, text: "continuing." }],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
stopReason: "stop" as const,
usage: {
input: 5000,
output: 1000,
cacheRead: 85000,
cacheWrite: 0,
totalTokens: 91000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: now,
};
session.agent.emitExternalEvent({ type: "message_end", message: finalAssistant });
session.agent.emitExternalEvent({ type: "agent_end", messages: [finalAssistant] });
await compactionDone;
await session.waitForIdle();
expect(promptSpy).toHaveBeenCalledTimes(1);
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(0);
});
it("pauses (single warning) when an overflow recovery still does not fit the window", async () => {
// The genuine dead-end the retry guard must still catch: even after dropping
// the failed turn the rebuilt prompt is over the window, so retrying would
// hit the same overflow. Pause once instead of looping.
seedPriorTurns();
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 205000, contextWindow: 200000, percent: 102.5 });
const notices = collectNotices();
const startCount = countCompactionStarts();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = overflowAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(startCount()).toBe(1);
expect(continueSpy).not.toHaveBeenCalled();
expect(session.isStreaming).toBe(false);
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(1);
expect(noProgress[0].level).toBe("warning");
});
it("auto-continues (no warning) when a shake rescue frees the oversized tail", async () => {
// The escalation contract: compaction cut at the only turn boundary but the
// kept tail (e.g. a huge tool result) still sits over the recovery band. The
// guard now runs an elide shake INSIDE that tail; once it frees enough, the
// auto-continue proceeds instead of pausing with the no-progress warning.
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session.agent, "continue").mockResolvedValue();
// Residual is over the band until the rescue elides the tail, then drops.
let shaken = false;
vi.spyOn(session, "getContextUsage").mockImplementation(() =>
shaken
? { tokens: 1000, contextWindow: 200000, percent: 0.5 }
: { tokens: 190000, contextWindow: 200000, percent: 95 },
);
const shakeSpy = vi.spyOn(session, "shake").mockImplementation(async () => {
shaken = true;
return { mode: "elide", toolResultsDropped: 1, blocksDropped: 0, tokensFreed: 160000, artifactId: "art-1" };
});
const notices = collectNotices();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = highUsageAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything());
expect(promptSpy).toHaveBeenCalledTimes(1);
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(0);
const recovery = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes("dead-end recovery"));
expect(recovery.length).toBe(1);
expect(recovery[0].level).toBe("info");
});
it("still warns when a shake rescue cannot free the irreducible tail", async () => {
// When the oversized tail has nothing elide-eligible (image-only or plain
// prose), the rescue frees nothing, the residual stays over the band, and
// the guard MUST still pause with the single no-progress warning.
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session.agent, "continue").mockResolvedValue();
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 });
// Nothing eligible: shake reports zero dropped, so residual is unchanged.
const shakeSpy = vi
.spyOn(session, "shake")
.mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 });
const notices = collectNotices();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = highUsageAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything());
expect(promptSpy).not.toHaveBeenCalled();
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(1);
expect(noProgress[0].level).toBe("warning");
const recovery = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes("dead-end recovery"));
expect(recovery.length).toBe(0);
// The dead-end is also stamped on the compaction entry so the transcript
// divider badges the pause and carries the warning across rebuilds/resume.
const compactionEntry = sessionManager
.getEntries()
.filter((e): e is CompactionEntry => e.type === "compaction")
.at(-1);
expect(compactionEntry?.warning).toContain(NO_PROGRESS_FRAGMENT);
});
it("auto-continues (no warning) when the image-drop tier frees an image-only tail", async () => {
// Elide cannot touch image content (collectShakeRegions skips image-only
// tool results and user-message images), so the rescue's second tier drops
// attached images — the automated `/shake images` remedy — and re-tests
// the recovery band before the guard is allowed to pause.
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session.agent, "continue").mockResolvedValue();
let imagesDropped = false;
vi.spyOn(session, "getContextUsage").mockImplementation(() =>
imagesDropped
? { tokens: 1000, contextWindow: 200000, percent: 0.5 }
: { tokens: 190000, contextWindow: 200000, percent: 95 },
);
// Nothing elide-eligible in the oversized tail.
vi.spyOn(session, "shake").mockResolvedValue({
mode: "elide",
toolResultsDropped: 0,
blocksDropped: 0,
tokensFreed: 0,
});
const dropSpy = vi.spyOn(session, "dropImages").mockImplementation(async () => {
imagesDropped = true;
return { removed: 2 };
});
const notices = collectNotices();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = highUsageAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(dropSpy).toHaveBeenCalledTimes(1);
expect(promptSpy).toHaveBeenCalledTimes(1);
const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT));
expect(noProgress.length).toBe(0);
const recovery = notices.filter(
n => n.source === NOTICE_SOURCE && n.message.includes("dropped 2 attached images"),
);
expect(recovery.length).toBe(1);
expect(recovery[0].level).toBe("info");
// A rescued pass must not stamp the dead-end warning on the entry.
const compactionEntry = sessionManager
.getEntries()
.filter((e): e is CompactionEntry => e.type === "compaction")
.at(-1);
expect(compactionEntry?.warning).toBeUndefined();
});
});