diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index ef13454cd4..c778174458 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -305,7 +305,11 @@ export function compressContext( const targetTokens = Math.max(0, maxTokens - reserveTokens); let messages = [...body.messages]; - let currentTokens = estimateTokens(JSON.stringify(messages)); + // #8594: pass the structured messages array directly — estimateTokens walks it for + // inline base64 image blocks (#8368) and substitutes a bounded per-image estimate. + // JSON.stringify()-ing first forces the char/4 text path and mis-measures a ~500KB + // image as ~125k tokens, triggering needless compression / context loss. + let currentTokens = estimateTokens(messages); const stats = { original: currentTokens, layers: [] as { name: string; tokens: number }[] }; // Already fits @@ -315,7 +319,7 @@ export function compressContext( // Layer 1: Trim tool_result/tool messages messages = trimToolMessages(messages, 2000); // Max 2000 chars per tool result - currentTokens = estimateTokens(JSON.stringify(messages)); + currentTokens = estimateTokens(messages); // #8594: object-path keeps the #8368 image estimate stats.layers.push({ name: "trim_tools", tokens: currentTokens }); if (currentTokens <= targetTokens) { @@ -328,7 +332,7 @@ export function compressContext( // Layer 2: Compress structured thinking blocks (remove from non-last assistant messages) messages = compressThinking(messages); - currentTokens = estimateTokens(JSON.stringify(messages)); + currentTokens = estimateTokens(messages); // #8594: object-path keeps the #8368 image estimate stats.layers.push({ name: "compress_thinking", tokens: currentTokens }); if (currentTokens <= targetTokens) { @@ -341,7 +345,7 @@ export function compressContext( // Layer 3: Aggressive purification — drop oldest messages keeping system + last N pairs messages = purifyHistory(messages, targetTokens); - currentTokens = estimateTokens(JSON.stringify(messages)); + currentTokens = estimateTokens(messages); // #8594: object-path keeps the #8368 image estimate stats.layers.push({ name: "purify_history", tokens: currentTokens }); return { @@ -427,7 +431,9 @@ function purifyHistory(messages: Record[], targetTokens: number // orphan tool_results that Claude rejects ("tool_result without preceding tool_use"). candidate = fixToolPairs(candidate); candidate = stripTrailingAssistantOrphanToolUse(candidate); - const tokens = estimateTokens(JSON.stringify(candidate)); + // #8594: measure the candidate structure directly so image-bearing turns are not + // over-counted and pruned during the binary search. + const tokens = estimateTokens(candidate); if (tokens <= targetTokens) break; keep = Math.max(2, Math.floor(keep * 0.7)); // Drop 30% each iteration } diff --git a/tests/unit/8594-compress-image-token-stringify.test.ts b/tests/unit/8594-compress-image-token-stringify.test.ts new file mode 100644 index 0000000000..3603169807 --- /dev/null +++ b/tests/unit/8594-compress-image-token-stringify.test.ts @@ -0,0 +1,94 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { compressContext } from "../../open-sse/services/contextManager.ts"; + +// #8594 — six production call sites (compressContext layers + purifyHistory binary +// search + the combo.ts fallback threshold) pre-JSON.stringify the messages before +// handing them to estimateTokens(). Stringifying forces the char/4 text path and +// undoes the #8368 inline-base64-image bounded estimate, so an image-bearing request +// that fits comfortably is mis-measured as ~100× larger and compressed needlessly. + +function makeFakePngBase64(approxBytes: number): string { + return Buffer.alloc(approxBytes, 65).toString("base64"); +} + +test("#8594: compressContext does NOT compress a within-limit inline-image request", () => { + // ~500KB image => ~666KB base64. As raw text that is ~166k tokens (over the limit); + // as a bounded image estimate it is ~1.2k tokens (well under). maxTokens=20000 sits + // between the two, so the bug (stringify) trips compression and the fix does not. + const base64 = makeFakePngBase64(500_000); + const body = { + model: "gpt-image-vision", + messages: [ + { role: "system", content: "You are a helpful vision assistant." }, + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image_url", image_url: { url: `data:image/png;base64,${base64}` } }, + ], + }, + ], + }; + + const result = compressContext(body, { maxTokens: 20000, reserveTokens: 0 }); + + assert.equal( + result.compressed, + false, + `BUG #8594 reproduced: within-limit image request was compressed. ` + + `stats.original=${(result.stats as { original?: number }).original} tokens ` + + `(should be a bounded ~1.2k image estimate, not the ~166k base64-as-text value)` + ); + assert.ok( + (result.stats as { original: number }).original < 5000, + `expected bounded image-token estimate (<5000), got ${(result.stats as { original: number }).original}` + ); +}); + +test("#8594: purifyHistory keeps all image-bearing turns when they fit within the limit", () => { + // Multiple image messages that, measured correctly, fit under the limit. The bug + // makes the Layer-3 binary search over-estimate and prune turns; the fix preserves them. + const base64 = makeFakePngBase64(200_000); + const imageTurn = (n: number) => ({ + role: "user", + content: [ + { type: "text", text: `Image number ${n}` }, + { type: "image_url", image_url: { url: `data:image/png;base64,${base64}` } }, + ], + }); + const body = { + model: "gpt-image-vision", + messages: [ + { role: "system", content: "vision" }, + imageTurn(1), + { role: "assistant", content: "ok 1" }, + imageTurn(2), + { role: "assistant", content: "ok 2" }, + imageTurn(3), + { role: "assistant", content: "ok 3" }, + ], + }; + const originalCount = body.messages.length; + + const result = compressContext(body, { maxTokens: 20000, reserveTokens: 0 }); + + assert.equal(result.compressed, false, "within-limit image history must not be compressed"); + assert.equal( + (result.body as { messages: unknown[] }).messages.length, + originalCount, + "all image-bearing turns must be retained when they fit" + ); +}); + +test("#8594: control — an oversized text request is still compressed (no regression)", () => { + const body = { + model: "gpt-text", + messages: [ + { role: "system", content: "s" }, + { role: "user", content: "a".repeat(200_000) }, // ~50k tokens, over a 20k limit + ], + }; + const result = compressContext(body, { maxTokens: 20000, reserveTokens: 0 }); + assert.equal(result.compressed, true, "oversized text must still trigger compression"); +});