fix(sse): flatten structured (array) content in Qwen Web executor (#6927)

* fix(sse): flatten structured (array) content in Qwen Web executor

foldMessages did String(m.content), turning OpenAI-style content-part arrays
into the literal "[object Object]" prompt. Add contentToText() to extract the
text parts. Reported on the support mesh.

TDD: red->green regression test tests/unit/qwen-web-content-array-serialization.test.ts

* docs(changelog): add fragment for #6927

* fix(stryker): register qwen-web content-array test in tap.testFiles

Fast Quality Gates flagged the new coverage for open-sse/executors/qwen-web.ts
as missing from stryker.conf.json's tap.testFiles list.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-12 10:26:58 -03:00
committed by GitHub
parent 7ca422d258
commit 2a5f9a5ed7
4 changed files with 107 additions and 1 deletions

View File

@@ -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)

View File

@@ -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") {

View File

@@ -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",

View File

@@ -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<Record<string, unknown>>): 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<typeof executor.execute>[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");
});
});