From 539d15fa9ca5b17ea1c69f005385c394b646dced Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Thu, 17 Sep 2026 14:00:08 +0300 Subject: [PATCH] fix: keep visual compaction across model changes and bound text migration Signed-off-by: dawn <90008@klbr.net> --- README.md | 6 +- index.ts | 125 ++++++++++++++------------- test/integration.mjs | 198 +++++++++++++++++++++++++++++++++++++------ 3 files changed, 241 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 21a3f54..71dbf34 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,9 @@ startup environment variables: artifacts live in Prime's native per-session artifact tree. session JSONL entries contain only small versioned references. artifact files use mode `0600`, have bounded size, and are checked with SHA-256 before use. -repeated compactions with the same model extend the existing archive. if the active model differs from the archive's producer model or cannot accept images, the next compaction migrates the archive to native text compaction to preserve history safely. request-time notices alert once per compaction when an archive is read under degraded or cross-model visibility. forks copy referenced immutable artifacts into the fork before the next request. unreferenced files are collected after startup and compaction, or manually with `/snapcompact gc`. +repeated compactions extend the existing archive under whatever model is active; a model switch only changes which model is recorded as the archive's producer, never the compaction path. if the active model cannot accept images, the next compaction migrates the archive to native text compaction to preserve history safely. request-time notices alert once per compaction when an archive is read by a model without image support. forks copy referenced immutable artifacts into the fork before the next request. unreferenced files are collected after startup and compaction, or manually with `/snapcompact gc`. -if the SnapCompact worker fails, the first visual compaction falls back to native text compaction. an existing archive migrates to native text only when its complete recoverable text fits safely. otherwise compaction is cancelled and the prior archive remains intact. +if the SnapCompact worker fails, the first visual compaction falls back to native text compaction. an existing archive migrates to native text carrying as much archive text as that request's budget leaves room for; the migration announces its token estimate and states how much archive text it drops. when the archive cannot fit next to the current messages at all, or the migrating call fails, compaction still runs natively without the archive text and says so, instead of cancelling; the archive file is left untouched. request-time frame selection respects the model context window, provider image limits, estimated visual tokens, base64 size, and other images already present in the request. omissions are stated in model-visible text. @@ -109,4 +109,4 @@ set `PRIME_AGENT_ROOT` when Prime is not installed at `/opt/homebrew/lib/node_mo PRIME_AGENT_ROOT=/path/to/prime-agent env -u NO_COLOR FORCE_COLOR=0 npm test ``` -the suite type-checks both runtimes and covers initial and repeated archives, cjk-safe rendering, model-switch budgets, native migration, cumulative file tracking, artifact collection, manual compaction, automatic compaction, resume, and fork/restart/resume image rehydration through Prime's faux provider. +the suite type-checks both runtimes and covers initial and repeated archives, cjk-safe rendering, model switches and provider-lane flips reading and compacting on the visual path unclamped, migration notices and their token estimates, over-budget native fallback, native migration, cumulative file tracking, artifact collection, manual compaction, automatic compaction, resume, and fork/restart/resume image rehydration through Prime's faux provider. diff --git a/index.ts b/index.ts index 324f1a7..939eae8 100644 --- a/index.ts +++ b/index.ts @@ -38,7 +38,6 @@ const MAX_WORKER_INPUT_BYTES = 32 * 1024 * 1024; const MAX_WORKER_OUTPUT_BYTES = 8 * 1024 * 1024; const MAX_WORKER_ERROR_BYTES = 64 * 1024; const WORKER_TIMEOUT_MS = 120_000; -const UNKNOWN_PROVIDER_IMAGE_BUDGET = 5; const CONTEXT_SAFETY_TOKENS = 4096; const UNBOUNDED_TEXT_TOKENS = Number.MAX_SAFE_INTEGER; const MIGRATION_SAFETY_TOKENS = 8192; @@ -798,20 +797,28 @@ async function textCompactWithArchive( 0, model.contextWindow - event.preparation.settings.reserveTokens - MIGRATION_SAFETY_TOKENS, ); - if (currentTokens + metadataTokens > inputBudget) { - const neededTokens = currentTokens + metadataTokens; - throw new Error( - `text migration needs about ${neededTokens.toLocaleString()} input tokens for current messages, ` + - `above the safe ${inputBudget.toLocaleString()}-token budget; switch to a larger model or reduce message history`, - ); - } - const prefixTokens = conservativeTextTokens(prefix); - const availableArchiveTokens = Math.max(0, inputBudget - currentTokens - metadataTokens - prefixTokens); const archiveTokens = conservativeTextTokens(source); + // One request carries as much archive text as this request's own budget leaves + // room for. The bound is that budget, never a fixed ceiling: a constant cap is + // what dropped whole archives on models that could have carried them, and the + // previous fix removed it. Nothing is staged either, because splitting one + // summary across calls changes what the summarizer sees. + const availableArchiveTokens = Math.max(0, inputBudget - currentTokens - metadataTokens - prefixTokens); + const carriedTokens = Math.min(archiveTokens, availableArchiveTokens); + const droppedTokens = archiveTokens - carriedTokens; + notify( + ctx, + `SnapCompact is migrating the ${formatModel(previous.snapcompact.producer)} image archive to text ` + + `compaction: about ${(prefixTokens + carriedTokens + currentTokens + metadataTokens).toLocaleString()} ` + + `input tokens (archive ${archiveTokens.toLocaleString()}, current messages ${currentTokens.toLocaleString()}) ` + + `against a ${inputBudget.toLocaleString()}-token budget; carrying ${carriedTokens.toLocaleString()} of the ` + + `archive's ${archiveTokens.toLocaleString()} text tokens` + + (droppedTokens > 0 ? ` and dropping ${droppedTokens.toLocaleString()} of them from this request` : ""), + ); const messagesToSummarize: AgentMessage[] = []; - if (availableArchiveTokens > 0) { + if (carriedTokens > 0) { const archiveSource = archiveTokens <= availableArchiveTokens ? source : boundedTextForTokens(source, availableArchiveTokens, "archive"); @@ -823,34 +830,48 @@ async function textCompactWithArchive( }); } } + const carriedArchive = messagesToSummarize.length > 0; + if (!carriedArchive) { + notify( + ctx, + `SnapCompact cannot fit the archive's ${archiveTokens.toLocaleString()} text tokens next to ` + + `${(currentTokens + metadataTokens).toLocaleString()} tokens of current messages in a ` + + `${inputBudget.toLocaleString()}-token budget, so this compaction runs without the archive text; ` + + `the archive file is left untouched`, + "warning", + ); + } messagesToSummarize.push(...event.preparation.messagesToSummarize); - return nativeCompactTracked( - event, - ctx, - previous, - compactImpl, - thinkingLevel, - messagesToSummarize, - ); + try { + return await nativeCompactTracked( + event, + ctx, + previous, + compactImpl, + thinkingLevel, + messagesToSummarize, + ); + } catch (error) { + if (event.signal.aborted || !carriedArchive) throw error; + // The migration call is the only one that can fail on its own request shape. + // Compaction still has to happen, so retry the same summarization without the + // archive text instead of cancelling and leaving the session uncompactable. + notify( + ctx, + `SnapCompact text migration failed: ${error instanceof Error ? error.message : String(error)}; ` + + `retrying this compaction without the archive text`, + "warning", + ); + console.warn("[snapcompact] text migration retry without archive", error); + return nativeCompactTracked(event, ctx, previous, compactImpl, thinkingLevel); + } } function formatModel(model: { provider: string; id: string }): string { return `${model.provider}/${model.id}`; } -function matchesProducer( - model: ExtensionContext["model"], - producer: SnapcompactState["producer"], -): boolean { - if (!model) return false; - return ( - model.provider === producer.provider && - model.api === producer.api && - model.id === producer.id - ); -} - function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info") { if (!ctx.hasUI) return; try { @@ -929,9 +950,11 @@ export function createSnapcompactExtension(dependencies: SnapcompactDependencies } } const vision = ctx.model?.input.includes("image") === true; - const sameModel = previous ? matchesProducer(ctx.model, previous.snapcompact.producer) : true; + // Model identity never gates the compaction path (the oh-my-pi snapcompact + // contract): the archive's frames are images of text that any vision model + // reads, so a model switch only changes which producer is recorded next. const needsTextPath = - !enabled || !vision || event.customInstructions !== undefined || !sameModel; + !enabled || !vision || event.customInstructions !== undefined; if (needsTextPath) { if (previous && previousArtifact) { try { @@ -1081,35 +1104,19 @@ export function createSnapcompactExtension(dependencies: SnapcompactDependencies const archive = artifact.preserveData.snapcompact; const vision = ctx.model?.input.includes("image") === true; - const sameModel = matchesProducer(ctx.model, details.snapcompact.producer); const isImagedArchive = archive.frames.length > 0; - if (isImagedArchive && (!vision || !sameModel)) { + if (isImagedArchive && !vision) { const compactionKey = `${ctx.sessionManager.getSessionId()}:${details.snapcompact.artifact.sha256}`; if (!notifiedDegradations.has(compactionKey)) { notifiedDegradations.add(compactionKey); const frameCount = archive.frames.length; const frameLabel = `${frameCount} image frame${frameCount === 1 ? "" : "s"}`; const producer = formatModel(details.snapcompact.producer); - if (!vision) { - notify( - ctx, - `SnapCompact archive rendered as ${frameLabel} by ${producer}; this model cannot read images, so the middle of the history is a text placeholder until the next compaction migrates it`, - "warning", - ); - } else { - const maxImages = Math.min(UNKNOWN_PROVIDER_IMAGE_BUDGET, details.snapcompact.frameBudget); - const visible = Math.min(maxImages, frameCount); - const omitted = frameCount - visible; - const visibility = omitted > 0 - ? `${visible} of ${frameCount} frames visible, ${omitted} omitted` - : `${visible} frame${visible === 1 ? "" : "s"} visible`; - const reader = ctx.model ? formatModel(ctx.model) : "unknown model"; - notify( - ctx, - `SnapCompact archive rendered as ${frameLabel} by ${producer}; reading with different model ${reader} (${visibility}); cross-model image support is unverified until the next compaction migrates it to text`, - "warning", - ); - } + notify( + ctx, + `SnapCompact archive rendered as ${frameLabel} by ${producer}; this model cannot read images, so the middle of the history is a text placeholder until the next compaction migrates it`, + "warning", + ); } } @@ -1122,11 +1129,9 @@ export function createSnapcompactExtension(dependencies: SnapcompactDependencies // budget boundary crossing, so the block layout stays frozen for the // whole compaction epoch: frame count from the stored budget, text // edges verbatim (UNBOUNDED_TEXT_TOKENS makes boundedTextForTokens a - // verified passthrough), MAX_FRAME_DATA_BYTES as the only clamp. - const maxImages = sameModel - ? details.snapcompact.frameBudget - : Math.min(UNKNOWN_PROVIDER_IMAGE_BUDGET, details.snapcompact.frameBudget); - content.push(...archiveBlocks(archive, maxImages, UNBOUNDED_TEXT_TOKENS)); + // verified passthrough), MAX_FRAME_DATA_BYTES as the only clamp. The + // reader model's identity does not enter the layout. + content.push(...archiveBlocks(archive, details.snapcompact.frameBudget, UNBOUNDED_TEXT_TOKENS)); } else { // Non-vision model reading an imaged archive: a transitional state. The // next compaction takes the needsTextPath branch and migrates this diff --git a/test/integration.mjs b/test/integration.mjs index 86c3cb3..eeb3d41 100644 --- a/test/integration.mjs +++ b/test/integration.mjs @@ -48,6 +48,37 @@ const prep = (messages, previousSummary) => ({ }); const controller = new AbortController(); const notifications = []; +// Independent recomputation of the estimates the extension reports, so a notice +// with a hardcoded or stale number cannot pass this suite. +const plainText = (text) => text.replace(/[\u000e\u000f]/g, "").replaceAll("\u2588", "\n"); +const conservativeTextTokens = (text) => { + let nonAscii = 0; + for (let index = 0; index < text.length; index++) { + if (text.charCodeAt(index) > 0x7f) nonAscii++; + } + return Math.ceil((text.length - nonAscii) / 3 + nonAscii * 2); +}; +const noticeNumbers = (value) => Number(value.replaceAll(",", "")); +const migrationEstimate = (message) => { + const match = /about ([\d,]+) input tokens \(archive ([\d,]+), current messages ([\d,]+)\) against a ([\d,]+)-token budget; carrying ([\d,]+) of the archive's ([\d,]+) text tokens(?: and dropping ([\d,]+) of them from this request)?/.exec(message ?? ""); + if (!match) return undefined; + return { + total: noticeNumbers(match[1]), + archive: noticeNumbers(match[2]), + messages: noticeNumbers(match[3]), + budget: noticeNumbers(match[4]), + carried: noticeNumbers(match[5]), + archiveTotal: noticeNumbers(match[6]), + dropped: match[7] === undefined ? 0 : noticeNumbers(match[7]), + }; +}; +const archiveSourceTokens = (sessionId, ref) => { + const artifact = JSON.parse(readFileSync( + join(root, "sessions", "session-artifacts", sessionId, "snapcompact", ref.file), + "utf8", + )); + return conservativeTextTokens(plainText(artifact.preserveData.snapcompact.text)); +}; let branch = []; const ctx = { cwd: process.cwd(), @@ -120,25 +151,59 @@ if (visual.role !== "custom" || images.length < 1 || images.length > first.compa if (images.some((image) => image.detail !== "original")) { throw new Error("vision context dropped SnapCompact frame detail"); } +// A different vision model reads the same frozen epoch: model identity never +// gates the archive (the oh-my-pi contract), so the frames render unclamped and +// no cross-model degradation notice appears. +const switchBaseline = notifications.length; ctx.model = { ...model, id: "different-model-same-provider" }; const switched = await context({ type: "context", messages: [ { role: "compactionSummary", summary: first.compaction.summary, tokensBefore: first.compaction.tokensBefore, timestamp: Date.now() }, { role: "user", content: "continue after model switch", timestamp: Date.now() }, ]}, ctx); const switchedImages = switched.messages[0].content.filter((block) => block.type === "image").length; -if (switchedImages > 5) throw new Error("same-provider model switch reused an unsafe producer frame budget"); -const switchNotice = notifications.find((n) => n.message.includes("reading with different model")); -if (!switchNotice || switchNotice.level !== "warning") { - throw new Error(`expected degradation warning on model switch, got ${JSON.stringify(switchNotice)}`); +if (switchedImages !== images.length) { + throw new Error(`model switch changed the frame budget: ${switchedImages} of ${images.length} frames`); } -const notificationCountAfterFirstDegradation = notifications.length; -await context({ type: "context", messages: [ +if (notifications.length !== switchBaseline) { + throw new Error(`model switch emitted a degradation notice: ${JSON.stringify(notifications.slice(switchBaseline))}`); +} +// Lane flip: the same model id and api served from another provider lane (as in +// zai/glm-5.3-flash and hyper/glm-5.3-flash) is still the same model. The archive +// frames are images of text, so a lane flip must not report cross-model image +// support as unverified, must not clamp the frame budget, and must not force the +// next compaction into the text path. +ctx.model = { ...model, provider: "hyper" }; +const laneFlipBaseline = notifications.length; +const laneFlipped = await context({ type: "context", messages: [ { role: "compactionSummary", summary: first.compaction.summary, tokensBefore: first.compaction.tokensBefore, timestamp: Date.now() }, - { role: "user", content: "second turn after model switch", timestamp: Date.now() }, + { role: "user", content: "continue on the other provider lane", timestamp: Date.now() }, ]}, ctx); -if (notifications.length !== notificationCountAfterFirstDegradation) { - throw new Error("degradation notice re-emitted on subsequent turn for the same compaction"); +const laneFlipNotices = notifications.slice(laneFlipBaseline); +const laneFlipDegradation = laneFlipNotices.find((n) => /different model|cross-model/.test(n.message)); +const laneFlipImages = laneFlipped.messages[0].content.filter((block) => block.type === "image").length; +const laneFlipCompaction = await before({ type: "session_before_compact", preparation: prep(oldMessages, first.compaction.summary), branchEntries: branch, customInstructions: undefined, signal: controller.signal }, ctx); +ctx.model = { ...model }; +const laneFlipFailures = []; +if (laneFlipDegradation) { + laneFlipFailures.push(`reported a cross-model degradation: ${JSON.stringify(laneFlipDegradation)}`); +} +if (laneFlipImages !== images.length) { + laneFlipFailures.push(`clamped the frame budget: ${laneFlipImages} of ${images.length} frames`); +} +const laneFlipMigration = notifications.slice(laneFlipBaseline).find((n) => /image archive to text compaction/.test(n.message)); +if (laneFlipMigration) { + laneFlipFailures.push(`started a text migration: ${laneFlipMigration.message}`); +} +if (laneFlipCompaction?.compaction?.details?.snapcompactText) { + laneFlipFailures.push("forced the archive into the text path"); +} else if (laneFlipCompaction?.compaction?.details?.snapcompact?.producer?.provider !== "hyper") { + laneFlipFailures.push("did not stay on the visual compaction path"); +} +if (laneFlipFailures.length > 0) { + throw new Error(`provider lane flip ${laneFlipFailures.join("; ")}`); } +const steadyStateNoticeBaseline = notifications.length; + // Frozen epoch layout: block bytes must not depend on the live window or the // growing tail. Every rehydration of the same archive must be byte-identical, // otherwise each change busts the prompt cache back to the system prefix. @@ -155,7 +220,7 @@ for (let turn = 0; turn < 20; turn++) { throw new Error(`vision archive blocks changed at tail step ${turn}; prompt cache would bust`); } } -if (notifications.length !== notificationCountAfterFirstDegradation) { +if (notifications.length !== steadyStateNoticeBaseline) { throw new Error("degradation notice re-emitted during steady-state turns for the same compaction"); } ctx.model = { ...model }; @@ -192,13 +257,21 @@ if (!forkHydrated.messages[0].content.some((block) => block.type === "image")) { throw new Error("forked context did not rehydrate copied archive images"); } ctx.model = { ...model, input: ["text"] }; +const nonVisionBaseline = notifications.length; await context({ type: "context", messages: [ { role: "compactionSummary", summary: first.compaction.summary, tokensBefore: first.compaction.tokensBefore, timestamp: Date.now() }, { role: "user", content: "continue fork text", timestamp: Date.now() }, ]}, ctx); -const nonVisionNotice = notifications.find((n) => n.message.includes("this model cannot read images")); -if (!nonVisionNotice || nonVisionNotice.level !== "warning") { - throw new Error(`expected non-vision degradation warning, got ${JSON.stringify(nonVisionNotice)}`); +const nonVisionNotices = notifications.slice(nonVisionBaseline); +if (nonVisionNotices.length !== 1 || nonVisionNotices[0].level !== "warning" || !nonVisionNotices[0].message.includes("this model cannot read images")) { + throw new Error(`expected one non-vision degradation warning, got ${JSON.stringify(nonVisionNotices)}`); +} +await context({ type: "context", messages: [ + { role: "compactionSummary", summary: first.compaction.summary, tokensBefore: first.compaction.tokensBefore, timestamp: Date.now() }, + { role: "user", content: "second fork text turn", timestamp: Date.now() }, +]}, ctx); +if (notifications.length !== nonVisionBaseline + 1) { + throw new Error("non-vision degradation notice re-emitted on the second turn of the same compaction"); } ctx.model = { ...model }; @@ -278,11 +351,63 @@ if ( ) { throw new Error(`worker failure notice hid the primary error: ${JSON.stringify(retainedNotice)}`); } -ctx.model = { ...model, input: ["text"], contextWindow: 20000, maxTokens: 4096 }; -console.warn = () => {}; -const manual = await before({ type: "session_before_compact", preparation: prep(nextMessages, first.compaction.summary), branchEntries: branch, customInstructions: "focus on tests", signal: controller.signal }, ctx); -console.warn = originalWarn; -if (manual?.cancel !== true) throw new Error("oversize manual text migration did not cancel safely"); +// Migration that cannot fit: the current messages alone exceed the safe input +// budget, so the archive text cannot ride along. Compaction must still run, using +// native text compaction without the archive, and say what it dropped. +const overBudgetRegistration = ai.registerFauxProvider({ + models: [{ + id: "snapcompact-over-budget-text", + name: "over-budget text migration test", + input: ["text"], + reasoning: false, + contextWindow: 20_000, + maxTokens: 8192, + }], + tokensPerSecond: 100_000, +}); +const overBudgetModel = overBudgetRegistration.getModel(); +overBudgetRegistration.setResponses([ + ai.fauxAssistantMessage("over-budget text migration summary"), +]); +ctx.model = overBudgetModel; +const overBudgetBaseline = notifications.length; +const expectedArchiveTokens = archiveSourceTokens(activeSessionId, ref1); +const overBudget = await before({ type: "session_before_compact", preparation: prep(nextMessages, first.compaction.summary), branchEntries: branch, customInstructions: "focus on tests", signal: controller.signal }, ctx); +if (overBudget?.cancel === true) { + throw new Error("over-budget text migration cancelled compaction instead of falling back"); +} +if (overBudget?.compaction?.details?.snapcompactText?.schema !== 1) { + throw new Error("over-budget text migration did not compact through the native fallback"); +} +const overBudgetNotices = notifications.slice(overBudgetBaseline); +const progressNotice = overBudgetNotices.find((n) => /image archive to text compaction/.test(n.message)); +if (!progressNotice) { + throw new Error(`over-budget text migration emitted no progress notice: ${JSON.stringify(overBudgetNotices)}`); +} +const overBudgetEstimate = migrationEstimate(progressNotice.message); +if (!overBudgetEstimate) { + throw new Error(`text migration progress notice carried no token estimate: ${progressNotice.message}`); +} +if (overBudgetEstimate.budget !== 0) { + throw new Error(`over-budget migration reported budget ${overBudgetEstimate.budget}: ${progressNotice.message}`); +} +if (overBudgetEstimate.archiveTotal !== expectedArchiveTokens) { + throw new Error(`text migration progress notice estimate ignored the archive: ${progressNotice.message}`); +} +if (overBudgetEstimate.carried !== 0 || overBudgetEstimate.dropped !== expectedArchiveTokens) { + throw new Error(`over-budget notice reported carried ${overBudgetEstimate.carried} and dropped ${overBudgetEstimate.dropped} of ${expectedArchiveTokens}: ${progressNotice.message}`); +} +if (!(overBudgetEstimate.total > overBudgetEstimate.messages && overBudgetEstimate.total < overBudgetEstimate.messages + expectedArchiveTokens)) { + throw new Error(`text migration progress notice reported an implausible request size of ${overBudgetEstimate.total}: ${progressNotice.message}`); +} +const droppedNotice = overBudgetNotices.find( + (n) => n.level === "warning" && /cannot fit the archive's [\d,]+ text tokens/.test(n.message), +); +if (!droppedNotice) { + throw new Error(`over-budget migration did not state that the archive was dropped: ${JSON.stringify(overBudgetNotices)}`); +} +overBudgetRegistration.unregister(); +ctx.model = { ...model }; const largeRegistration = ai.registerFauxProvider({ models: [{ @@ -327,11 +452,26 @@ if (!truncatedMigration?.compaction) throw new Error("truncated text migration f if (truncatedMigration.compaction.details?.snapcompactText?.schema !== 1) { throw new Error("truncated text migration did not record snapcompactText details"); } +const truncatedEstimate = migrationEstimate(notifications.at(-1)?.message); +if (!truncatedEstimate) { + throw new Error(`truncated text migration emitted no token estimate: ${JSON.stringify(notifications.at(-1))}`); +} +if (truncatedEstimate.archiveTotal !== expectedArchiveTokens) { + throw new Error(`truncated migration estimate ignored the archive: ${JSON.stringify(notifications.at(-1))}`); +} +if (truncatedEstimate.carried <= 0 || truncatedEstimate.dropped <= 0) { + throw new Error(`truncated migration did not bound one call's archive carry: ${JSON.stringify(notifications.at(-1))}`); +} +if (truncatedEstimate.carried + truncatedEstimate.dropped !== truncatedEstimate.archiveTotal) { + throw new Error(`truncated migration notice did not account for the dropped archive text: ${JSON.stringify(notifications.at(-1))}`); +} +// A compaction under a different vision model stays on the visual path and +// records the new model as the archive's producer; it never migrates to text. const crossModelRegistration = ai.registerFauxProvider({ models: [{ id: "snapcompact-cross-model-vision", - name: "cross-model vision migration test", + name: "cross-model vision compaction test", input: ["text", "image"], reasoning: false, contextWindow: 2_000_000, @@ -344,17 +484,21 @@ crossModelRegistration.setResponses([ ai.fauxAssistantMessage("cross-model migration summary"), ]); ctx.model = crossModel; -const crossModelMigration = await before({ +const crossModelCompaction = await before({ type: "session_before_compact", preparation: prep(nextMessages, first.compaction.summary), branchEntries: branch, customInstructions: undefined, signal: controller.signal, }, ctx); -if (!crossModelMigration?.compaction) throw new Error("cross-model migration failed to compact"); -if (crossModelMigration.compaction.details?.snapcompactText?.schema !== 1) { - throw new Error("cross-model migration did not record snapcompactText details"); +if (!crossModelCompaction?.compaction) throw new Error("cross-model compaction failed to compact"); +if (crossModelCompaction.compaction.details?.snapcompactText) { + throw new Error("cross-model compaction migrated the archive to text"); +} +if (crossModelCompaction.compaction.details?.snapcompact?.producer?.id !== crossModel.id) { + throw new Error("cross-model compaction did not stay on the visual path"); } +crossModelRegistration.unregister(); console.log(JSON.stringify({ firstDetailsBytes: Buffer.byteLength(detailsJson), @@ -370,9 +514,13 @@ console.log(JSON.stringify({ textContextChars: textChars, firstFailureNativeFallback: initialFailure === undefined, existingFailureCancelled: workerFailureWithArchive.cancel, - manualOversizeCancelled: manual.cancel, + overBudgetFallback: overBudget.compaction.details.snapcompactText.schema, + overBudgetDroppedNotice: Boolean(droppedNotice), + laneFlipFrames: laneFlipImages, + laneFlipCompactionPath: laneFlipCompaction?.compaction?.details?.snapcompact ? "visual" : "text", + textMigrationTokensReported: expectedArchiveTokens, largeModelMigrationSuccess: Boolean(largeMigration?.compaction), truncatedModelMigrationSuccess: Boolean(truncatedMigration?.compaction), - crossModelMigrationSuccess: Boolean(crossModelMigration?.compaction), + crossModelStayedVisual: crossModelCompaction?.compaction?.details?.snapcompact?.producer?.id, }, null, 2)); rmSync(root, { recursive: true, force: true }); -- 2.54.0