diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ad5f374bd..8ae5fa3e4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -24,6 +24,7 @@ ### Fixed - Fixed Auto QA grievance recording silently dropping every report since the xd:// device consolidation: `openAutoQaDb` treated the database file path (`~/.omp/autoqa.db`) as a directory and tried to open `autoqa.db/autoqa.db` inside it, which fails on legacy installs (the flat file blocks the directory) and fresh ones alike (SQLite does not create parent directories). Also restored the `busy_timeout` pragma dropped in the same refactor (#2421). Renamed `getAutoQaDbDir` to `getAutoQaDbPath` to match what it returns. +- Fixed auto-compaction re-triggering the "Compaction freed too little context" warning on every resume when the branch's last entry was an over-threshold snapcompact archive: the dead-end rescue now rebuilds the trailing archive locally at a threshold-derived frame budget (superseding the stale frame payload) instead of pausing, since the elide/image tiers can never touch a compaction entry (#4786). - Fixed the setup wizard hiding the selected row on short terminals (e.g. 24x80): the provider sign-in, theme, and web-search lists now fit their windows to the visible height, and decorative chrome (sign-in hint, theme mock preview) yields to the list when space is tight. - Fixed restored sessions replaying terminal aborted or errored assistant turns, which could repeatedly fail continuation from an assistant role; `/retry` now consults the persisted transcript so the failed turn remains retryable without re-entering provider context. - Fixed `get_available_models` and `set_model` RPCs racing background model discovery on cold start by awaiting the in-flight refresh before reading the registry. RPC/ACP clients that query the catalog or select a model immediately after session ready previously saw only statically-bundled models until discovery completed seconds later. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 1a9dc9b14..8d9dc40a1 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -13806,6 +13806,17 @@ export class AgentSession { options: { skipElide: boolean; hasProgress: () => boolean }, ): Promise { if (signal.aborted) return false; + // Tier 0 — a snapcompact pass whose just-written frame archive is itself + // the over-budget cost (each pass re-renders the carried-forward text + // into MORE frames, so the archive grows past the recovery band and the + // elide/image tiers below can never shrink it): rebuild the archive at + // a threshold-derived frame budget. + const frameRescue = await this.#rescueSnapcompactFrameOverflow( + this.sessionManager.getBranch(), + this.settings.getGroup("compaction"), + signal, + ); + if (frameRescue !== undefined && options.hasProgress()) return true; let elided = 0; let elidedTokens = 0; let elideSink = "placeholders"; @@ -13862,6 +13873,170 @@ export class AgentSession { return `elided ${elided} heavy block${elided === 1 ? "" : "s"} (~${tokensFreed.toLocaleString()} tokens) to ${sink}`; } + /** + * Frame budget for {@link #rescueSnapcompactFrameOverflow}: targets + * `COMPACTION_RECOVERY_BAND × threshold` (the same band + * {@link #compactionCreatedHeadroom} re-tests), not the window-fit budget + * {@link #computeSnapcompactMaxFrames} sizes against — a rebuilt archive + * must land back under the maintenance trigger, or the next settle + * re-enters the same dead-end. Cap reserve mirrors + * #computeSnapcompactMaxFrames (text edges + summary template), and + * `keptTailTokens` charges the kept entries AFTER the archive so the + * budget mirrors what #compactionCreatedHeadroom will actually measure. + * Returns 0 when not even one frame fits that budget — the rebuild could + * never create headroom, so the caller must not append it. + */ + #computeSnapcompactRescueMaxFrames(settings: CompactionSettings, keptTailTokens: number): number { + const ctxWindow = this.model?.contextWindow ?? 0; + if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget()); + const thresholdTokens = resolveThresholdTokens(ctxWindow, settings); + const recoveryBandTokens = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND); + const baseTokens = computeNonMessageTokens(this); + const shape = snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")); + const edgeCap = snapcompact.geometry(shape).capacity; + const textEdgeTokens = Math.ceil((2 * edgeCap * 1.15) / 4); + const SUMMARY_TEMPLATE_TOKENS = 2000; + const frameBudget = recoveryBandTokens - baseTokens - keptTailTokens - textEdgeTokens - SUMMARY_TEMPLATE_TOKENS; + if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 0; + // Same hard caps as #computeSnapcompactMaxFrames: a threshold-derived + // count above the per-request payload budget would "shrink" a huge + // archive to a frame count the rebuilt prompt can never attach anyway. + return Math.min( + Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE), + snapcompact.MAX_FRAMES_DEFAULT, + snapcompact.maxFramesForDataBudget(), + ); + } + + /** + * Dead-end rescue for a branch whose latest snapcompact CompactionEntry is + * itself billed past the maintenance threshold + * (`FRAME_TOKEN_ESTIMATE × frames`). Reaching the `!preparation` dead-end + * proves everything after that entry is already kept-recent (nothing to + * summarize), so the archive is the irreducible cost — and the elide/image + * tiers can never touch it: `collectShakeRegions` and `dropImages()` only + * inspect "message"/"custom_message" entries, so a `type: "compaction"` + * entry falls through both and the session re-warns on every resume (the + * shape issue #4786's rescue does not cover). + * + * Rebuilds the SAME archive locally — no LLM, no network — by re-running + * `snapcompact.compact()` over the entry's carried-forward source text at + * a maxFrames derived from the trigger threshold instead of the window: + * `planArchive` truncates the oldest chars to fit, so the rebuilt entry + * genuinely shrinks. The rebuilt entry keeps the stale entry's + * `firstKeptEntryId`, so the kept tail is untouched, and persisting + * through `appendCompaction()` lets the write-time superseded-compaction + * elision drop the stale frame payload from the JSONL automatically. + */ + async #rescueSnapcompactFrameOverflow( + branchEntries: SessionEntry[], + settings: CompactionSettings, + signal: AbortSignal, + ): Promise { + if (signal.aborted) return undefined; + // Re-rendering frames needs a vision-capable model, same gate as the + // snapcompact strategy path. + if (!this.model?.input.includes("image")) return undefined; + const staleEntry = getLatestCompactionEntry(branchEntries); + if (!staleEntry) return undefined; + // Only rescue when the archive is the actual source of the overflow. + // The frame budget below charges the kept tail AFTER the archive plus + // the fixed context, mirroring what #compactionCreatedHeadroom will + // measure. When not even one frame fits (e.g. a huge kept tool result + // dominates), rebuilding would append the replacement compaction at + // the leaf — turning the branch tail into a compaction entry, which + // prepareCompaction's last-entry guard can never summarize past even + // after an elide shrinks the real culprit. Bail and let the + // elide/image tiers handle that tail instead. + let keptTailTokens = 0; + for (let i = branchEntries.length - 1; i >= 0; i--) { + const entry = branchEntries[i]; + if (entry.id === staleEntry.id) break; + const message = (entry as { message?: AgentMessage }).message; + if (message) keptTailTokens += estimateTokens(message); + } + const archive = snapcompact.getPreservedArchive(staleEntry.preserveData); + if (!archive || archive.frames.length <= 1) return undefined; + const archiveText = snapcompact.archiveSourceText(archive); + if (!archiveText) return undefined; + const maxFrames = this.#computeSnapcompactRescueMaxFrames(settings, keptTailTokens); + if (maxFrames < 1 || maxFrames >= archive.frames.length) return undefined; + + const staleDetails = staleEntry.details as snapcompact.CompactionDetails | undefined; + const fileOps = snapcompact.createFileOps(); + for (const file of staleDetails?.readFiles ?? []) fileOps.read.add(file); + for (const file of staleDetails?.modifiedFiles ?? []) fileOps.edited.add(file); + const shapeSetting = this.settings.get("snapcompact.shape"); + const shape = snapcompact.resolveShapeForText(archiveText, this.model, shapeSetting); + let result: snapcompact.CompactionResult; + try { + result = await snapcompact.compact( + { + firstKeptEntryId: staleEntry.firstKeptEntryId, + messagesToSummarize: [], + turnPrefixMessages: [], + tokensBefore: staleEntry.tokensBefore, + previousSummary: staleEntry.summary, + previousPreserveData: staleEntry.preserveData, + fileOps, + }, + { + convertToLlm, + model: this.model, + ...(shapeSetting === "auto" ? {} : { shape }), + maxFrames, + }, + ); + } catch (error) { + logger.warn("Dead-end snapcompact frame rescue failed", { + error: error instanceof Error ? error.message : String(error), + }); + return undefined; + } + if (signal.aborted) return undefined; + const rebuilt = snapcompact.getPreservedArchive(result.preserveData); + if (!rebuilt || rebuilt.frames.length >= archive.frames.length) return undefined; + + const rebuiltEntryId = this.sessionManager.appendCompaction( + result.summary, + result.shortSummary, + result.firstKeptEntryId, + result.tokensBefore, + result.details, + false, + result.preserveData, + ); + const sessionContext = this.buildDisplaySessionContext(); + this.agent.replaceMessages(sessionContext.messages); + this.#rebasePendingContextSnapshotAfterCompaction(); + // Same post-rewrite bookkeeping as the regular compaction append: the + // rebuilt context no longer carries the transient plan reference (#1246), + // and advisor cursors / todo phases were derived from the replaced + // history. + this.#planReferenceSent = false; + this.#resetAllAdvisorRuntimes(); + this.#syncTodoPhasesFromBranch(); + this.#closeCodexProviderSessionsForHistoryRewrite(); + // Extensions must see the entry that is now active, not (only) the one + // this rebuild just superseded — mirror the regular append path's hook. + const rebuiltEntry = this.sessionManager.getEntries().find(e => e.id === rebuiltEntryId) as + | CompactionEntry + | undefined; + if (this.#extensionRunner && rebuiltEntry) { + await this.#extensionRunner.emit({ + type: "session_compact", + compactionEntry: rebuiltEntry, + fromExtension: false, + }); + } + this.emitNotice( + "info", + `Compaction dead-end recovery: rebuilt the trailing snapcompact archive at a smaller frame budget (${archive.frames.length} → ${rebuilt.frames.length} frames) so maintenance could make progress.`, + "compaction", + ); + return result; + } + /** * Internal: Run auto-compaction with events. * @@ -14075,35 +14250,70 @@ export class AgentSession { // strategy pass (it tried and found nothing); skip entirely on the // idle timer (it re-checks usage on its own cadence). let rescueRewroteHistory = false; + // A snapcompact CompactionEntry is invisible to both rescue tiers + // below (they only inspect message entries) and to prepareCompaction + // itself (last-entry-is-compaction guard), so a frame archive billed + // past the threshold dead-ends here on every resume. Rebuild it at a + // threshold-derived frame budget first — but treat that as complete + // only when it actually created headroom: the latest archive may not + // be the oversized tail (e.g. a huge kept tool result after it), and + // declaring victory on a mere frame-count shrink would skip the + // elide/image tiers that can still reach that tail and suppress a + // warning the user should see. + let frameRescueResult: snapcompact.CompactionResult | undefined; + let frameRescueCreatedHeadroom = false; if (reason !== "idle") { - await this.#rescueCompactionDeadEnd(autoCompactionSignal, { - skipElide: fallbackFromShake, - hasProgress: () => { - // Only reached when a tier actually freed something, so the - // branch has been rewritten either way. - rescueRewroteHistory = true; - pathEntriesForCompaction = this.sessionManager.getBranch(); - preparation = prepareCompaction( - pathEntriesForCompaction, - compactionSettings, - autoCompactionCandidates, - ); - return preparation !== undefined; - }, - }); + frameRescueResult = await this.#rescueSnapcompactFrameOverflow( + pathEntriesForCompaction, + compactionSettings, + autoCompactionSignal, + ); + if (frameRescueResult) { + rescueRewroteHistory = true; + pathEntriesForCompaction = this.sessionManager.getBranch(); + frameRescueCreatedHeadroom = this.#compactionCreatedHeadroom(); + } + if (!frameRescueCreatedHeadroom) { + await this.#rescueCompactionDeadEnd(autoCompactionSignal, { + skipElide: fallbackFromShake, + hasProgress: () => { + // Only reached when a tier actually freed something, so the + // branch has been rewritten either way. + rescueRewroteHistory = true; + pathEntriesForCompaction = this.sessionManager.getBranch(); + preparation = prepareCompaction( + pathEntriesForCompaction, + compactionSettings, + autoCompactionCandidates, + ); + return preparation !== undefined; + }, + }); + } } if (!preparation) { + // A successful frame rescue rewrote history and activated a new + // compaction entry — surface it as a real (non-skipped) result so + // the TUI rebuilds the transcript instead of treating the pass as + // a benign no-op. await this.#emitSessionEvent({ type: "auto_compaction_end", action, - result: undefined, + result: frameRescueResult, aborted: false, willRetry: false, - skipped: true, + skipped: frameRescueResult === undefined, }); - const noProgressDeadEnd = reason !== "idle"; + const noProgressDeadEnd = reason !== "idle" && !frameRescueCreatedHeadroom; let continuationScheduled = false; - if (!suppressContinuation && this.agent.hasQueuedMessages()) { + if (frameRescueCreatedHeadroom) { + continuationScheduled = this.#scheduleCompactionContinuation({ + generation, + autoContinue: shouldAutoContinue, + terminalTextAnswer, + suppressContinuation, + }); + } else if (!suppressContinuation && this.agent.hasQueuedMessages()) { this.#scheduleAgentContinue({ delayMs: 100, generation, @@ -14112,11 +14322,19 @@ export class AgentSession { continuationScheduled = true; } if (noProgressDeadEnd) { - this.emitNotice( - "warning", - compactionDeadEndWarning("shrink it (e.g. clear large tool output)"), - "compaction", - ); + const deadEndWarning = compactionDeadEndWarning("shrink it (e.g. clear large tool output)"); + this.emitNotice("warning", deadEndWarning, "compaction"); + // A rescue that appended a rebuilt archive without creating + // headroom must carry the dead-end badge on the entry the + // transcript actually shows (the rebuilt one), or the pause + // loses its explanation once the notice scrolls away. + if (frameRescueResult) { + const stampEntry = getLatestCompactionEntry(this.sessionManager.getBranch()); + if (stampEntry) { + stampEntry.warning = deadEndWarning; + await this.sessionManager.rewriteEntries(); + } + } } // A rescue that offloaded content but still could not produce a // preparation rewrote the branch; flag it so the overflow-recovery @@ -14537,12 +14755,18 @@ export class AgentSession { } const deadEndWarning = noProgressDeadEnd ? compactionDeadEndWarning("clear large tool output") : undefined; - if (deadEndWarning && savedCompactionEntry) { + if (deadEndWarning) { // Stamp the divider: the compaction bar badges the dead-end and // carries the full warning in its ctrl+o detail, so the pause - // stays explained even after the notice row scrolls away. - savedCompactionEntry.warning = deadEndWarning; - await this.sessionManager.rewriteEntries(); + // stays explained even after the notice row scrolls away. Stamp + // the branch's LATEST compaction entry — a frame rescue may have + // superseded `savedCompactionEntry` with a rebuilt one, and the + // collapsed transcript badges only the active entry. + const stampEntry = getLatestCompactionEntry(this.sessionManager.getBranch()) ?? savedCompactionEntry; + if (stampEntry) { + stampEntry.warning = deadEndWarning; + await this.sessionManager.rewriteEntries(); + } } await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry }); diff --git a/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts b/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts new file mode 100644 index 000000000..e9af615c4 --- /dev/null +++ b/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts @@ -0,0 +1,501 @@ +import { afterEach, 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 { 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"; +import * as snapcompact from "@oh-my-pi/snapcompact"; + +/** + * Regression test for the snapcompact frame dead-end. + * + * A branch whose LAST entry is a snapcompact CompactionEntry billed past the + * maintenance threshold (FRAME_TOKEN_ESTIMATE × frames) dead-ends every pass: + * prepareCompaction returns undefined (nothing after the entry to summarize), + * and the elide/image rescue tiers only inspect "message"/"custom_message" + * entries, so the `type: "compaction"` tail escapes both and the no-progress + * warning re-fires on every resume — the shape issue #4786's rescue does not + * cover. + * + * The fix rebuilds the trailing archive locally via snapcompact.compact() at + * a threshold-derived frame budget (planArchive truncates the oldest chars), + * persists it through appendCompaction (write-time elision drops the stale + * frame payload), and skips the misleading no-progress warning. + */ +describe("AgentSession snapcompact frame dead-end rescue", () => { + 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"; + const SEEDED_FRAME_COUNT = 16; + + function makeFrames(count: number): Record[] { + return Array.from({ length: count }, (_, i) => ({ + data: btoa(`stale-frame-${i}`), + mimeType: "image/png", + cols: 4, + rows: 2, + chars: 8, + })); + } + + function makeArchivePreserveData(frameCount: number): Record { + return { + snapcompact: { + frames: makeFrames(frameCount), + text: `HEAD sentinel. ${"Archived history line. ".repeat(200)}TAIL sentinel.`, + totalChars: 4600, + truncatedChars: 0, + }, + }; + } + + async function createSession(options: { + frameCount: number; + visionModel?: boolean; + /** Seed no compaction entry; instead a hook supplies one carrying this + * many frames — exercising the POST-PASS dead-end (a completed pass + * whose just-written archive is itself the over-budget cost). */ + hookArchiveFrames?: number; + }): Promise { + tempDir = TempDir.createSync("@pi-snapcompact-frame-dead-end-"); + 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()); + + let extensionRunner: ExtensionRunner | undefined; + if (options.hookArchiveFrames !== undefined) { + // Short-circuit the summarization LLM call with a hook-supplied + // compaction whose archive carries the oversized frame payload — + // mirrors agent-session-auto-compaction-progress-guard.test.ts. + 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\tpreserveData: ${JSON.stringify(makeArchivePreserveData(options.hookArchiveFrames))},`, + "\t\t\t},", + "\t\t};", + "\t});", + "}", + ].join("\n"), + ); + const extensionsResult = await loadExtensions([extensionPath], tempDir.path()); + 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: threshold/band math below is tuned to 200k. + const model = { + ...bundled, + contextWindow: 200_000, + maxTokens: 64_000, + ...(options.visionModel === false ? { input: ["text" as const] } : {}), + }; + + // Seed the poisoned shape: one user turn, then (unless the hook supplies + // the archive) a trailing snapcompact CompactionEntry as the LAST branch + // entry — the real prepareCompaction must hit its + // last-entry-is-compaction guard organically. + const userEntryId = sessionManager.appendMessage({ + role: "user", + content: "hello", + timestamp: Date.now(), + }); + if (options.hookArchiveFrames === undefined) { + sessionManager.appendCompaction( + "Archived history onto stale snapcompact frames.", + "stale snapcompact archive", + userEntryId, + 150_000, + { readFiles: ["src/a.ts"], modifiedFiles: ["src/b.ts"] }, + false, + makeArchivePreserveData(options.frameCount), + ); + } + + const agent = new Agent({ + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + }); + + session = new AgentSession({ + agent, + sessionManager, + settings: Settings.isolated({ + "compaction.autoContinue": true, + "compaction.strategy": "snapcompact", + // Fixed trigger so the rescue's threshold-derived frame budget is + // deterministic: band 0.8 × 60k = 48k minus base/edge reserves + // yields well under 16 frames — the rebuild must shrink. + "compaction.thresholdTokens": 60_000, + }), + modelRegistry, + extensionRunner, + }); + } + + afterEach(async () => { + try { + await session?.dispose(); + } finally { + authStorage?.close(); + await tempDir?.remove(); + vi.restoreAllMocks(); + } + }); + + 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; + } + + /** Threshold-tripping assistant turn against the 60k trigger. */ + 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(), + }; + } + + async function triggerMaintenance(): Promise { + const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers(); + 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(); + } + + it("rebuilds a stale trailing snapcompact archive and skips the no-progress warning", async () => { + await createSession({ frameCount: SEEDED_FRAME_COUNT }); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + // Over the band until the rescue rebuilds the archive, then well under — + // the rescue only counts as complete when it creates real headroom. + let rebuiltArchiveApplied = false; + vi.spyOn(session, "getContextUsage").mockImplementation(() => + rebuiltArchiveApplied + ? { tokens: 30000, contextWindow: 200000, percent: 15 } + : { tokens: 190000, contextWindow: 200000, percent: 95 }, + ); + const shakeSpy = vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); + const compactSpy = vi.spyOn(snapcompact, "compact").mockImplementation(async () => { + rebuiltArchiveApplied = true; + return { + summary: "Rebuilt archive at a smaller frame budget.", + shortSummary: "rebuilt snapcompact archive", + firstKeptEntryId: (sessionManager.getBranch()[0] as { id: string }).id, + tokensBefore: 150_000, + details: { readFiles: ["src/a.ts"], modifiedFiles: ["src/b.ts"] }, + preserveData: makeArchivePreserveData(4), + }; + }); + + const notices = collectNotices(); + const compactionEnds: { result?: unknown; skipped?: boolean }[] = []; + session.subscribe(event => { + if (event.type === "auto_compaction_end") { + compactionEnds.push({ result: event.result, skipped: event.skipped }); + } + }); + await triggerMaintenance(); + + expect(compactSpy).toHaveBeenCalledTimes(1); + // The rescue rewrote history: the pass must surface a real result (TUI + // rebuilds on it), not a skipped no-op. + expect(compactionEnds.length).toBe(1); + expect(compactionEnds[0].result).toBeTruthy(); + expect(compactionEnds[0].skipped).toBeFalsy(); + const [, compactOptions] = compactSpy.mock.calls[0] as [unknown, { maxFrames?: number }]; + expect(compactOptions.maxFrames).toBeDefined(); + expect(compactOptions.maxFrames as number).toBeLessThan(SEEDED_FRAME_COUNT); + + // The rebuilt entry supersedes the stale one; write-time elision must + // have dropped the stale frame payload from the persisted branch. + const compactions = sessionManager.getBranch().filter(e => e.type === "compaction") as CompactionEntry[]; + expect(compactions.length).toBe(2); + const [stale, rebuilt] = compactions; + expect(stale.summary).toContain("Superseded compaction summary elided"); + expect(stale.preserveData).toBeUndefined(); + const rebuiltArchive = snapcompact.getPreservedArchive(rebuilt.preserveData); + expect(rebuiltArchive?.frames.length).toBe(4); + + // The frame rescue fired first: the elide/image tiers (provable no-ops + // on a compaction tail) were skipped, and no misleading warning. + expect(shakeSpy).not.toHaveBeenCalled(); + 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("rebuilds the just-written archive when a completed pass dead-ends on its own frames", async () => { + // POST-PASS shape (observed live on 17.0.8): compaction ran and wrote a + // frame archive, but the archive itself is the over-budget cost — each + // pass re-renders the carried-forward text into MORE frames. The + // elide/image tiers can't shrink it; tier 0 of the dead-end rescue must. + await createSession({ frameCount: 0, hookArchiveFrames: SEEDED_FRAME_COUNT }); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + let rebuiltArchiveApplied = false; + vi.spyOn(session, "getContextUsage").mockImplementation(() => + rebuiltArchiveApplied + ? { tokens: 30000, contextWindow: 200000, percent: 15 } + : { tokens: 190000, contextWindow: 200000, percent: 95 }, + ); + const shakeSpy = vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); + const compactSpy = vi.spyOn(snapcompact, "compact").mockImplementation(async () => { + rebuiltArchiveApplied = true; + return { + summary: "Rebuilt archive at a smaller frame budget.", + shortSummary: "rebuilt snapcompact archive", + firstKeptEntryId: (sessionManager.getBranch()[0] as { id: string }).id, + tokensBefore: 150_000, + details: { readFiles: [], modifiedFiles: [] }, + preserveData: makeArchivePreserveData(4), + }; + }); + + const notices = collectNotices(); + const emitSpy = vi.spyOn(ExtensionRunner.prototype, "emit"); + await triggerMaintenance(); + + expect(compactSpy).toHaveBeenCalledTimes(1); + const compactions = sessionManager.getBranch().filter(e => e.type === "compaction") as CompactionEntry[]; + expect(compactions.length).toBe(2); + const [hookWritten, rebuilt] = compactions; + expect(hookWritten.summary).toContain("Superseded compaction summary elided"); + expect(hookWritten.preserveData).toBeUndefined(); + expect(snapcompact.getPreservedArchive(rebuilt.preserveData)?.frames.length).toBe(4); + // Extensions must be notified about the entry that is now active, not + // only the hook-written one the rescue superseded. + const compactEvents = emitSpy.mock.calls + .map(c => c[0] as { type?: string; compactionEntry?: CompactionEntry }) + .filter(e => e.type === "session_compact"); + expect(compactEvents.some(e => e.compactionEntry?.id === rebuilt.id)).toBe(true); + expect(shakeSpy).not.toHaveBeenCalled(); + 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); + }); + + it("still runs the elide tiers and warns when the frame rebuild frees too little", async () => { + // Codex review on #6362: the latest archive may not be the oversized + // tail (e.g. a huge kept tool result sits after it). A frame-count + // shrink alone must NOT count as success — the elide/image tiers still + // get their shot at the real tail, and the no-progress warning stays. + await createSession({ frameCount: SEEDED_FRAME_COUNT }); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + // Usage stays over the band even after the rebuild. + vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 }); + const shakeSpy = vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); + vi.spyOn(snapcompact, "compact").mockResolvedValue({ + summary: "Rebuilt archive at a smaller frame budget.", + shortSummary: "rebuilt snapcompact archive", + firstKeptEntryId: (sessionManager.getBranch()[0] as { id: string }).id, + tokensBefore: 150_000, + details: { readFiles: [], modifiedFiles: [] }, + preserveData: makeArchivePreserveData(4), + }); + + const notices = collectNotices(); + await triggerMaintenance(); + + expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything()); + 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"); + // The dead-end badge must live on the ACTIVE (rebuilt) entry — the + // collapsed transcript only shows the latest compaction divider. + const compactions = sessionManager.getBranch().filter(e => e.type === "compaction") as CompactionEntry[]; + const active = compactions.at(-1); + expect(snapcompact.getPreservedArchive(active?.preserveData)?.frames.length).toBe(4); + expect(active?.warning).toContain(NO_PROGRESS_FRAGMENT); + }); + + it("bails when the kept tail plus fixed context leaves no frame budget", async () => { + // Codex review on #6362 (round 5): a tail just under the recovery band + // still cannot coexist with the fixed context + a minimum rebuilt + // archive. The budget now charges the kept tail like + // #compactionCreatedHeadroom does, so the rescue must bail instead of + // appending a rebuild that can never create headroom. + await createSession({ frameCount: SEEDED_FRAME_COUNT }); + // ~40k estimated tokens: under the 48k band, but over band − edges/template. + sessionManager.appendMessage({ + role: "toolResult", + toolCallId: "call-mid", + toolName: "bash", + content: [{ type: "text", text: "y".repeat(160_000) }], + isError: false, + timestamp: Date.now(), + }); + vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(undefined); + 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 }); + const shakeSpy = vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); + const compactSpy = vi.spyOn(snapcompact, "compact"); + + await triggerMaintenance(); + + expect(compactSpy).not.toHaveBeenCalled(); + expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything()); + expect(sessionManager.getBranch().at(-1)?.type).not.toBe("compaction"); + }); + + it("leaves an oversized non-archive tail to the elide tiers instead of rescuing the archive", async () => { + // Codex review on #6362 (round 4): with […, archive, HUGE kept tool + // result], rebuilding the archive would append the replacement at the + // leaf — making the branch tail a compaction entry that + // prepareCompaction's last-entry guard can never summarize past, even + // after elide shrinks the real culprit. The rescue must bail when the + // post-archive tail alone exceeds the recovery band. + await createSession({ frameCount: SEEDED_FRAME_COUNT }); + // Seed a kept tool-result tail far above the 0.8 × 60k band. + sessionManager.appendMessage({ + role: "toolResult", + toolCallId: "call-huge", + toolName: "bash", + content: [{ type: "text", text: "x".repeat(400_000) }], + isError: false, + timestamp: Date.now(), + }); + // Force the no-preparation dead-end (as in the #4786 guard tests): the + // oversized turn leaves nothing summarizable, which is the shape where + // a premature archive rebuild would wedge prepareCompaction. + vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(undefined); + 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 }); + const shakeSpy = vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); + const compactSpy = vi.spyOn(snapcompact, "compact"); + + const notices = collectNotices(); + await triggerMaintenance(); + + // The archive was NOT rebuilt; the elide tier got its shot at the tail. + expect(compactSpy).not.toHaveBeenCalled(); + expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything()); + const lastEntry = sessionManager.getBranch().at(-1); + expect(lastEntry?.type).not.toBe("compaction"); + const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); + expect(noProgress.length).toBe(1); + }); + + it("still warns once when the trailing archive is already at the minimum frame count", async () => { + await createSession({ frameCount: 1 }); + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue(); + vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 190000, contextWindow: 200000, percent: 95 }); + vi.spyOn(session, "shake").mockResolvedValue({ + mode: "elide", + toolResultsDropped: 0, + blocksDropped: 0, + tokensFreed: 0, + }); + const compactSpy = vi.spyOn(snapcompact, "compact"); + + const notices = collectNotices(); + await triggerMaintenance(); + + expect(compactSpy).not.toHaveBeenCalled(); + expect(promptSpy).not.toHaveBeenCalled(); + expect(continueSpy).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"); + }); + + it("skips the frame rescue when the active model is not vision-capable", async () => { + await createSession({ frameCount: SEEDED_FRAME_COUNT, visionModel: false }); + 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 }); + const shakeSpy = vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }); + const compactSpy = vi.spyOn(snapcompact, "compact"); + + const notices = collectNotices(); + await triggerMaintenance(); + + // Text-only model: no frame re-render; existing tiers still run and the + // existing dead-end warning is preserved. + expect(compactSpy).not.toHaveBeenCalled(); + expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything()); + const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); + expect(noProgress.length).toBe(1); + }); +}); diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts index e49dcb573..971041fd9 100644 --- a/packages/snapcompact/test/snapcompact.test.ts +++ b/packages/snapcompact/test/snapcompact.test.ts @@ -944,6 +944,34 @@ describe("compact", () => { expect(archive?.frames.length).toBe(5); }); + it("re-compacting with a smaller maxFrames than the previous archive shrinks the frame count", async () => { + const first = await snapcompact.compact( + makePreparation({ + messagesToSummarize: [ + createUserMessage(`HEAD SENTINEL. ${"Important fact number one. ".repeat(1000)}TAIL SENTINEL.`), + ], + }), + { frameSize: TEST_FRAME_SIZE, maxFrames: 7 }, + ); + const firstArchive = snapcompact.getPreservedArchive(first.preserveData); + expect(firstArchive?.frames.length).toBe(7); + + // No new messages: rebuild the SAME archive at a reduced budget — the + // dead-end rescue path for a trailing over-threshold archive. + const shrunk = await snapcompact.compact( + makePreparation({ + messagesToSummarize: [], + previousSummary: first.summary, + previousPreserveData: first.preserveData, + }), + { frameSize: TEST_FRAME_SIZE, maxFrames: 3 }, + ); + const shrunkArchive = snapcompact.getPreservedArchive(shrunk.preserveData); + expect(shrunkArchive?.frames.length).toBeGreaterThan(0); + expect(shrunkArchive?.frames.length).toBeLessThanOrEqual(3); + expect(shrunkArchive?.textHead ?? shrunkArchive?.text).toContain("HEAD SENTINEL."); + }); + it("keeps the original text head across later compactions", async () => { const first = await snapcompact.compact( makePreparation({