From 60f25eff98b9c36270d07d03adee2bf3942f464c Mon Sep 17 00:00:00 2001 From: ggdayup Date: Sun, 23 Aug 2026 08:53:08 +0800 Subject: [PATCH] fix(sse): merge purify_history compression notice into the leading system message (#11113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on the combined batch board: purify-system-first suite 4/4, typecheck clean. Pre-merge: file-size baseline gained a frozen entry for contextManager.ts at 1001 (+1, this PR's merge-into-leading-system branch) with a dated annotation — the gate caps unlisted files at 1000. Producer side of the live-confirmed TokenRouter 400 class: no internal path emits a mid-array system message anymore. Thank you @ggdayup — the call-log evidence made this airtight! --- config/quality/file-size-baseline.json | 2 + open-sse/services/contextManager.ts | 32 ++++++- ...ontext-manager-purify-system-first.test.ts | 91 +++++++++++++++++++ 3 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 tests/unit/context-manager-purify-system-first.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index b7f40cebfa..f803789ffd 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -388,6 +388,8 @@ "open-sse/services/claudeCodeCompatible.ts": 1563, "open-sse/services/combo.ts": 4742, "open-sse/services/compression/strategySelector.ts": 1379, + "open-sse/services/contextManager.ts": 1001, + "_rebaseline_2026_08_22_11113_purify_system_first": "PR #11113 (ggdayup) own growth: open-sse/services/contextManager.ts 1000->1001 (+1, purifyHistory merges the compression notice into the leading system message instead of splicing a second one mid-array — live-confirmed TokenRouter 400s; the +1 is the merge-into-leading branch, not extractable). Covered by tests/unit/context-manager-purify-system-first.test.ts. Owner pre-authorized baseline bumps 2026-08-22.", "open-sse/services/rateLimitManager.ts": 1517, "open-sse/translator/response/openai-responses.ts": 1652, "open-sse/utils/cursorAgentProtobuf.ts": 1956, diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index a2d678f107..6fe9e94c8e 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -669,13 +669,35 @@ function purifyHistory(messages: Record[], targetTokens: number result = fixToolPairs(result); result = stripTrailingAssistantOrphanToolUse(result); - // Add summary of dropped messages + // Add summary of dropped messages. Merge the notice INTO the leading + // system/developer message instead of splicing a second system-role message + // mid-array: strict gateways (TokenRouter confirmed live 2026-08-22, see the + // PROVIDERS_SYSTEM_MUST_BE_FIRST list in src/lib/memory/injection.ts) reject + // any system message at index > 0 with HTTP 400 "System message must be at + // the beginning". When there is no leading system message, prepend one -- + // index 0 is accepted by every provider (same slot the old splice used when + // system[] was empty). if (keep < nonSystem.length) { const dropped = nonSystem.length - keep; - result.splice(system.length, 0, { - role: "system", - content: `[Context compressed: ${dropped} earlier messages removed to fit context window]`, - }); + const droppedNotice = `[Context compressed: ${dropped} earlier messages removed to fit context window]`; + const first = result[0]; + if (first && (first.role === "system" || first.role === "developer")) { + if (typeof first.content === "string") { + result[0] = { + ...first, + content: first.content ? `${droppedNotice}\n${first.content}` : droppedNotice, + }; + } else if (Array.isArray(first.content)) { + result[0] = { + ...first, + content: [{ type: "text", text: droppedNotice }, ...(first.content as unknown[])], + }; + } else { + result[0] = { ...first, content: droppedNotice }; + } + } else { + result.unshift({ role: "system", content: droppedNotice }); + } } return result; diff --git a/tests/unit/context-manager-purify-system-first.test.ts b/tests/unit/context-manager-purify-system-first.test.ts new file mode 100644 index 0000000000..51c658231d --- /dev/null +++ b/tests/unit/context-manager-purify-system-first.test.ts @@ -0,0 +1,91 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { compressContext } from "../../open-sse/services/contextManager.ts"; + +/** + * Plan-A root fix for the 2026-08-22 tokenrouter 400s. purifyHistory() used to + * splice the `[Context compressed: …]` notice as a SECOND system-role message at + * index system.length; strict gateways (TokenRouter, xiaomi-mimo/mimo) reject any + * system message at index > 0 with HTTP 400 "System message must be at the + * beginning". The notice must now merge into the leading system/developer + * message — or prepend a single system message when none exists — so the output + * never contains a system role after index 0, for ANY provider. + */ + +function bigTurn(n: number) { + return { role: "user", content: `turn ${n}: ${"x".repeat(4_000)}` }; +} + +function run(body: Record) { + // ~30k tokens of history vs a small target forces Layer-3 purify_history. + return compressContext(body, { maxTokens: 5_000, reserveTokens: 0 }); +} + +function systemIndices(messages: Array<{ role: string }>) { + return messages.map((m, i) => (m.role === "system" ? i : -1)).filter((i) => i >= 0); +} + +test("purify_history merges dropped-notice into existing leading system message", () => { + const body = { + model: "any-model", + messages: [ + { role: "system", content: "You are a helpful assistant." }, + ...Array.from({ length: 12 }, (_, i) => bigTurn(i)), + ], + }; + const result = run(body); + assert.equal(result.compressed, true); + const messages = (result.body as { messages: Array> }).messages; + assert.deepEqual(systemIndices(messages as Array<{ role: string }>).slice(1), []); + const first = messages[0]; + assert.equal(first.role, "system"); + const text = String(first.content); + assert.match(text, /Context compressed: \d+ earlier messages removed/); + assert.match(text, /You are a helpful assistant\./); +}); + +test("purify_history prepends a single system notice when no system message exists", () => { + const body = { + model: "any-model", + messages: Array.from({ length: 12 }, (_, i) => bigTurn(i)), + }; + const result = run(body); + assert.equal(result.compressed, true); + const messages = (result.body as { messages: Array> }).messages; + assert.deepEqual(systemIndices(messages as Array<{ role: string }>), [0]); + assert.match(String(messages[0].content), /Context compressed: \d+ earlier messages removed/); +}); + +test("purify_history merges into leading developer message without adding a second one", () => { + const body = { + model: "any-model", + messages: [ + { role: "developer", content: "dev instructions" }, + ...Array.from({ length: 12 }, (_, i) => bigTurn(i)), + ], + }; + const result = run(body); + assert.equal(result.compressed, true); + const messages = (result.body as { messages: Array> }).messages; + assert.deepEqual( + messages.filter((m) => m.role === "developer").length, + 1, + "exactly one developer message" + ); + assert.match(String(messages[0].content), /Context compressed: \d+ earlier messages removed/); + assert.match(String(messages[0].content), /dev instructions/); +}); + +test("no compression means no notice and untouched history", () => { + const body = { + model: "any-model", + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "hi" }, + ], + }; + const result = run(body); + assert.equal(result.compressed, false); + const messages = (result.body as { messages: unknown[] }).messages; + assert.equal(messages.length, 2); +});