From 1d4c4cd7c90b5314b9945e6d66fcd099d645cf39 Mon Sep 17 00:00:00 2001 From: Kizuno18 <110933270+Kizuno18@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:46:03 -0300 Subject: [PATCH] fix(sse): re-run strict system hoist after format translation (#10803) Re-runs hoistLeadingSystemMessage on the final outbound array at translateRequest's single return, instead of only pre-translation. claudeToOpenAI (and the Responses source path, which never ran the pre-translation hoist at all since `messages` doesn't exist yet there) re-introduces/normalizes a leading system message after the hoist already ran, so a strict provider (e.g. vLLM/Qwen3, xiaomi-mimo) could still receive a non-compliant array and 400 with "System message must be at the beginning." Validated live against a vLLM/Qwen3 endpoint (documented in the PR) plus in an isolated worktree boarded onto origin/release/v3.8.50 (0 conflicts, 2 files): - 39/39 focused tests pass (probe-7293-strict-system-hoist including the new Claude-source regression case, memory-system-first-6135, claude-system-role-cache-boundary, memory-cache-safe-injection). - check-file-size, check-changelog-integrity: OK. - typecheck:core: clean. - check-complexity / check-cognitive-complexity: OK, both under baseline. Co-authored-by: Kizuno18 --- open-sse/translator/index.ts | 13 +++++++ .../probe-7293-strict-system-hoist.test.ts | 36 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 501d2ffdbc..0e4dcc23ff 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -756,6 +756,19 @@ export function translateRequest( delete result[RESPONSES_STORE_MARKER]; } + // #7293 follow-up: the pre-translation hoist above normalizes the *source* + // message array, which a target translator can then undo. `claudeToOpenAI` + // pushes `body.system` as a fresh leading system message before appending the + // converted messages, so an already-hoisted system lands at index 1 again; + // a Responses-source request has no `messages` at all until translation, so + // the earlier call is a no-op for it. Re-run on the final outbound array — + // it is the only shape the upstream actually sees. Idempotent: same array + // reference for non-strict providers and already-compliant requests, so + // prompt-cache prefixes stay stable. + if (targetFormat === FORMATS.OPENAI && result.messages && Array.isArray(result.messages)) { + result.messages = hoistLeadingSystemMessage(result.messages, provider); + } + return result; } diff --git a/tests/unit/probe-7293-strict-system-hoist.test.ts b/tests/unit/probe-7293-strict-system-hoist.test.ts index da1bce3480..bb66a38774 100644 --- a/tests/unit/probe-7293-strict-system-hoist.test.ts +++ b/tests/unit/probe-7293-strict-system-hoist.test.ts @@ -142,3 +142,39 @@ test("#7293: already-compliant strict-provider request is a no-op (prompt-cache assert.deepEqual(result.messages, messages); }); + +test("#7293: Claude-source request keeps a single leading system message after claudeToOpenAI re-adds body.system", () => { + // Claude Code's real shape: a top-level `system` field AND a system-role + // message inside `messages`. claudeToOpenAI pushes body.system as the leading + // system message and then appends the converted messages, so hoisting before + // translation is not enough — the offender reappears at index 1. + const body = { + model: "mimo-v2.5", + system: [{ type: "text", text: "You are a coding assistant." }], + messages: [ + { role: "user", content: "hi" }, + { role: "system", content: "deferred tools list" }, + { role: "user", content: "go" }, + ], + }; + + const result = translateRequest( + FORMATS.CLAUDE, + FORMATS.OPENAI, + "mimo-v2.5", + body, + false, + null, + "xiaomi-mimo" + ); + + const outMessages = result.messages as Array<{ role: string; content: string }>; + const systemIndices = outMessages + .map((m, i) => (m.role === "system" ? i : -1)) + .filter((i) => i >= 0); + + assert.deepEqual(systemIndices, [0]); + // Merge, never drop: both the top-level system and the offender survive. + assert.match(outMessages[0].content, /You are a coding assistant\./); + assert.match(outMessages[0].content, /deferred tools list/); +});