From b2aed90711a5ee9c8354681a601b3ff0d68ef15a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:58:02 -0300 Subject: [PATCH] fix(sse): normalize array user content for Command Code to avoid upstream 400 (#5166) (#5174) Integrated into release/v3.8.39. Normalize array user content for Command Code (#5166, user-array/400 symptom); 4/4 tests pass on merge result. --- CHANGELOG.md | 4 + open-sse/executors/commandCode.ts | 2 +- .../unit/command-code-user-array-5166.test.ts | 195 ++++++++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 tests/unit/command-code-user-array-5166.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ffef0c5587..bf2391745e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ _In development — bullets added per PR; finalized at release._ +### 🔧 Bug Fixes + +- **fix(sse): normalize array user-message content in the Command Code executor to prevent upstream 400** — when a client sends a user turn whose `content` is an array of content parts (e.g. `[{type:"text",text:"…"}, …]`), the raw array was forwarded verbatim to the Command Code upstream, which requires `messages[N].content` for the `user` role to be a plain string — resulting in `expected string, received array` / HTTP 400 on DeepSeek V4-Pro and other Command Code models. The user branch of `convertMessages` now calls `normalizeContentText()` (already used by system, assistant, and tool branches) so multi-part user content is joined to a string before dispatch. Partially addresses ([#5166](https://github.com/diegosouzapw/OmniRoute/issues/5166)); the 0-output-token symptom on reasoning-only models is tracked separately. + --- ## [3.8.38] — 2026-06-27 diff --git a/open-sse/executors/commandCode.ts b/open-sse/executors/commandCode.ts index dbb3115252..76fd7af3b8 100644 --- a/open-sse/executors/commandCode.ts +++ b/open-sse/executors/commandCode.ts @@ -95,7 +95,7 @@ function convertMessages(messages: unknown): { system: string; messages: unknown } if (role === "user") { - out.push({ role: "user", content: message.content ?? "" }); + out.push({ role: "user", content: normalizeContentText(message.content) }); continue; } diff --git a/tests/unit/command-code-user-array-5166.test.ts b/tests/unit/command-code-user-array-5166.test.ts new file mode 100644 index 0000000000..01d20a15ff --- /dev/null +++ b/tests/unit/command-code-user-array-5166.test.ts @@ -0,0 +1,195 @@ +/** + * Regression test for #5166 (user-content-array 400 on Command Code / deepseek-v4-pro). + * + * When a client sends a user message whose `content` is an array of content parts + * (e.g. [{type:"text",text:"Hello"},{type:"text",text:"World"}]), the raw array + * must NOT reach the Command Code upstream — it requires user content to be a plain + * string. The executor must normalise the array to a string before posting. + * + * NOTE: this file covers ONLY the user-content-array/400 symptom of #5166. + * The 0-output-token symptom on mimo-v2.5-pro (reasoning-only models) is tracked + * separately and is NOT addressed here. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-cmd-code-user-array-5166-") +); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { getExecutor } = await import("../../open-sse/executors/index.ts"); +const core = await import("../../src/lib/db/core.ts"); + +const originalFetch = globalThis.fetch; + +function commandCodeStream(lines: unknown[]) { + const text = lines.map((l) => JSON.stringify(l)).join("\n") + "\n"; + return new Response(text, { status: 200, headers: { "Content-Type": "application/x-ndjson" } }); +} + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +// ── helpers ──────────────────────────────────────────────────────────────────── + +type FetchCall = { url: string; init: Record; body: Record }; + +function captureFetch(response: Response) { + const calls: FetchCall[] = []; + globalThis.fetch = async (url, init: RequestInit = {}) => { + calls.push({ url: String(url), init: init as Record, body: JSON.parse(String(init.body)) }); + return response; + }; + return calls; +} + +// ── failing tests (before fix, user content is the raw array) ────────────── + +test( + "#5166 user message with multi-part array content is flattened to a string (#5166)", + async () => { + const calls = captureFetch( + commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]) + ); + + await getExecutor("command-code").execute({ + model: "deepseek/deepseek-v4-pro", + stream: false, + credentials: { apiKey: "cc_test_key" }, + body: { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Hello" }, + { type: "text", text: "World" }, + ], + }, + ], + }, + }); + + const posted = calls[0].body; + const userMsg = (posted.params as Record).messages[0] as Record< + string, + unknown + >; + + // Must be a string — never an array — otherwise Command Code's upstream returns 400. + assert.equal( + typeof userMsg.content, + "string", + `user message content must be a string, got ${typeof userMsg.content}` + ); + // Joined text parts with "\n" + assert.equal(userMsg.content, "Hello\nWorld"); + } +); + +test( + "#5166 user message with single text-part array is flattened to a plain string", + async () => { + const calls = captureFetch( + commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]) + ); + + await getExecutor("command-code").execute({ + model: "deepseek/deepseek-v4-pro", + stream: false, + credentials: { apiKey: "cc_test_key" }, + body: { + messages: [ + { + role: "user", + content: [{ type: "text", text: "Hi there" }], + }, + ], + }, + }); + + const posted = calls[0].body; + const userMsg = (posted.params as Record).messages[0] as Record< + string, + unknown + >; + assert.equal(typeof userMsg.content, "string"); + assert.equal(userMsg.content, "Hi there"); + } +); + +test( + "#5166 user message with plain string content passes through unchanged (no regression)", + async () => { + const calls = captureFetch( + commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]) + ); + + await getExecutor("command-code").execute({ + model: "deepseek/deepseek-v4-pro", + stream: false, + credentials: { apiKey: "cc_test_key" }, + body: { + messages: [ + { + role: "user", + content: "Plain string message", + }, + ], + }, + }); + + const posted = calls[0].body; + const userMsg = (posted.params as Record).messages[0] as Record< + string, + unknown + >; + assert.equal(typeof userMsg.content, "string"); + assert.equal(userMsg.content, "Plain string message"); + } +); + +test( + "#5166 user message with mixed parts (text + image_url) keeps only text parts", + async () => { + const calls = captureFetch( + commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]) + ); + + await getExecutor("command-code").execute({ + model: "deepseek/deepseek-v4-pro", + stream: false, + credentials: { apiKey: "cc_test_key" }, + body: { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Describe this:" }, + { type: "image_url", image_url: { url: "https://example.com/img.png" } }, + ], + }, + ], + }, + }); + + const posted = calls[0].body; + const userMsg = (posted.params as Record).messages[0] as Record< + string, + unknown + >; + assert.equal(typeof userMsg.content, "string"); + // Only text parts extracted; image_url part is dropped (not a "text" type) + assert.equal(userMsg.content, "Describe this:"); + } +);