From 9eb66f54f2be60e766df896bd58ecb80fe3eea59 Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Tue, 23 Jun 2026 06:37:44 -0400 Subject: [PATCH] fix(history): anchor collapsed history recency --- src/core/history.ts | 42 +++++++++++++++++++++++++++++++++++++++++- tests/history.test.ts | 40 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/core/history.ts b/src/core/history.ts index 5cb61c7..76550a9 100644 --- a/src/core/history.ts +++ b/src/core/history.ts @@ -24,10 +24,12 @@ import { bytesToBase64 } from './png.js'; * emitter (here) and the matcher (transform.ts) must reference this constant. */ export const HISTORY_SYNTHETIC_INTRO = - '[Earlier turns of THIS conversation, transcribed in the image(s) below. Each turn is wrapped in ... or ... tags, where N is an absolute turn index (larger N = more recent); attribute every turn strictly by its tag, and treat the highest-N turns as the most recent prior context, NOT the low-N opening turns. This is prior context, NOT the current request.]'; + '[Earlier turns of THIS conversation, transcribed in the image(s) below. Each turn is wrapped in ... or ... tags, where N is an absolute turn index (larger N = more recent); attribute every turn strictly by its tag, and treat the highest-N turns as the most recent prior context, NOT the low-N opening turns. Earlier turns may contain questions or tasks that were already answered later in this same history; do not reopen low-N turns unless the live text after this block asks you to. This is prior context, NOT the current request.]'; export const HISTORY_SYNTHETIC_OUTRO = '[End of earlier conversation. The current request is the live text that follows below.]'; +const LATEST_COLLAPSED_USER_PREVIEW_CHARS = 300; + /** Break-even gate predicate. Injected by transform.ts to avoid a circular import. * IMPORTANT: pass the full string, not text.length — the row-aware path in * isCompressionProfitable must see actual newlines to budget images correctly. @@ -271,6 +273,42 @@ export function messagesToHistorySegments( return { text: textOut.join('\n\n'), slotText: slotOut.join('\n\n') }; } +function userPromptText(content: string | ContentBlock[]): string { + if (typeof content === 'string') return content; + const parts: string[] = []; + for (const blk of content) { + if (!blk || typeof blk !== 'object') continue; + const t = (blk as { type?: string }).type; + if (t === 'text') parts.push((blk as TextBlock).text); + else if (t === 'image') parts.push('[image]'); + } + return parts.join('\n\n'); +} + +function compactPreview(text: string): string { + const compact = text.replace(/\s+/g, ' ').trim(); + if (compact.length <= LATEST_COLLAPSED_USER_PREVIEW_CHARS) return compact; + return compact.slice(0, LATEST_COLLAPSED_USER_PREVIEW_CHARS).trimEnd() + '...'; +} + +function latestCollapsedUserPointer( + messages: Message[], + upToExclusive: number, + fromInclusive: number, +): TextBlock | undefined { + for (let i = upToExclusive - 1; i >= fromInclusive; i--) { + const m = messages[i]!; + if (m.role !== 'user') continue; + const preview = compactPreview(userPromptText(m.content)); + if (!preview) continue; + return { + type: 'text', + text: `[Most recent collapsed user turn: ${preview}. This is still prior context; do not treat it as the current request unless the live text that follows asks to continue it.]`, + }; + } + return undefined; +} + /** * Collapse the closed-prefix run into one synthetic user message with 1+ history images. * Returns original messages unchanged on any no-collapse path (reason set in info). @@ -440,9 +478,11 @@ export async function collapseHistory( info.reason = 'render_empty'; return { messages, info }; } + const latestUserPointer = latestCollapsedUserPointer(messages, collapseLen, protectedPrefix); const syntheticContent: ContentBlock[] = [ { type: 'text', text: HISTORY_SYNTHETIC_INTRO }, ...imageBlocks, + ...(latestUserPointer ? [latestUserPointer] : []), { type: 'text', text: HISTORY_SYNTHETIC_OUTRO }, ]; const syntheticUser: Message = { diff --git a/tests/history.test.ts b/tests/history.test.ts index 62d2aff..d27a7ec 100644 --- a/tests/history.test.ts +++ b/tests/history.test.ts @@ -10,7 +10,7 @@ * images. * - `messagesToHistoryText`: `--- role ---` framing, skips empty turns. * - `collapseHistory`: full pipeline — opts handling, break-even gate, - * synthetic user message shape (text+image+text), live-tail preservation. + * synthetic user message shape (text+image+recency pointer+text), live-tail preservation. * - `transformRequest` end-to-end: compressHistory off (default), on with * enough turns to fire, off when no closed prefix exists. */ @@ -315,7 +315,37 @@ describe('collapseHistory', () => { expect(out[2]).toBe(msgs[11]); }); - it('splits dense collapsed history into readable image pages without plaintext side channels', async () => { + it('adds live-text recency guardrails for collapsed history', async () => { + const oldMarker = 'OLD WORKTREE QUESTION'; + const latestMarker = 'CURRENT FRONTIER IR REUSE'; + const msgs: Message[] = []; + for (let i = 0; i < 14; i++) { + const marker = i === 0 ? oldMarker : i === 10 ? latestMarker : `turn ${i}`; + const body = `${marker}: ` + 'x'.repeat(2800); + msgs.push(i % 2 === 0 ? usr(body) : asst(body)); + } + + const { messages: out, info } = await collapseHistory(msgs, profitable, { + keepTail: 2, + minCollapsePrefix: 5, + cols: 100, + collapseChunk: 0, + }); + + expect(info.reason).toBe(undefined); + expect(info.collapsedTurns).toBe(12); + const content = out[0]!.content as Array>; + const textBlocks = content.filter((c) => c.type === 'text') as Array<{ text: string }>; + expect(textBlocks).toHaveLength(3); + expect(textBlocks[0]!.text).toContain('do not reopen low-N turns'); + expect(textBlocks[1]!.text).toContain('Most recent collapsed user turn'); + expect(textBlocks[1]!.text).toContain(''); + expect(textBlocks[1]!.text).toContain(latestMarker); + expect(textBlocks[1]!.text).not.toContain(oldMarker); + expect(textBlocks[2]!.text).toContain('current request is the live text'); + }); + + it('splits dense collapsed history into readable image pages with only a bounded recency pointer', async () => { const body = Array.from( { length: 180 }, (_, i) => `line ${i}: ${'x'.repeat(80)}`, @@ -334,9 +364,11 @@ describe('collapseHistory', () => { ); const content = out[0]!.content as Array>; const textBlocks = content.filter((c) => c.type === 'text'); - expect(textBlocks).toHaveLength(2); + expect(textBlocks).toHaveLength(3); expect((textBlocks[0] as { text: string }).text).toContain('attribute every turn strictly by its tag'); - expect((textBlocks[1] as { text: string }).text).toContain('current request is the live text'); + expect((textBlocks[1] as { text: string }).text).toContain('Most recent collapsed user turn'); + expect(((textBlocks[1] as { text: string }).text).length).toBeLessThan(500); + expect((textBlocks[2] as { text: string }).text).toContain('current request is the live text'); expect(content.filter((c) => c.type === 'image')).toHaveLength(info.collapsedImages); });