diff --git a/changelog.d/fixes/6927-qwen-web-content-array.md b/changelog.d/fixes/6927-qwen-web-content-array.md new file mode 100644 index 0000000000..5eff8b6ed9 --- /dev/null +++ b/changelog.d/fixes/6927-qwen-web-content-array.md @@ -0,0 +1 @@ +- fix(sse): Qwen Web executor no longer sends `[object Object]` when a message uses structured (array) content — the text parts are now flattened (#6927) diff --git a/open-sse/executors/qwen-web.ts b/open-sse/executors/qwen-web.ts index 485eb93758..bb812a10e7 100644 --- a/open-sse/executors/qwen-web.ts +++ b/open-sse/executors/qwen-web.ts @@ -255,11 +255,32 @@ export class QwenWebExecutor extends BaseExecutor { }; } + /** Flatten OpenAI-style content (string | Array<{type,text}>) into plain text. + * A bare String() on an array of content parts yields "[object Object]" — the + * serialization bug reported on the support mesh. */ + private contentToText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === "string") return part; + if (part && typeof part === "object") { + const p = part as { type?: unknown; text?: unknown }; + if (typeof p.text === "string") return p.text; + } + return ""; + }) + .filter(Boolean) + .join("\n"); + } + return content == null ? "" : String(content); + } + private foldMessages(messages: Array<{ role: string; content: unknown }>): string { let systemContent = ""; let userContent = ""; for (const m of messages) { - const text = String(m.content ?? ""); + const text = this.contentToText(m.content); if (m.role === "system") { systemContent += (systemContent ? "\n\n" : "") + text; } else if (m.role === "user") { diff --git a/stryker.conf.json b/stryker.conf.json index ffa4f9f17f..f944f3c27d 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -218,6 +218,7 @@ "tests/unit/quota-policy-generalization.test.ts", "tests/unit/quota-pool-log-route.test.ts", "tests/unit/quota-streaming-consumption-usd.test.ts", + "tests/unit/qwen-web-content-array-serialization.test.ts", "tests/unit/rate-limit-enhanced.test.ts", "tests/unit/rate-limit-manager.test.ts", "tests/unit/rate-limit-queue-timeout-lockout.test.ts", diff --git a/tests/unit/qwen-web-content-array-serialization.test.ts b/tests/unit/qwen-web-content-array-serialization.test.ts new file mode 100644 index 0000000000..3949f6484b --- /dev/null +++ b/tests/unit/qwen-web-content-array-serialization.test.ts @@ -0,0 +1,83 @@ +// Regression: Qwen Web executor folded structured (array) message content with a +// bare String(m.content), producing the literal "[object Object]" prompt instead of +// the real text (reported on the support mesh: "[[object][object]] serialisation error"). +// The executor must flatten OpenAI-style content parts into their text before sending. +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +const mod = await import("../../open-sse/executors/qwen-web.ts"); + +type FetchCall = { url: string; init: { method?: string; body?: string } }; +const realFetch = globalThis.fetch; + +function sseResponse(events: Array>): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const ev of events) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(ev)}\n\n`)); + } + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function chatCreatedResponse(id = "chat-arr"): Response { + return new Response(JSON.stringify({ success: true, data: { id } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("QwenWebExecutor — structured (array) content serialization", () => { + it("flattens OpenAI-style content parts to text (no '[object Object]')", async () => { + const calls: FetchCall[] = []; + globalThis.fetch = (async (url: string | URL | Request, init: RequestInit = {}) => { + calls.push({ url: String(url), init: init as { method?: string; body?: string } }); + if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse(); + return sseResponse([ + { choices: [{ delta: { phase: "answer", content: "ok", status: "finished" } }] }, + ]); + }) as typeof fetch; + + const executor = new mod.QwenWebExecutor(); + await executor.execute({ + model: "qwen3.7-max", + body: { + messages: [ + { role: "system", content: [{ type: "text", text: "You are helpful." }] }, + { + role: "user", + content: [ + { type: "text", text: "First part." }, + { type: "text", text: "Second part." }, + ], + }, + ], + }, + stream: false, + credentials: { apiKey: "token=jwt-tok; cna=abc" }, + signal: null, + } as unknown as Parameters[0]); + + const compBody = JSON.parse(calls[1].init.body); + const sent = String(compBody.messages[0].content); + + assert.ok( + !sent.includes("[object Object]"), + `prompt must not contain '[object Object]', got: ${sent}` + ); + assert.ok(sent.includes("First part."), "text of first content part must survive"); + assert.ok(sent.includes("Second part."), "text of second content part must survive"); + assert.ok(sent.includes("You are helpful."), "system content part must survive"); + }); +});