fix(sse): split concatenated tool_call arguments from same-name index collisions (#11043)

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.
This commit is contained in:
Dizzle
2026-08-22 01:54:48 +02:00
committed by GitHub
parent ae2de4511b
commit 02a6c3d90b
2 changed files with 193 additions and 4 deletions

View File

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

View File

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