From 2a02b5d7e058cd767dbc9d37061e88710ecc3e05 Mon Sep 17 00:00:00 2001 From: FiaShi Date: Fri, 3 Jul 2026 15:48:28 +0800 Subject: [PATCH 1/6] fix(core): preserve no-argument tool calls that stream an empty arguments string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For tools that take no parameters, some OpenAI-compatible providers stream `arguments: ""` (or omit the field entirely) and never send an argument fragment. The streaming parser dropped such calls wholesale (`meta?.name && buffer.trim()`), while the non-streaming path keeps them with `args: {}` — so a turn containing only that call looked empty and geminiChat raised "Model stream ended with empty response text", triggering pointless retries. Align the streaming parser with the non-streaming path: emit the call with empty args when the buffer is empty at stream end. Rewrite the unit test that encoded the drop, and add regression coverage at parser and converter chunk level. --- .../openaiContentGenerator/converter.test.ts | 58 +++++++++++++++++++ .../streamingToolCallParser.test.ts | 20 ++++++- .../streamingToolCallParser.ts | 34 ++++++----- 3 files changed, 95 insertions(+), 17 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index 91418e07b1f..bf64b3ab9c2 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -292,6 +292,64 @@ describe('OpenAIContentConverter', () => { expect(fnB?.args).toEqual({ file_path: '/b/y.ts' }); expect(fnB?.id).toBe('call_B'); }); + + it('emits no-argument tool calls that stream an empty arguments string', () => { + // Providers may finish a no-argument tool call with `arguments: ""` + // and no follow-up fragment (e.g. llama.cpp-style servers). The call + // must reach the caller with empty args instead of being dropped, + // which would make the whole turn look empty and trigger retries. + const stream = withStreamParser(new StreamingToolCallParser()); + + const opener = { + object: 'chat.completion.chunk', + id: 'noargs-open', + created: 1, + model: 'test', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_noargs', + type: 'function' as const, + function: { name: 'list_sessions', arguments: '' }, + }, + ], + }, + finish_reason: null, + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk; + + const finisher = { + object: 'chat.completion.chunk', + id: 'noargs-finish', + created: 2, + model: 'test', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'tool_calls', + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk; + + converter.convertOpenAIChunkToGemini(opener, stream); + const result = converter.convertOpenAIChunkToGemini(finisher, stream); + + const fn = result.candidates?.[0]?.content?.parts?.find( + (p: Part) => p.functionCall, + )?.functionCall; + + expect(fn?.name).toBe('list_sessions'); + expect(fn?.args).toEqual({}); + expect(fn?.id).toBe('call_noargs'); + }); }); describe('convertGeminiRequestToOpenAI', () => { diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts index 2e5ac32b51f..860e9f5102e 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts @@ -391,11 +391,25 @@ describe('StreamingToolCallParser', () => { expect(completed).toHaveLength(0); }); - it('should not return tool calls with empty buffer', () => { - parser.addChunk(0, '', 'call_1', 'function1'); // Empty buffer + it('should return no-argument tool calls with empty args when buffer is empty', () => { + // For tools without parameters, some providers stream + // `arguments: ""` (or omit the field) and never send an argument + // fragment. The call must survive with empty args, matching the + // non-streaming path. + parser.addChunk(0, '', 'call_1', 'function1'); const completed = parser.getCompletedToolCalls(); - expect(completed).toHaveLength(0); + expect(completed).toEqual([ + { id: 'call_1', name: 'function1', args: {}, index: 0 }, + ]); + }); + + it('should return empty args for whitespace-only argument buffers', () => { + parser.addChunk(0, ' ', 'call_1', 'function1'); + + const completed = parser.getCompletedToolCalls(); + expect(completed).toHaveLength(1); + expect(completed[0].args).toEqual({}); }); }); diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts index c4f52ca4d42..c918a44c95e 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts @@ -265,7 +265,7 @@ export class StreamingToolCallParser { for (const [index, buffer] of this.buffers.entries()) { const meta = this.toolCallMeta.get(index); - if (meta?.name && buffer.trim()) { + if (meta?.name) { if (meta.id) { if (emittedIds.has(meta.id)) { continue; @@ -275,21 +275,27 @@ export class StreamingToolCallParser { let args: Record = {}; - // Try to parse the final buffer - try { - args = JSON.parse(buffer); - } catch { - // Try with repair (auto-close strings) - const inString = this.inStrings.get(index); - if (inString) { - try { - args = JSON.parse(buffer + '"'); - } catch { - // If all parsing fails, use safeJsonParse as fallback + // An empty buffer is a legal end state: for no-argument tools some + // providers stream `arguments: ""` (or omit the field entirely) and + // never send an argument fragment. Keep args as {} to match the + // non-streaming path in converter.ts. + if (buffer.trim()) { + // Try to parse the final buffer + try { + args = JSON.parse(buffer); + } catch { + // Try with repair (auto-close strings) + const inString = this.inStrings.get(index); + if (inString) { + try { + args = JSON.parse(buffer + '"'); + } catch { + // If all parsing fails, use safeJsonParse as fallback + args = safeJsonParse(buffer, {}); + } + } else { args = safeJsonParse(buffer, {}); } - } else { - args = safeJsonParse(buffer, {}); } } From 0d2b91d048482f072b5cf7d13de8b3307932935c Mon Sep 17 00:00:00 2001 From: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:00:17 +0800 Subject: [PATCH 2/6] fix(core): use name metadata as slot-occupancy signal for no-argument tool calls Follow-up to review feedback: after empty buffers became a legal end state for no-argument tool calls, three parser methods still used buffer.trim() to decide whether an index slot was occupied. A provider reusing indices could then silently overwrite a completed no-argument call (addChunk collision guard, findNextAvailableIndex) or append stray continuation chunks to it (findMostRecentIncompleteIndex). Switch the occupancy signal in all three places to the name metadata, keeping the JSON-completeness check for non-empty buffers. Add regression tests for both corruption paths and update the stale getCompletedToolCalls JSDoc. Co-Authored-By: Claude Fable 5 --- .../streamingToolCallParser.test.ts | 40 +++++++++++ .../streamingToolCallParser.ts | 67 ++++++++++++------- 2 files changed, 81 insertions(+), 26 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts index 860e9f5102e..38081e89e61 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts @@ -411,6 +411,46 @@ describe('StreamingToolCallParser', () => { expect(completed).toHaveLength(1); expect(completed[0].args).toEqual({}); }); + + it('should not overwrite a completed no-argument tool call when a new call reuses its index', () => { + // First tool call: no arguments, provider never sends a fragment + parser.addChunk(0, '', 'call_1', 'no_arg_function'); + + // Second tool call arrives at the same index with a different ID + parser.addChunk(0, '{"param": "value"}', 'call_2', 'function2'); + + // Both calls must survive: the second is relocated to a new index + const completed = parser.getCompletedToolCalls(); + expect(completed).toEqual([ + { id: 'call_1', name: 'no_arg_function', args: {}, index: 0 }, + { + id: 'call_2', + name: 'function2', + args: { param: 'value' }, + index: 1, + }, + ]); + }); + + it('should not route continuation chunks to a completed no-argument tool call', () => { + // Incomplete tool call accumulating arguments at index 0 + parser.addChunk(0, '{"key":', 'call_1', 'function1'); + // Completed no-argument tool call at the higher index 1 + parser.addChunk(1, '', 'call_2', 'no_arg_function'); + // Completed tool call at index 2 + parser.addChunk(2, '{"x": 1}', 'call_3', 'function3'); + + // Continuation chunk without an ID arriving at a completed index must + // be routed to the incomplete call_1, not to the no-argument call_2 + parser.addChunk(2, '"value"}'); + + const completed = parser.getCompletedToolCalls(); + expect(completed).toEqual([ + { id: 'call_1', name: 'function1', args: { key: 'value' }, index: 0 }, + { id: 'call_2', name: 'no_arg_function', args: {}, index: 1 }, + { id: 'call_3', name: 'function3', args: { x: 1 }, index: 2 }, + ]); + }); }); describe('Edge cases', () => { diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts index c918a44c95e..66958bc7313 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts @@ -88,20 +88,28 @@ export class StreamingToolCallParser { const existingDepth = this.depths.get(index)!; const existingMeta = this.toolCallMeta.get(index); - // Check if we have a complete tool call at this index + // Check if we have a complete tool call at this index. Occupancy + // is signaled by the name metadata, not the buffer: an empty + // buffer with a name is a complete no-argument call. if ( - existingBuffer.trim() && + existingMeta?.name && existingDepth === 0 && existingMeta?.id && existingMeta.id !== id ) { - try { - JSON.parse(existingBuffer); + let existingComplete = true; + if (existingBuffer.trim()) { + try { + JSON.parse(existingBuffer); + } catch { + // Existing buffer is not complete JSON, we can reuse this index + existingComplete = false; + } + } + if (existingComplete) { // We have a complete tool call with a different ID at this index // Find a new index for this tool call actualIndex = this.findNextAvailableIndex(); - } catch { - // Existing buffer is not complete JSON, we can reuse this index } } } @@ -244,8 +252,10 @@ export class StreamingToolCallParser { * 2. Auto-close unclosed strings and retry * 3. Fallback to safeJsonParse for malformed data * - * Only returns tool calls with both name metadata and non-empty buffers. - * Should be called when streaming is complete (finish_reason is present). + * Only returns tool calls with name metadata. An empty buffer yields + * empty arguments ({}), matching the non-streaming path for no-argument + * tools. Should be called when streaming is complete (finish_reason is + * present). * * @returns Array of completed tool calls with their metadata and parsed arguments */ @@ -315,8 +325,9 @@ export class StreamingToolCallParser { * Finds the next available index for a new tool call * * Scans indices starting from nextAvailableIndex to find one that's safe to use. - * Reuses indices with empty buffers or incomplete parsing states. - * Skips indices with complete, parseable tool call data to prevent overwriting. + * Reuses indices never claimed by a named tool call or with incomplete + * parsing states. Skips indices with complete tool call data — including + * no-argument calls with empty buffers — to prevent overwriting. * * @returns The next available index safe for storing a new tool call */ @@ -327,22 +338,23 @@ export class StreamingToolCallParser { const depth = this.depths.get(this.nextAvailableIndex)!; const meta = this.toolCallMeta.get(this.nextAvailableIndex); - // If buffer is empty or incomplete (depth > 0), this index is available - if (!buffer.trim() || depth > 0 || !meta?.id) { + // If the slot was never claimed by a named call or is incomplete + // (depth > 0), this index is available. An empty buffer alone does + // not mean available: with name metadata it is a complete + // no-argument call. + if (!meta?.name || depth > 0 || !meta?.id) { return this.nextAvailableIndex; } - // Try to parse the buffer to see if it's complete - try { - JSON.parse(buffer); - // If parsing succeeds and depth is 0, this index has a complete tool call - if (depth === 0) { - this.nextAvailableIndex++; - continue; + // A non-empty buffer must parse as complete JSON for the slot to be + // occupied; an empty buffer is already complete (no-argument call) + if (buffer.trim()) { + try { + JSON.parse(buffer); + } catch { + // If parsing fails, this index is available for reuse + return this.nextAvailableIndex; } - } catch { - // If parsing fails, this index is available for reuse - return this.nextAvailableIndex; } this.nextAvailableIndex++; @@ -354,8 +366,9 @@ export class StreamingToolCallParser { * Finds the most recent incomplete tool call index * * Used when continuation chunks arrive without IDs. Scans existing tool calls - * to find the highest index with incomplete parsing state (depth > 0, empty buffer, - * or unparseable JSON). Falls back to creating a new index if none found. + * to find the highest index with incomplete parsing state (depth > 0, empty + * buffer with no name metadata yet, or unparseable JSON). Falls back to + * creating a new index if none found. * * @returns The index of the most recent incomplete tool call, or a new available index */ @@ -366,8 +379,10 @@ export class StreamingToolCallParser { const depth = this.depths.get(index)!; const meta = this.toolCallMeta.get(index); - // Check if this tool call is incomplete - if (meta?.id && (depth > 0 || !buffer.trim())) { + // Check if this tool call is incomplete. An empty buffer counts only + // while no name has arrived: with name metadata it is a complete + // no-argument call, and stray chunks must not be appended to it + if (meta?.id && (depth > 0 || (!buffer.trim() && !meta?.name))) { maxIndex = Math.max(maxIndex, index); } else if (buffer.trim()) { // Check if buffer is parseable (complete) From a5db830525247216b4e15c114eb8dadc08a60669 Mon Sep 17 00:00:00 2001 From: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:03:07 +0800 Subject: [PATCH 3/6] fix(core): collapse non-object argument parses at emit and lock canonical empty-opener shape Review follow-up on the no-ID continuation routing at addChunk. Mid-stream, an empty buffer with name metadata is formally undecidable between "completed no-argument call" and "canonical opener awaiting its first argument fragment" (every OpenAI-compatible provider opens with arguments:"" and streams fragments ID-less at the same index). Routing must favor the canonical shape, so the guard stays; a new test pins that shape, which the suite previously did not cover. The corruption concern from review is instead bounded at emit time: a buffer polluted by a stray fragment can parse or repair to a non-object value, which now collapses to {} so a polluted no-argument call still emits empty args. Co-Authored-By: Claude Fable 5 --- .../streamingToolCallParser.test.ts | 31 +++++++++++++++++++ .../streamingToolCallParser.ts | 10 ++++++ 2 files changed, 41 insertions(+) diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts index 38081e89e61..6e24df942ce 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts @@ -432,6 +432,37 @@ describe('StreamingToolCallParser', () => { ]); }); + it('should route ID-less argument fragments to a call whose opener streamed empty arguments', () => { + // Canonical OpenAI-compatible streaming shape: the opener carries + // id + name + `arguments: ""`, then argument fragments follow at the + // same index without an ID. Mid-stream, an empty buffer with name + // metadata must therefore stay continuable at its own index — it is + // indistinguishable from a completed no-argument call until stream end. + parser.addChunk(0, '', 'call_1', 'function1'); + parser.addChunk(0, '{"x":'); + parser.addChunk(0, '1}'); + + const completed = parser.getCompletedToolCalls(); + expect(completed).toEqual([ + { id: 'call_1', name: 'function1', args: { x: 1 }, index: 0 }, + ]); + }); + + it('should emit empty args for a no-argument call polluted by a stray fragment at its index', () => { + // If a misbehaving provider reuses a completed no-argument call's + // index for another call's ID-less fragment, the fragment cannot be + // re-routed (see canonical-shape test above). The damage must stay + // bounded: the polluted buffer repairs to a non-object value, which + // collapses to {} at emit time. + parser.addChunk(0, '{"key":', 'call_1', 'function1'); + parser.addChunk(1, '', 'call_2', 'no_arg_function'); + parser.addChunk(1, '"value"}'); + + const completed = parser.getCompletedToolCalls(); + const noArg = completed.find((c) => c.id === 'call_2'); + expect(noArg?.args).toEqual({}); + }); + it('should not route continuation chunks to a completed no-argument tool call', () => { // Incomplete tool call accumulating arguments at index 0 parser.addChunk(0, '{"key":', 'call_1', 'function1'); diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts index 66958bc7313..abc14d1b16c 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts @@ -307,6 +307,16 @@ export class StreamingToolCallParser { args = safeJsonParse(buffer, {}); } } + // Tool arguments are always JSON objects; a corrupted buffer can + // parse or repair to a non-object value (e.g. a bare string), so + // collapse anything else to {}. + if ( + typeof args !== 'object' || + args === null || + Array.isArray(args) + ) { + args = {}; + } } completed.push({ From f1efd7f66bca85f94c46a6c70757128a328a78d5 Mon Sep 17 00:00:00 2001 From: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:58:22 +0800 Subject: [PATCH 4/6] fix(core): add debug logging for empty-buffer emission and non-object argument collapse Review follow-up: a stray fragment that happens to parse as a valid JSON object is indistinguishable from real arguments at emit time, so log both the non-object collapse and empty-buffer emissions to aid diagnosis. Co-Authored-By: Claude Fable 5 --- .../core/openaiContentGenerator/streamingToolCallParser.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts index abc14d1b16c..73f59f29455 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts @@ -315,8 +315,15 @@ export class StreamingToolCallParser { args === null || Array.isArray(args) ) { + debugLogger.debug( + `Collapsing non-object arguments for tool call ${meta.name} (id=${meta.id}) at index ${index}; buffer likely polluted by a misrouted fragment`, + ); args = {}; } + } else { + debugLogger.debug( + `Emitting no-argument tool call ${meta.name} (id=${meta.id}) at index ${index} with empty buffer`, + ); } completed.push({ From 096eea550a58cb6b2db0341a37dae89d1b96897c Mon Sep 17 00:00:00 2001 From: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:00:58 +0800 Subject: [PATCH 5/6] fix(core): extend replay guard to opener-shaped replays of no-argument tool calls Review follow-up: after empty buffers became a legal completed state, a replayed opener (duplicate ID, #5107 lineage) could overwrite a completed no-argument call's name metadata, since the replay guard only engaged on non-empty buffers. Swallowing every known-ID chunk at that state would drop ID-bearing argument fragments for providers whose opener streams empty arguments, so the guard uses the protocol shape as discriminator: a chunk carrying a name but no argument content is an opener replay and is ignored; a chunk with argument content is a continuation and appends. Regression tests cover both directions. Co-Authored-By: Claude Fable 5 --- .../streamingToolCallParser.test.ts | 25 +++++++++++++++++ .../streamingToolCallParser.ts | 28 +++++++++++++++---- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts index 6e24df942ce..1160ada13de 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts @@ -653,6 +653,31 @@ describe('StreamingToolCallParser', () => { expect(parser.getBuffer(0)).toBe('{"param1": "value1"}'); }); + it('should ignore replayed openers for a completed no-argument tool call', () => { + parser.addChunk(0, '', 'call_1', 'list_sessions'); + // Provider replays the same ID's opener with a different name; the + // surviving call must not be mutated + parser.addChunk(0, '', 'call_1', 'different_function'); + + const completed = parser.getCompletedToolCalls(); + expect(completed).toHaveLength(1); + expect(completed[0].name).toBe('list_sessions'); + expect(completed[0].args).toEqual({}); + }); + + it('should append ID-bearing argument fragments after an empty opener', () => { + // Some providers repeat the tool call ID on argument fragments. A + // known-ID chunk carrying argument content is a continuation, not a + // replay, and must not be swallowed by the replay guard. + parser.addChunk(0, '', 'call_1', 'function1'); + const result = parser.addChunk(0, '{"x":1}', 'call_1'); + + expect(result.complete).toBe(true); + expect(parser.getCompletedToolCalls()).toEqual([ + { id: 'call_1', name: 'function1', args: { x: 1 }, index: 0 }, + ]); + }); + it('should ignore metadata-only replay chunks after a tool call ID completes', () => { parser.addChunk(0, '{"file_path": "a.ts"}', 'call_1', 'read_file'); diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts index 73f59f29455..3379e5f5a97 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts @@ -154,15 +154,31 @@ export class StreamingToolCallParser { const currentBuffer = this.buffers.get(actualIndex)!; const currentDepth = this.depths.get(actualIndex)!; - if (isKnownId && currentBuffer.trim() && currentDepth === 0) { - try { - JSON.parse(currentBuffer); + if (isKnownId && currentDepth === 0) { + if (currentBuffer.trim()) { + try { + JSON.parse(currentBuffer); + debugLogger.debug( + `Ignoring replay chunk for completed toolCall id=${id}`, + ); + return { complete: false }; + } catch { + // Not complete yet; append the incoming chunk below. + } + } else if ( + this.toolCallMeta.get(actualIndex)?.name && + name && + !chunk.trim() + ) { + // The call at this index may be a completed no-argument call. An + // incoming chunk that carries a name but no argument content is an + // opener-shaped replay (duplicate ID) and must not mutate the + // surviving call; a chunk with argument content is a continuation + // for a call whose opener streamed empty arguments and must append. debugLogger.debug( - `Ignoring replay chunk for completed toolCall id=${id}`, + `Ignoring replayed opener for no-argument toolCall id=${id}`, ); return { complete: false }; - } catch { - // Not complete yet; append the incoming chunk below. } } From 852e763272be0ae83f7ce6f1b6ba2a0ad5d97d4e Mon Sep 17 00:00:00 2001 From: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:01:05 +0800 Subject: [PATCH 6/6] test(core): cover null/array argument collapse and multi-slot relocation scan Review follow-up: pin the null and array branches of the emit-time non-object collapse, and exercise findNextAvailableIndex scanning past multiple occupied no-argument slots during collision relocation. Co-Authored-By: Claude Fable 5 --- .../streamingToolCallParser.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts index 1160ada13de..e7dc1bec2e7 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts @@ -463,6 +463,34 @@ describe('StreamingToolCallParser', () => { expect(noArg?.args).toEqual({}); }); + it('should collapse null argument buffers to empty args', () => { + parser.addChunk(0, 'null', 'call_1', 'function1'); + + const completed = parser.getCompletedToolCalls(); + expect(completed[0].args).toEqual({}); + }); + + it('should collapse array argument buffers to empty args', () => { + parser.addChunk(0, '[1,2,3]', 'call_1', 'function1'); + + const completed = parser.getCompletedToolCalls(); + expect(completed[0].args).toEqual({}); + }); + + it('should scan past occupied no-argument slots when relocating a colliding call', () => { + parser.addChunk(0, '', 'call_a', 'no_arg_a'); + parser.addChunk(1, '', 'call_b', 'no_arg_b'); + // Collision at index 0 must relocate past both occupied no-arg slots + parser.addChunk(0, '{"x": 1}', 'call_c', 'fn_c'); + + const completed = parser.getCompletedToolCalls(); + expect(completed).toEqual([ + { id: 'call_a', name: 'no_arg_a', args: {}, index: 0 }, + { id: 'call_b', name: 'no_arg_b', args: {}, index: 1 }, + { id: 'call_c', name: 'fn_c', args: { x: 1 }, index: 2 }, + ]); + }); + it('should not route continuation chunks to a completed no-argument tool call', () => { // Incomplete tool call accumulating arguments at index 0 parser.addChunk(0, '{"key":', 'call_1', 'function1');