mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
fix(history): anchor collapsed history recency
This commit is contained in:
+41
-1
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user