fix(history): anchor collapsed history recency

This commit is contained in:
Steven Chong
2026-06-23 06:37:44 -04:00
parent 85f8536b67
commit 9eb66f54f2
2 changed files with 77 additions and 5 deletions
+41 -1
View File
@@ -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 <user t="N">...</user> or <assistant t="N">...</assistant> 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 <user t="N">...</user> or <assistant t="N">...</assistant> 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: <user t="${i}">${preview}</user>. 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 = {
+36 -4
View File
@@ -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<Record<string, unknown>>;
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('<user t="10">');
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<Record<string, unknown>>;
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);
});