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 <Kizuno18@users.noreply.github.com>
This commit is contained in:
Kizuno18
2026-08-20 23:46:03 -03:00
committed by GitHub
parent 374b387b35
commit 1d4c4cd7c9
2 changed files with 49 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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/);
});