From 02a6c3d90b33bd1d0c4ced31593caa62de27e274 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:54:48 +0200 Subject: [PATCH] fix(sse): split concatenated tool_call arguments from same-name index collisions (#11043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⭐5 — Providers que não bumpam index/id em tool calls repetidas do mesmo nome colam N arguments JSON num só ({...}{...}{...}); leitores a jusante pegam só o primeiro e dropam o resto em silêncio. Detecta N objetos concatenados e divide de volta em N tool_calls. TDD 20/20 + 86/86 irmãos. Fecha #11044. Base-red #9985 inherited. --- open-sse/utils/streamPayloadCollector.ts | 91 ++++++++++++++++- tests/unit/stream-payload-collector.test.ts | 106 +++++++++++++++++++- 2 files changed, 193 insertions(+), 4 deletions(-) diff --git a/open-sse/utils/streamPayloadCollector.ts b/open-sse/utils/streamPayloadCollector.ts index 9ca089dfe4..ea1b2e658e 100644 --- a/open-sse/utils/streamPayloadCollector.ts +++ b/open-sse/utils/streamPayloadCollector.ts @@ -128,6 +128,75 @@ function tryParseJson(raw: string): unknown { } } +/** + * Splits a tool_call `arguments` string that is actually multiple back-to-back JSON + * objects glued together with no separator, into its individual object substrings. + * + * Root cause (observed on opencode/muse-spark-1.2-contributor-free via the zen + * provider): some upstreams never vary `index`/`id` across a 2nd/3rd/… tool_call of + * the SAME name emitted in one turn, so every delta in `buildOpenAISummary` above + * resolves to the same accumulator key and `arguments` ends up as N JSON objects + * concatenated with no delimiter — invalid as a single JSON value, but each object is + * individually well-formed. Structural, not provider-specific: applies to whichever + * upstream exhibits the same index-collision streaming bug. + * + * Returns `null` when `raw` is empty, already valid single JSON, or does not scan as + * ≥2 back-to-back valid JSON values — callers must leave `arguments` untouched in + * that case (never regress a value that used to reach the client as-is). + */ +export function splitConcatenatedToolCallArguments(raw: string): string[] | null { + if (!raw) return null; + try { + JSON.parse(raw); + return null; // Already a single valid JSON value — nothing to split. + } catch { + // Fall through to the multi-value scan below. + } + + const parts: string[] = []; + let depth = 0; + let inString = false; + let escaped = false; + let start = -1; + + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + if (start === -1) { + if (ch === " " || ch === "\n" || ch === "\r" || ch === "\t") continue; + if (ch !== "{" && ch !== "[") return null; // Not a value boundary — bail, leave untouched. + start = i; + } + if (inString) { + if (escaped) escaped = false; + else if (ch === "\\") escaped = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === "{" || ch === "[") depth++; + else if (ch === "}" || ch === "]") { + depth--; + if (depth === 0) { + parts.push(raw.slice(start, i + 1)); + start = -1; + } + } + } + if (start !== -1 || depth !== 0 || parts.length < 2) return null; + + for (const part of parts) { + try { + JSON.parse(part); + } catch { + return null; // One of the scanned segments isn't valid JSON — bail entirely. + } + } + return parts; +} + // ─── Per-format live reducers ──────────────────────────────────────────────── // Each reducer mirrors the corresponding build*Summary()'s original for-loop // body exactly (ingest = one loop iteration, finalize = the post-loop return), @@ -262,7 +331,27 @@ function createOpenAIReducer(fallbackModel?: string | null): SummaryReducer { message.reasoning_content = joinedReasoning; } - const finalToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index); + const mergedToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index); + // Expand any entry whose accumulated `arguments` turned out to be multiple + // concatenated JSON objects (upstream never varied index/id across repeated + // same-name tool_calls) into its own separate tool_calls entries. + const finalToolCalls: ToolCall[] = []; + let nextIndex = 0; + for (const tc of mergedToolCalls) { + const splitArgs = splitConcatenatedToolCallArguments(tc.function.arguments); + if (!splitArgs) { + finalToolCalls.push({ ...tc, index: nextIndex++ }); + continue; + } + for (const [i, args] of splitArgs.entries()) { + finalToolCalls.push({ + id: tc.id ? `${tc.id}_split${i}` : null, + index: nextIndex++, + type: tc.type, + function: { name: tc.function.name, arguments: args }, + }); + } + } if (finalToolCalls.length > 0) { finishReason = "tool_calls"; message.tool_calls = finalToolCalls; diff --git a/tests/unit/stream-payload-collector.test.ts b/tests/unit/stream-payload-collector.test.ts index 610d0861a0..e8869bb8bf 100644 --- a/tests/unit/stream-payload-collector.test.ts +++ b/tests/unit/stream-payload-collector.test.ts @@ -42,7 +42,7 @@ test("buildStreamSummaryFromEvents handles empty array", () => { }); test("buildStreamSummaryFromEvents handles single event", () => { - const events = [{ data: { choices: [{ delta: { content: "hello" } }] } }]; + const events = [{ index: 0, data: { choices: [{ delta: { content: "hello" } }] } }]; const result = collector.buildStreamSummaryFromEvents(events) as any; assert.ok(result !== null); assert.ok(typeof result === "object"); @@ -50,8 +50,8 @@ test("buildStreamSummaryFromEvents handles single event", () => { test("buildStreamSummaryFromEvents handles multiple events", () => { const events = [ - { data: { choices: [{ delta: { content: "hello" } }] } }, - { data: { choices: [{ delta: { content: " world" } }] } }, + { index: 0, data: { choices: [{ delta: { content: "hello" } }] } }, + { index: 1, data: { choices: [{ delta: { content: " world" } }] } }, ]; const result = collector.buildStreamSummaryFromEvents(events) as any; assert.ok(result !== null); @@ -218,6 +218,106 @@ test("buildStreamSummaryFromEvents keeps two genuinely different interleaved too assert.equal(toolCalls[1].function.arguments, '{"path":"b"}'); }); +// opencode/muse-spark-1.2-contributor-free (zen provider): the upstream SSE stream +// never varies `index`/`id` for a 2nd/3rd tool_call of the SAME name in one turn — +// every delta lands on the same accumulator key, so 3 distinct `task` calls +// concatenate into a single malformed `arguments` string containing 3 back-to-back +// JSON objects (the model emits the whole 3rd call already glued to the first two +// in one delta — no true streaming needed to trigger it). +test("buildStreamSummaryFromEvents splits a tool_call whose arguments are multiple concatenated JSON objects under the same id/index (muse-spark SSE index bug)", () => { + const glued = + '{"description":"Subagent OK 1","prompt":"Reply only \\"OK\\". Nothing else.","subagent_type":"general"}' + + '{"description":"Subagent OK 2","prompt":"Reply only \\"OK\\". Nothing else.","subagent_type":"general"}' + + '{"description":"Subagent OK 3","prompt":"Reply only \\"OK\\". Nothing else.","subagent_type":"general"}'; + + const events = [ + toolCallEvent({ + role: "assistant", + tool_calls: [ + { + index: 0, + id: "call_task_glued", + type: "function", + function: { name: "task", arguments: glued }, + }, + ], + }), + toolCallEvent({}, "tool_calls"), + ]; + + const summary = collector.buildStreamSummaryFromEvents( + events, + "openai", + "opencode/muse-spark-1.2-contributor-free" + ) as ToolCallSummary; + const toolCalls = summary.choices[0].message.tool_calls; + + assert.equal( + toolCalls.length, + 3, + `expected 3 split tool_calls, got ${toolCalls.length}: ${JSON.stringify(toolCalls)}` + ); + for (const [i, tc] of toolCalls.entries()) { + assert.equal(tc.function.name, "task"); + const parsed = JSON.parse(tc.function.arguments); + assert.equal(parsed.description, `Subagent OK ${i + 1}`); + } +}); + +test("buildStreamSummaryFromEvents leaves a single valid JSON arguments string untouched (no false-positive split)", () => { + const events = [ + toolCallEvent({ + role: "assistant", + tool_calls: [ + { + index: 0, + id: "call_single", + type: "function", + function: { name: "write", arguments: '{"path":"a.txt","content":"{}"}' }, + }, + ], + }), + toolCallEvent({}, "tool_calls"), + ]; + + const summary = collector.buildStreamSummaryFromEvents( + events, + "openai", + "deepseek-v4-flash-free" + ) as ToolCallSummary; + const toolCalls = summary.choices[0].message.tool_calls; + + assert.equal(toolCalls.length, 1); + assert.equal(toolCalls[0].function.arguments, '{"path":"a.txt","content":"{}"}'); +}); + +test("buildStreamSummaryFromEvents leaves genuinely malformed (non-concatenated) JSON arguments untouched (no worse than before)", () => { + const events = [ + toolCallEvent({ + role: "assistant", + tool_calls: [ + { + index: 0, + id: "call_broken", + type: "function", + function: { name: "write", arguments: '{"path":"a.txt", "content": tr' }, + }, + ], + }), + toolCallEvent({}, "tool_calls"), + ]; + + const summary = collector.buildStreamSummaryFromEvents( + events, + "openai", + "deepseek-v4-flash-free" + ) as ToolCallSummary; + const toolCalls = summary.choices[0].message.tool_calls; + + assert.equal(toolCalls.length, 1); + assert.equal(toolCalls[0].function.arguments, '{"path":"a.txt", "content": tr'); +}); + type OpenAIStreamSummary = { choices: Array<{ finish_reason: string;