mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
feat(gpt): pin the live user request as text in history collapse
Autonomous GPT agents (OpenCode/gpt-5.5) send ONE human request then a long run of tool turns. The lone request is the OLDEST turn, so history collapse imaged it first and the model lost it — "I wonder what the user actually asked" → off-task drift (observed: agent edited compaction.ts instead of answering a compare question). The live-request guard's "the request is the trailing user message" heuristic assumes interactive chat and points at nothing here. Fix: keep the most-recent user turn OVERALL as legible TEXT, spliced between before-pin and after-pin history images inside the synthetic user message; older user turns stay imaged (must not look live — snap-to-first-prompt guard). The guard now echoes the request verbatim (capped). History is imaged on both sides of the pin, so compression barely changes. Cache safety (adversarially reviewed): - Pin ONLY when the latest user turn is INSIDE the collapse range. If it's in the kept tail (ordinary interactive turn) it's already native text — pinning an older in-range turn would migrate the pin across collapse-chunk boundaries and re-image frozen history. Restricting to the latest turn fixes its position until the next prompt, so before/after sections stay byte-stable across a run. - Undersized before-pin remainder merges into the previous before-section rather than emitting a sub-threshold (net-negative) image. Both Chat Completions and Responses paths. 473 tests pass; tsc + build clean. Not yet released — validate on the gpt-5.5 machine first.
This commit is contained in:
+130
-29
@@ -117,11 +117,22 @@ export interface HistoryTurn {
|
||||
/** Item we can't safely serialize (unknown kind, item_reference) — a hard
|
||||
* barrier: never collapse across it, since dropping it could lose state. */
|
||||
opaque: boolean;
|
||||
/** Raw body when this item is a real USER request (role==='user', not a tool
|
||||
* result). The planner pins the MOST RECENT such turn as legible text instead
|
||||
* of imaging it, so the live ask is never OCR-only. undefined = not a user turn. */
|
||||
userText?: string;
|
||||
}
|
||||
|
||||
export interface GptCollapsePlan {
|
||||
/** Rendered history images. Empty when no collapse happened. */
|
||||
/** Rendered history images BEFORE the pinned user turn (or ALL images when no
|
||||
* turn was pinned). Empty when no collapse happened. */
|
||||
images: RenderedImage[];
|
||||
/** Rendered history images AFTER the pinned user turn. Empty unless a pin split
|
||||
* the range. Total imaged = images ∪ imagesAfter. */
|
||||
imagesAfter: RenderedImage[];
|
||||
/** Raw text of the most-recent user request, kept legible (NOT imaged) and
|
||||
* spliced between `images` and `imagesAfter`. undefined = nothing pinned. */
|
||||
pinText?: string;
|
||||
/** The collapsed transcript text that was rendered (for o200k token counting). */
|
||||
text: string;
|
||||
/** Inclusive start index into the original item array. */
|
||||
@@ -171,6 +182,32 @@ function findClosedBoundary(
|
||||
return lastClosed;
|
||||
}
|
||||
|
||||
/** True if [from, toExclusive) opens no tool call it doesn't also close (and hits
|
||||
* no opaque barrier). Used to confirm the pinned user turn sits at a tool-closed
|
||||
* boundary so force-sealing the section before it can't orphan a function call. */
|
||||
function isClosedPrefix(turns: HistoryTurn[], from: number, toExclusive: number): boolean {
|
||||
const open = new Set<string>();
|
||||
for (let i = from; i < toExclusive; i++) {
|
||||
const t = turns[i]!;
|
||||
if (t.opaque) return false;
|
||||
for (const id of t.openIds) open.add(id);
|
||||
for (const id of t.closeIds) open.delete(id);
|
||||
}
|
||||
return open.size === 0;
|
||||
}
|
||||
|
||||
/** Join turn texts over [from, toExclusive), skipping empties and `skip` (the
|
||||
* pinned turn, which is emitted as text rather than imaged). */
|
||||
function joinTurns(turns: HistoryTurn[], from: number, toExclusive: number, skip: number): string {
|
||||
const parts: string[] = [];
|
||||
for (let i = from; i < toExclusive; i++) {
|
||||
if (i === skip) continue;
|
||||
const s = turns[i]!.text;
|
||||
if (s && s.length > 0) parts.push(s);
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan + render a history collapse over pre-lowered turns. Pure w.r.t. the input
|
||||
* (caller does the splice and builds the format-specific synthetic item).
|
||||
@@ -184,6 +221,7 @@ export async function planGptCollapse(
|
||||
const o: GptHistoryOptions = { ...GPT_HISTORY_DEFAULTS, ...opts };
|
||||
const base: GptCollapsePlan = {
|
||||
images: [],
|
||||
imagesAfter: [],
|
||||
text: '',
|
||||
start: 0,
|
||||
endExclusive: 0,
|
||||
@@ -216,11 +254,31 @@ export async function planGptCollapse(
|
||||
if (boundary + 1 - pp < o.minCollapsePrefix) {
|
||||
return { ...base, reason: 'prefix_too_short' };
|
||||
}
|
||||
const text = turns
|
||||
.slice(pp, boundary + 1)
|
||||
.map((t) => t.text)
|
||||
.filter((s) => s && s.length > 0)
|
||||
.join('\n\n');
|
||||
const rawEnd = boundary + 1;
|
||||
// Pin the LIVE request — the most-recent user turn OVERALL — as legible TEXT so it
|
||||
// is never OCR-only. Older user turns stay imaged (they must NOT look like the live
|
||||
// request; that's the snap-to-first-prompt guard). The history BEFORE and AFTER the
|
||||
// pin both stay imaged, so compression holds.
|
||||
//
|
||||
// CRITICAL: pin ONLY when the latest user turn falls INSIDE the collapse range. If
|
||||
// it sits in the kept tail (ordinary interactive turn) it is already native text —
|
||||
// pinning an OLDER in-range user turn would make the pin migrate across collapse-
|
||||
// chunk boundaries and re-image frozen history (cache churn). Restricting the pin to
|
||||
// the latest user turn means its position is fixed until the next prompt, so the
|
||||
// before/after section grid stays byte-stable across a long run. This covers exactly
|
||||
// the two shapes that need it: the autonomous single-prompt agent (pin == pp), and a
|
||||
// long current turn whose tool loop overflowed the tail (pin in the middle).
|
||||
let pinIdx = -1;
|
||||
for (let i = turns.length - 1; i >= pp; i--) {
|
||||
if (turns[i]!.userText !== undefined) { pinIdx = i; break; }
|
||||
}
|
||||
if (pinIdx >= rawEnd) pinIdx = -1; // latest user turn is in the live tail → already text
|
||||
// Only pin at a tool-closed boundary: a user turn straddled by an open tool call
|
||||
// (malformed input) would orphan the call when we seal the section before it.
|
||||
if (pinIdx >= 0 && !isClosedPrefix(turns, pp, pinIdx)) pinIdx = -1;
|
||||
|
||||
// Imaged baseline EXCLUDES the pinned turn (it is emitted as text, not rendered).
|
||||
const text = joinTurns(turns, pp, rawEnd, pinIdx);
|
||||
// Floor gate in o200k TOKENS, not chars: imaging bills vision tokens and the
|
||||
// text baseline is o200k tokens, so the break-even is a token comparison.
|
||||
if (!text || gptCountTokens(text) < o.minCollapseTokens) {
|
||||
@@ -245,10 +303,14 @@ export async function planGptCollapse(
|
||||
// Leftover tail turns that don't fill a whole section are NOT collapsed: collapse
|
||||
// ends at the last SEALED boundary so every emitted image is a frozen section.
|
||||
// (freezeChunk 0 = legacy whole-blob: one section spanning the whole range.)
|
||||
const rawEnd = boundary + 1;
|
||||
// The pinned turn force-seals the section before it and starts a fresh section
|
||||
// after it, so no image straddles the live request (history stays imaged on both
|
||||
// sides). (freezeChunk 0 = legacy whole-blob, still split around the pin.)
|
||||
const sections: Array<[number, number]> = [];
|
||||
if (o.freezeChunk <= 0) {
|
||||
sections.push([pp, rawEnd]); // legacy: whole range as one section
|
||||
if (pinIdx > pp) sections.push([pp, pinIdx]);
|
||||
const afterStart = pinIdx >= pp ? pinIdx + 1 : pp;
|
||||
if (afterStart < rawEnd) sections.push([afterStart, rawEnd]);
|
||||
} else {
|
||||
let secStart = pp;
|
||||
let acc = 0;
|
||||
@@ -262,6 +324,25 @@ export async function planGptCollapse(
|
||||
// because it collapses the whole closed prefix with no live leftover.
|
||||
const open = new Set<string>();
|
||||
for (let i = pp; i < rawEnd; i++) {
|
||||
if (i === pinIdx) {
|
||||
// Force-seal the before-pin section (open is empty here by isClosedPrefix)
|
||||
// and skip the pin so it is never imaged. If the remainder since the last
|
||||
// seal is too small to be worth its own image, MERGE it into the previous
|
||||
// before-section (a slightly oversized image) rather than emitting a sub-
|
||||
// threshold one — imaging ~200 tokens costs more in vision tokens than it
|
||||
// saves. (open is empty here, so extending the prior section can't orphan.)
|
||||
if (secStart < i) {
|
||||
const prev = sections[sections.length - 1];
|
||||
if (acc < o.sectionTokens && prev && prev[1] === secStart) {
|
||||
prev[1] = i; // extend previous before-section through the remainder
|
||||
} else {
|
||||
sections.push([secStart, i]);
|
||||
}
|
||||
}
|
||||
secStart = i + 1;
|
||||
acc = 0;
|
||||
continue;
|
||||
}
|
||||
acc += gptCountTokens(turns[i]!.text);
|
||||
for (const id of turns[i]!.openIds) open.add(id);
|
||||
for (const id of turns[i]!.closeIds) open.delete(id);
|
||||
@@ -279,15 +360,12 @@ export async function planGptCollapse(
|
||||
// cache-unstable partial blob.
|
||||
return { ...base, reason: 'below_min_tokens', collapsedChars: text.length };
|
||||
}
|
||||
const imgs: RenderedImage[] = [];
|
||||
const maxImages = Math.max(0, Math.floor(o.maxImages));
|
||||
const rendered: Array<{ s: number; e: number; imgs: RenderedImage[] }> = [];
|
||||
let imgCount = 0;
|
||||
let collapseEnd = pp;
|
||||
for (const [s, e] of sections) {
|
||||
const sectionText = turns
|
||||
.slice(s, e)
|
||||
.map((t) => t.text)
|
||||
.filter((str) => str && str.length > 0)
|
||||
.join('\n\n');
|
||||
const sectionText = joinTurns(turns, s, e, -1);
|
||||
if (!sectionText || sectionText.length === 0) continue;
|
||||
const safeSection = neutralizeSentinel(sectionText);
|
||||
const sectionRender = o.reflow ? reflow(safeSection) ?? safeSection : sectionText;
|
||||
@@ -295,38 +373,49 @@ export async function planGptCollapse(
|
||||
// the static slab. renderTextToPngs caps each PNG at MAX_HEIGHT_PX so a tall
|
||||
// section pages into N images, all still well under the 10,000-patch budget.
|
||||
const sectionImgs = await renderTextToPngs(sectionRender, o.cols, {}, o.maxHeightPx);
|
||||
if (imgs.length + sectionImgs.length > maxImages) {
|
||||
if (imgCount + sectionImgs.length > maxImages) {
|
||||
// TRUE cap: keep the sections already selected, leave this and every later
|
||||
// section as normal text in the live remainder. Do NOT collapse nothing.
|
||||
// section (and the pin, if not yet reached) as normal text in the remainder.
|
||||
break;
|
||||
}
|
||||
for (const img of sectionImgs) imgs.push(img);
|
||||
rendered.push({ s, e, imgs: sectionImgs });
|
||||
imgCount += sectionImgs.length;
|
||||
collapseEnd = e;
|
||||
}
|
||||
if (imgs.length === 0) {
|
||||
// The pin is "consumed" (emitted as text inside the synthetic) only once we have
|
||||
// collapsed PAST it. If the image cap stopped us before the pin, it survives as a
|
||||
// native user message in the untouched remainder — still legible, no work lost.
|
||||
const pinConsumed = pinIdx >= pp && collapseEnd > pinIdx;
|
||||
const imagesBefore: RenderedImage[] = [];
|
||||
const imagesAfter: RenderedImage[] = [];
|
||||
for (const r of rendered) {
|
||||
if (pinConsumed && r.s >= pinIdx + 1) imagesAfter.push(...r.imgs);
|
||||
else imagesBefore.push(...r.imgs);
|
||||
}
|
||||
if (imagesBefore.length === 0 && imagesAfter.length === 0) {
|
||||
// First section alone exceeded the cap (or cap <= 0). Fall back to text.
|
||||
return { ...base, reason: 'too_many_images', collapsedChars: text.length };
|
||||
}
|
||||
// The collapsed transcript / o200k baseline reflects ONLY what we imaged.
|
||||
const collapsedText = turns
|
||||
.slice(pp, collapseEnd)
|
||||
.map((t) => t.text)
|
||||
.filter((s) => s && s.length > 0)
|
||||
.join('\n\n');
|
||||
const pinText = pinConsumed ? turns[pinIdx]!.userText : undefined;
|
||||
// The collapsed transcript / o200k baseline reflects ONLY what we imaged — the
|
||||
// pin, when consumed, is text and is excluded from the imaged baseline.
|
||||
const collapsedText = joinTurns(turns, pp, collapseEnd, pinConsumed ? pinIdx : -1);
|
||||
const droppedCodepoints = new Map<number, number>();
|
||||
let droppedChars = 0;
|
||||
for (const img of imgs) {
|
||||
for (const img of [...imagesBefore, ...imagesAfter]) {
|
||||
droppedChars += img.droppedChars;
|
||||
for (const [cp, n] of img.droppedCodepoints) {
|
||||
droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
|
||||
}
|
||||
}
|
||||
return {
|
||||
images: imgs,
|
||||
images: imagesBefore,
|
||||
imagesAfter,
|
||||
pinText,
|
||||
text: collapsedText,
|
||||
start: pp,
|
||||
endExclusive: collapseEnd,
|
||||
collapsedTurns: collapseEnd - pp,
|
||||
collapsedTurns: collapseEnd - pp - (pinConsumed ? 1 : 0),
|
||||
collapsedChars: collapsedText.length,
|
||||
droppedChars,
|
||||
droppedCodepoints,
|
||||
@@ -403,7 +492,13 @@ function responsesItemToTurn(item: unknown, idx: number): HistoryTurn {
|
||||
// from turn 60 instead of resurfacing the salient opening turn. Stable per item →
|
||||
// cache-safe (mirrors src/core/history.ts). Tool turns stay unindexed (not mistakable
|
||||
// for a live request); the index rides the conversational role tags.
|
||||
return { text: `<${tag} t="${idx}">\n${body}\n</${tag}>`, openIds: [], closeIds: [], opaque: false };
|
||||
return {
|
||||
text: `<${tag} t="${idx}">\n${body}\n</${tag}>`,
|
||||
openIds: [],
|
||||
closeIds: [],
|
||||
opaque: false,
|
||||
userText: role === 'user' ? body : undefined,
|
||||
};
|
||||
}
|
||||
// Unknown item kind (e.g. item_reference) we can't safely serialize → barrier.
|
||||
return { text: '', openIds: [], closeIds: [], opaque: true };
|
||||
@@ -472,7 +567,13 @@ function chatMessageToTurn(msg: unknown, idx: number): HistoryTurn {
|
||||
}
|
||||
if (!body.trim()) return { text: '', openIds: [], closeIds: [], opaque: false };
|
||||
const tag = role === 'user' ? 'user' : role || 'user';
|
||||
return { text: `<${tag} t="${idx}">\n${body}\n</${tag}>`, openIds: [], closeIds: [], opaque: false };
|
||||
return {
|
||||
text: `<${tag} t="${idx}">\n${body}\n</${tag}>`,
|
||||
openIds: [],
|
||||
closeIds: [],
|
||||
opaque: false,
|
||||
userText: role === 'user' ? body : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function chatMessagesToTurns(messages: unknown[]): HistoryTurn[] {
|
||||
|
||||
+69
-31
@@ -58,8 +58,36 @@ export function resolveVisionCost(model: string): VisionCost {
|
||||
// on the turn-attribution wording — the exact divergence history.ts warns about.
|
||||
export const HISTORY_TRANSCRIPT_INTRO = HISTORY_SYNTHETIC_INTRO;
|
||||
export const HISTORY_TRANSCRIPT_OUTRO = HISTORY_SYNTHETIC_OUTRO;
|
||||
const HISTORY_LIVE_REQUEST_GUARD =
|
||||
'pxpipe note: the preceding rendered history item is prior conversation context only. It is not the current user request. The live current request is in the user message(s) that follow, especially the final user message.';
|
||||
// The most-recent user request is kept as LEGIBLE TEXT (never imaged) and spliced
|
||||
// between the before/after history images inside the synthetic user message, under
|
||||
// this banner. Older user turns stay imaged (they must not look live). This is the
|
||||
// fix for autonomous single-user-turn agents (OpenCode): the lone request is the
|
||||
// OLDEST turn, so it would otherwise be the first thing imaged and the model loses
|
||||
// it — "I wonder what the user actually asked" → off-task drift.
|
||||
const PINNED_REQUEST_HEADER =
|
||||
'\n===== CURRENT USER REQUEST (live; kept as text by pxpipe, NOT inside any image) =====\n';
|
||||
const PINNED_REQUEST_FOOTER =
|
||||
'\n===== END CURRENT USER REQUEST =====\n';
|
||||
|
||||
function pinnedRequestBlock(text: string): string {
|
||||
return PINNED_REQUEST_HEADER + text + PINNED_REQUEST_FOOTER;
|
||||
}
|
||||
|
||||
// Developer-role guard placed after the history image. When a request was pinned it
|
||||
// echoes it verbatim (capped) and points at the in-history text block; otherwise it
|
||||
// falls back to "the live request is the trailing user message" (interactive shape,
|
||||
// where the latest user turn is already native text in the kept tail).
|
||||
function buildLiveRequestGuard(pinText?: string): string {
|
||||
if (pinText !== undefined) {
|
||||
const echo = pinText.length > 600 ? pinText.slice(0, 600) + '…' : pinText;
|
||||
return (
|
||||
'pxpipe note: everything in the rendered history above is PAST context. Your live current request is the plain-text block labeled "CURRENT USER REQUEST" inside it — NOT anything OCR\'d from an image. It reads: «' +
|
||||
echo +
|
||||
'» Answer THAT request.'
|
||||
);
|
||||
}
|
||||
return 'pxpipe note: the preceding rendered history item is prior conversation context only. It is not the current user request. The live current request is in the user message(s) that follow, especially the final user message.';
|
||||
}
|
||||
|
||||
export function openAIVisionTokens(model: string, w: number, h: number): number {
|
||||
const c = resolveVisionCost(model);
|
||||
@@ -490,28 +518,30 @@ function foldGptHistory(
|
||||
model: string,
|
||||
plan: GptCollapsePlan,
|
||||
): void {
|
||||
if (plan.images.length === 0) {
|
||||
// A pin can split the collapse into before/after image groups — account for both.
|
||||
const allImages = [...plan.images, ...plan.imagesAfter];
|
||||
if (allImages.length === 0) {
|
||||
if (plan.reason) info.historyReason = plan.reason;
|
||||
if (plan.collapsedChars > 0) info.historyTextChars = plan.collapsedChars;
|
||||
return;
|
||||
}
|
||||
info.imageTokens = (info.imageTokens ?? 0) + gptImageTokens(model, plan.images);
|
||||
info.imageTokens = (info.imageTokens ?? 0) + gptImageTokens(model, allImages);
|
||||
// o200k token value of the collapsed transcript (what it cost as plain text).
|
||||
info.baselineImagedTokens = (info.baselineImagedTokens ?? 0) + gptTextTokens(plan.text);
|
||||
info.imageCount = (info.imageCount ?? 0) + plan.images.length;
|
||||
for (const img of plan.images) {
|
||||
info.imageCount = (info.imageCount ?? 0) + allImages.length;
|
||||
for (const img of allImages) {
|
||||
info.imageBytes = (info.imageBytes ?? 0) + img.png.length;
|
||||
info.imagePixels = (info.imagePixels ?? 0) + img.width * img.height;
|
||||
}
|
||||
info.imagePngs = [...(info.imagePngs ?? []), ...plan.images.map((i) => i.png)];
|
||||
info.imagePngs = [...(info.imagePngs ?? []), ...allImages.map((i) => i.png)];
|
||||
info.imageDims = [
|
||||
...(info.imageDims ?? []),
|
||||
...plan.images.map((i) => ({ width: i.width, height: i.height })),
|
||||
...allImages.map((i) => ({ width: i.width, height: i.height })),
|
||||
];
|
||||
if (plan.droppedChars > 0) info.droppedChars = (info.droppedChars ?? 0) + plan.droppedChars;
|
||||
info.collapsedTurns = plan.collapsedTurns;
|
||||
info.collapsedChars = plan.collapsedChars;
|
||||
info.collapsedImages = plan.images.length;
|
||||
info.collapsedImages = allImages.length;
|
||||
info.historyTextChars = plan.collapsedChars;
|
||||
info.historyReason = 'collapsed';
|
||||
info.bucketChars = { ...(info.bucketChars ?? {}), history: plan.collapsedChars };
|
||||
@@ -676,18 +706,21 @@ export async function transformOpenAIChatCompletions(
|
||||
maxHeightPx: o.gptHistory?.maxHeightPx ?? resolveGptProfile(req.model).maxHeightPx,
|
||||
});
|
||||
foldGptHistory(info, req.model, plan);
|
||||
if (plan.images.length > 0) {
|
||||
const synthetic: OpenAIChatMessage = {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: HISTORY_TRANSCRIPT_INTRO },
|
||||
...plan.images.map(openAIImagePart),
|
||||
{ type: 'text', text: HISTORY_TRANSCRIPT_OUTRO },
|
||||
],
|
||||
};
|
||||
const allImages = [...plan.images, ...plan.imagesAfter];
|
||||
if (allImages.length > 0) {
|
||||
// [intro][before-images][pinned request as TEXT][after-images][outro] —
|
||||
// chronological, with the live ask legible (not OCR-only) in its real slot.
|
||||
const content: OpenAIContentPart[] = [{ type: 'text', text: HISTORY_TRANSCRIPT_INTRO }];
|
||||
for (const img of plan.images) content.push(openAIImagePart(img));
|
||||
if (plan.pinText !== undefined) {
|
||||
content.push({ type: 'text', text: pinnedRequestBlock(plan.pinText) });
|
||||
for (const img of plan.imagesAfter) content.push(openAIImagePart(img));
|
||||
}
|
||||
content.push({ type: 'text', text: HISTORY_TRANSCRIPT_OUTRO });
|
||||
const synthetic: OpenAIChatMessage = { role: 'user', content };
|
||||
const guard: OpenAIChatMessage = {
|
||||
role: 'developer',
|
||||
content: HISTORY_LIVE_REQUEST_GUARD,
|
||||
content: buildLiveRequestGuard(plan.pinText),
|
||||
};
|
||||
req.messages = [
|
||||
...req.messages.slice(0, plan.start),
|
||||
@@ -696,7 +729,7 @@ export async function transformOpenAIChatCompletions(
|
||||
...req.messages.slice(plan.endExclusive),
|
||||
];
|
||||
info.historyImageSha = await sha8(
|
||||
plan.images.map((i) => bytesToBase64(i.png)).join(''),
|
||||
allImages.map((i) => bytesToBase64(i.png)).join(''),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -895,18 +928,23 @@ export async function transformOpenAIResponses(
|
||||
maxHeightPx: o.gptHistory?.maxHeightPx ?? resolveGptProfile(req.model).maxHeightPx,
|
||||
});
|
||||
foldGptHistory(info, req.model, plan);
|
||||
if (plan.images.length > 0) {
|
||||
const synthetic: ResponsesInputItem = {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: HISTORY_TRANSCRIPT_INTRO },
|
||||
...plan.images.map(responsesImagePart),
|
||||
{ type: 'input_text', text: HISTORY_TRANSCRIPT_OUTRO },
|
||||
],
|
||||
};
|
||||
const allImages = [...plan.images, ...plan.imagesAfter];
|
||||
if (allImages.length > 0) {
|
||||
// [intro][before-images][pinned request as TEXT][after-images][outro] —
|
||||
// chronological, with the live ask legible (not OCR-only) in its real slot.
|
||||
const content: ResponsesContentPart[] = [
|
||||
{ type: 'input_text', text: HISTORY_TRANSCRIPT_INTRO },
|
||||
];
|
||||
for (const img of plan.images) content.push(responsesImagePart(img));
|
||||
if (plan.pinText !== undefined) {
|
||||
content.push({ type: 'input_text', text: pinnedRequestBlock(plan.pinText) });
|
||||
for (const img of plan.imagesAfter) content.push(responsesImagePart(img));
|
||||
}
|
||||
content.push({ type: 'input_text', text: HISTORY_TRANSCRIPT_OUTRO });
|
||||
const synthetic: ResponsesInputItem = { role: 'user', content };
|
||||
const guard: ResponsesInputItem = {
|
||||
role: 'developer',
|
||||
content: HISTORY_LIVE_REQUEST_GUARD,
|
||||
content: buildLiveRequestGuard(plan.pinText),
|
||||
};
|
||||
req.input = [
|
||||
...inputItems.slice(0, plan.start),
|
||||
@@ -915,7 +953,7 @@ export async function transformOpenAIResponses(
|
||||
...inputItems.slice(plan.endExclusive),
|
||||
];
|
||||
info.historyImageSha = await sha8(
|
||||
plan.images.map((i) => bytesToBase64(i.png)).join(''),
|
||||
allImages.map((i) => bytesToBase64(i.png)).join(''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,6 +506,113 @@ describe('transformOpenAIChatCompletions — history collapse', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Autonomous agent shape (OpenCode / gpt-5.5): ONE human request, then a long
|
||||
// run of assistant + tool turns and NO further user turns. The lone request is the
|
||||
// OLDEST turn, so without pinning it is the first thing imaged and the model loses
|
||||
// it ("I wonder what the user actually asked" → off-task drift). The pin keeps the
|
||||
// most-recent (here: only) user turn as legible text while the work still images.
|
||||
function buildAutonomousResponses(turns: number): Array<Record<string, unknown>> {
|
||||
const items: Array<Record<string, unknown>> = [
|
||||
{ role: 'user', content: `${LIVE_PROMPT_MARKER} `.repeat(40) },
|
||||
];
|
||||
for (let i = 0; i < turns; i++) {
|
||||
const id = `call_${i}`;
|
||||
items.push({ role: 'assistant', content: `Working on step ${i}. `.repeat(30) });
|
||||
items.push({ type: 'function_call', call_id: id, name: 'read', arguments: `{"path":"f${i}"}` });
|
||||
items.push({ type: 'function_call_output', call_id: id, output: `result ${i} `.repeat(50) });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildAutonomousChat(turns: number): Array<Record<string, unknown>> {
|
||||
const msgs: Array<Record<string, unknown>> = [
|
||||
{ role: 'system', content: BIG_SLAB },
|
||||
{ role: 'user', content: `${LIVE_PROMPT_MARKER} `.repeat(40) },
|
||||
];
|
||||
for (let i = 0; i < turns; i++) {
|
||||
const id = `call_${i}`;
|
||||
msgs.push({
|
||||
role: 'assistant',
|
||||
content: `Working on step ${i}. `.repeat(30),
|
||||
tool_calls: [{ id, type: 'function', function: { name: 'read', arguments: `{"path":"f${i}"}` } }],
|
||||
});
|
||||
msgs.push({ role: 'tool', tool_call_id: id, content: `result ${i} `.repeat(50) });
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
describe('GPT history collapse — pins the live request as text (autonomous shape)', () => {
|
||||
it('Responses: lone request kept as legible text + echoed in the guard, work imaged', async () => {
|
||||
const body = enc.encode(JSON.stringify({
|
||||
model: 'gpt-5.6',
|
||||
instructions: BIG_SLAB,
|
||||
input: buildAutonomousResponses(24),
|
||||
}));
|
||||
const result = await transformOpenAIResponses(body, { charsPerToken: 1, minCompressChars: 1 });
|
||||
expect(result.info.historyReason).toBe('collapsed');
|
||||
expect(result.info.collapsedImages ?? 0).toBeGreaterThan(0);
|
||||
|
||||
const out = JSON.parse(dec.decode(result.body)) as { input: Array<Record<string, unknown>> };
|
||||
const serialized = JSON.stringify(out.input);
|
||||
// The request survives as LEGIBLE TEXT (not OCR-only) under the pin banner.
|
||||
expect(serialized).toContain('CURRENT USER REQUEST');
|
||||
expect(serialized).toContain(LIVE_PROMPT_MARKER);
|
||||
// The synthetic HISTORY item (not the slab) carries the pinned text + images.
|
||||
const hist = out.input.find((it) => {
|
||||
const c = (it as { content?: unknown }).content;
|
||||
return (
|
||||
Array.isArray(c) &&
|
||||
c.some((p) => (p as { type?: string }).type === 'input_image') &&
|
||||
c.some((p) => (p as { text?: string }).text?.includes('attribute every turn strictly by its tag'))
|
||||
);
|
||||
}) as { content: Array<{ type: string; text?: string }> };
|
||||
expect(hist).toBeDefined();
|
||||
expect(hist.content.some((p) => p.type === 'input_text' && p.text?.includes(LIVE_PROMPT_MARKER))).toBe(true);
|
||||
// The developer guard echoes the request verbatim.
|
||||
const dev = out.input.find((it) => (it as { role?: string }).role === 'developer');
|
||||
expect(JSON.stringify(dev)).toContain(LIVE_PROMPT_MARKER);
|
||||
});
|
||||
|
||||
it('Chat: lone request kept as legible text + echoed in the guard, work imaged', async () => {
|
||||
const body = enc.encode(JSON.stringify({
|
||||
model: 'gpt-5.6',
|
||||
messages: buildAutonomousChat(24),
|
||||
}));
|
||||
const result = await transformOpenAIChatCompletions(body, { charsPerToken: 1, minCompressChars: 1 });
|
||||
expect(result.info.historyReason).toBe('collapsed');
|
||||
expect(result.info.collapsedImages ?? 0).toBeGreaterThan(0);
|
||||
|
||||
const out = JSON.parse(dec.decode(result.body)) as { messages: Array<Record<string, unknown>> };
|
||||
const serialized = JSON.stringify(out.messages);
|
||||
expect(serialized).toContain('CURRENT USER REQUEST');
|
||||
expect(serialized).toContain(LIVE_PROMPT_MARKER);
|
||||
const hist = out.messages.find((m) => {
|
||||
const c = (m as { content?: unknown }).content;
|
||||
return (
|
||||
Array.isArray(c) &&
|
||||
c.some((p) => (p as { type?: string }).type === 'image_url') &&
|
||||
c.some((p) => (p as { text?: string }).text?.includes('attribute every turn strictly by its tag'))
|
||||
);
|
||||
}) as { content: Array<{ type: string; text?: string }> };
|
||||
expect(hist).toBeDefined();
|
||||
expect(hist.content.some((p) => p.type === 'text' && p.text?.includes(LIVE_PROMPT_MARKER))).toBe(true);
|
||||
const dev = out.messages.find((m) => (m as { role?: string }).role === 'developer');
|
||||
expect(JSON.stringify(dev)).toContain(LIVE_PROMPT_MARKER);
|
||||
});
|
||||
|
||||
it('Responses: byte-stable history image sha across identical autonomous requests', async () => {
|
||||
const make = () => enc.encode(JSON.stringify({
|
||||
model: 'gpt-5.6',
|
||||
instructions: BIG_SLAB,
|
||||
input: buildAutonomousResponses(24),
|
||||
}));
|
||||
const a = await transformOpenAIResponses(make(), { charsPerToken: 1, minCompressChars: 1 });
|
||||
const b = await transformOpenAIResponses(make(), { charsPerToken: 1, minCompressChars: 1 });
|
||||
expect(a.info.historyImageSha).toBeDefined();
|
||||
expect(a.info.historyImageSha).toBe(b.info.historyImageSha);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Vision cost: gpt-5.x FLAGSHIP patch model (multiplier 1.0, original detail) ──
|
||||
// Per OpenAI docs (patch tokenization): flagship gpt-5.4/5.5/5.6 have NO listed
|
||||
// multiplier (= 1.0); the 1.62/2.46 values are mini/nano ONLY. And `detail:original`
|
||||
|
||||
@@ -228,6 +228,126 @@ describe('planGptCollapse — reflow (↵ packing)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('planGptCollapse — pin the latest user request as text', () => {
|
||||
// Turns with REAL user requests (userText set) at the given indices; the rest is
|
||||
// assistant work. Varied words so o200k token counts clear the gates realistically.
|
||||
function turnsWithUser(n: number, userIndices: number[], reps = 60): HistoryTurn[] {
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const isUser = userIndices.includes(i);
|
||||
const body =
|
||||
(isUser ? `USER REQUEST ${i} ` : `assistant work ${i} `) +
|
||||
`alpha beta gamma delta epsilon ${i} `.repeat(reps);
|
||||
const tag = isUser ? 'user' : 'assistant';
|
||||
return {
|
||||
text: `<${tag} t="${i}">\n${body}\n</${tag}>`,
|
||||
openIds: [],
|
||||
closeIds: [],
|
||||
opaque: false,
|
||||
userText: isUser ? body : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
it('autonomous (single user turn at the front): request kept as TEXT, work imaged', async () => {
|
||||
const turns = turnsWithUser(40, [0]);
|
||||
const plan = await planGptCollapse(turns, 0, yes);
|
||||
expect(plan.pinText).toBeDefined();
|
||||
expect(plan.pinText).toContain('USER REQUEST 0');
|
||||
// Nothing before the pin (it is the first collapsible turn); work imaged after it.
|
||||
expect(plan.images).toHaveLength(0);
|
||||
expect(plan.imagesAfter.length).toBeGreaterThan(0);
|
||||
expect(plan.start).toBe(0);
|
||||
// The pinned request is NOT part of the imaged baseline (it stays text).
|
||||
expect(plan.text).not.toContain('USER REQUEST 0');
|
||||
});
|
||||
|
||||
it('interactive: pins the LATEST user turn, images history BEFORE and AFTER it', async () => {
|
||||
const turns = turnsWithUser(40, [0, 20]);
|
||||
const plan = await planGptCollapse(turns, 0, yes, { collapseChunk: 0, sectionTokens: 100, maxImages: 100 });
|
||||
// The newest user turn in range (20) is pinned; the older one (0) stays imaged.
|
||||
expect(plan.pinText).toContain('USER REQUEST 20');
|
||||
expect(plan.pinText).not.toContain('USER REQUEST 0');
|
||||
expect(plan.text).toContain('USER REQUEST 0'); // older user turn IS imaged
|
||||
expect(plan.text).not.toContain('USER REQUEST 20'); // pinned turn is NOT imaged
|
||||
// History stays imaged on BOTH sides of the live request (no compression drop).
|
||||
expect(plan.images.length).toBeGreaterThan(0);
|
||||
expect(plan.imagesAfter.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not orphan a tool call when sealing the section around the pin', async () => {
|
||||
const turns = turnsWithUser(40, [0, 20]);
|
||||
turns[10] = { text: '[tool_use a]\n{}', openIds: ['a'], closeIds: [], opaque: false };
|
||||
turns[11] = { text: '[tool_result]\nok', openIds: [], closeIds: ['a'], opaque: false };
|
||||
turns[25] = { text: '[tool_use b]\n{}', openIds: ['b'], closeIds: [], opaque: false };
|
||||
turns[26] = { text: '[tool_result]\nok', openIds: [], closeIds: ['b'], opaque: false };
|
||||
const plan = await planGptCollapse(turns, 0, yes, { collapseChunk: 0, sectionTokens: 100, maxImages: 100 });
|
||||
expect(plan.pinText).toContain('USER REQUEST 20');
|
||||
// Every opened call id in the imaged range (pin excluded) must close in it.
|
||||
const open = new Set<string>();
|
||||
for (let i = plan.start; i < plan.endExclusive; i++) {
|
||||
for (const id of turns[i]!.openIds) open.add(id);
|
||||
for (const id of turns[i]!.closeIds) open.delete(id);
|
||||
}
|
||||
expect(open.size).toBe(0);
|
||||
});
|
||||
|
||||
it('cap stopping BEFORE the pin leaves the request native (not consumed)', async () => {
|
||||
const turns = turnsWithUser(40, [0, 30]); // latest user request sits deep at 30
|
||||
const plan = await planGptCollapse(turns, 0, yes, { collapseChunk: 0, sectionTokens: 100, maxImages: 1 });
|
||||
expect(plan.images.length).toBe(1); // cap honored
|
||||
expect(plan.imagesAfter).toHaveLength(0);
|
||||
expect(plan.pinText).toBeUndefined(); // never reached the pin → it stays a native message
|
||||
expect(plan.endExclusive).toBeLessThanOrEqual(30);
|
||||
});
|
||||
|
||||
it('pin is cache-stable: same request + sealed window across appended turns', async () => {
|
||||
// collapseChunk 10 snaps L=40 and L=41 to the SAME cutoff (30) → identical plan.
|
||||
const base = turnsWithUser(60, [0]);
|
||||
const a = await planGptCollapse(base.slice(0, 40), 0, yes, { collapseChunk: 10 });
|
||||
const b = await planGptCollapse(base.slice(0, 41), 0, yes, { collapseChunk: 10 });
|
||||
expect(a.pinText).toBe(b.pinText);
|
||||
expect(a.text).toBe(b.text);
|
||||
expect(a.endExclusive).toBe(b.endExclusive);
|
||||
});
|
||||
|
||||
it('no pin when the only user turn is in the kept tail (interactive shape)', async () => {
|
||||
// User turn at 38 is within keepTail (last 6 of 40) → already native text, nothing to pin.
|
||||
const turns = turnsWithUser(40, [38]);
|
||||
const plan = await planGptCollapse(turns, 0, yes);
|
||||
expect(plan.pinText).toBeUndefined();
|
||||
expect(plan.imagesAfter).toHaveLength(0);
|
||||
expect(plan.images.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does NOT pin an older in-range user turn when the LATEST user turn is in the tail', async () => {
|
||||
// Ordinary interactive shape: latest request (37) is in the kept tail (native text).
|
||||
// Pinning the older in-range turn (20) would make the pin migrate across collapse
|
||||
// boundaries and re-image frozen history — so nothing is pinned, older user turns
|
||||
// (0, 20) stay imaged. Regression guard for the cache-churn finding.
|
||||
const turns = turnsWithUser(40, [0, 20, 37]);
|
||||
const plan = await planGptCollapse(turns, 0, yes, { collapseChunk: 0, sectionTokens: 100, maxImages: 100 });
|
||||
expect(plan.pinText).toBeUndefined();
|
||||
expect(plan.imagesAfter).toHaveLength(0);
|
||||
expect(plan.text).toContain('USER REQUEST 0');
|
||||
expect(plan.text).toContain('USER REQUEST 20');
|
||||
});
|
||||
|
||||
it('pin position is stable as the current turn\'s tool loop grows (no re-image churn)', async () => {
|
||||
// "Long current turn": users at 0 and 20, then a growing tool loop after 20 with no
|
||||
// new user turn. The pin stays at 20 (fixed) as work is appended, so before-pin
|
||||
// content and the request stay byte-identical (frozen) while after-pin grows.
|
||||
const base = turnsWithUser(80, [0, 20]);
|
||||
const a = await planGptCollapse(base.slice(0, 50), 0, yes, { collapseChunk: 10 });
|
||||
const b = await planGptCollapse(base.slice(0, 51), 0, yes, { collapseChunk: 10 });
|
||||
expect(a.pinText).toBe(b.pinText);
|
||||
expect(a.pinText).toContain('USER REQUEST 20');
|
||||
expect(a.start).toBe(b.start);
|
||||
// Both still image history on each side of the pinned request.
|
||||
expect(a.images.length).toBeGreaterThan(0);
|
||||
expect(a.imagesAfter.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planGptCollapse — chunk-snapped boundary (cache byte-stability)', () => {
|
||||
it('seals the same token sections as turns are appended (byte-stable window)', async () => {
|
||||
// 128 o200k tokens/turn, sectionTokens 2000 → a section seals every ~16 turns.
|
||||
|
||||
Reference in New Issue
Block a user