Merge PR #6362: fix(compaction): rescue snapcompact archives stuck past the maintenance threshold (@HugoLopes45)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -13806,6 +13806,17 @@ export class AgentSession {
|
||||
options: { skipElide: boolean; hasProgress: () => boolean },
|
||||
): Promise<boolean> {
|
||||
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<snapcompact.CompactionResult | undefined> {
|
||||
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 });
|
||||
|
||||
@@ -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<string, unknown>[] {
|
||||
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<string, unknown> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user