Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/core/src/core/openaiContentGenerator/converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,11 +391,96 @@ 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 },
]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No test covers a no-argument tool call coexisting with a regular (argument-bearing) tool call in the same parser instance. This mixed case is where the internal routing/collision heuristics (index-collision guard, continuation-chunk routing, findMostRecentIncompleteIndex) are most likely to surface edge cases. Consider adding a test like:

it('should emit both no-arg and regular tool calls from the same stream', () => {
  parser.addChunk(0, '', 'call_1', 'list_sessions');
  parser.addChunk(1, '{"query":', 'call_2', 'search_web');
  parser.addChunk(1, ' "hello"}');

  const completed = parser.getCompletedToolCalls();
  expect(completed).toHaveLength(2);
  expect(completed.find((tc) => tc.id === 'call_1')?.args).toEqual({});
  expect(completed.find((tc) => tc.id === 'call_2')?.args).toEqual({
    query: 'hello',
  });
  expect(parser.hasIncompleteToolCalls()).toBe(false);
});

— qwen3.7-max via Qwen Code /review

});

it('should return empty args for whitespace-only argument buffers', () => {
parser.addChunk(0, ' ', 'call_1', 'function1');

const completed = parser.getCompletedToolCalls();
expect(completed).toHaveLength(1);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This test uses weaker assertions than the empty-buffer test above (line 398). The empty-buffer test uses expect(completed).toEqual([{ id: 'call_1', name: 'function1', args: {}, index: 0 }]) (full shape), while this one only checks toHaveLength(1) and completed[0].args. It would not catch metadata corruption or wrong-index routing.

Suggested change
expect(completed).toHaveLength(1);
it('should return empty args for whitespace-only argument buffers', () => {
parser.addChunk(0, ' ', 'call_1', 'function1');
const completed = parser.getCompletedToolCalls();
expect(completed).toEqual([
{ id: 'call_1', name: 'function1', args: {}, index: 0 },
]);
});

— qwen3.7-max via Qwen Code /review

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 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');
// 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 },
]);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This collision path was correctly updated to use existingMeta?.name as the occupancy signal, but the continuation-routing block ~40 lines below (line ~130, if (existingDepth > 0 || !existingBuffer.trim())) still uses !existingBuffer.trim() as the incompleteness check. This creates an inherent ambiguity: a completed no-argument call (empty buffer + name + depth 0) at an index is indistinguishable mid-stream from an opener that received arguments: "" and is still waiting for fragments.

The canonical streaming case (opener with empty args followed by fragments at the same index) is why this path wasn't simply switched to meta?.name — both states have an empty buffer with name metadata. The existing tests pass because they're designed around the canonical case. But if a misbehaving provider sends a stray ID-less fragment at a completed no-arg call's index, the fragment is routed to the completed call instead of findMostRecentIncompleteIndex(). The non-object guard (~line 313) bounds the damage for most corruption paths (strings, arrays, null), but a valid JSON object fragment would pass through undetected.

A proper fix would require tracking explicit state per index (e.g., argumentsStarted: boolean set on the first non-empty argument fragment) to disambiguate "waiting for args" from "complete no-arg call" — but that's a broader change. If that's not worth the scope here, at minimum consider documenting the provider arrival-order assumption this path depends on.

— qwen3.7-max via Qwen Code /review

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
}
}
}
Expand Down Expand Up @@ -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
*/
Expand All @@ -265,7 +275,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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The JSDoc for getCompletedToolCalls() (line ~248) still states "Only returns tool calls with both name metadata and non-empty buffers", but the guard was changed from meta?.name && buffer.trim() to meta?.name. The comment is now inaccurate — empty buffers produce args: {} for no-argument tools.

Suggested change
if (meta?.name) {
/**
* Returns completed tool calls from the parser.
* Only returns tool calls that have name metadata. Calls with empty or
* whitespace-only argument buffers are emitted with `args: {}` to match
* the non-streaming path in converter.ts.
*/

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The guard relaxation here (meta?.name only) correctly preserves empty-buffer no-arg tool calls at emission time, but three buffer.trim() checks in unchanged addChunk() code still use "has buffer content" as a proxy for "this slot is occupied by a real tool call." Now that empty buffer is a valid terminal state, those proxies break in multi-tool-call scenarios:

  1. Line 93 (collision detection): existingBuffer.trim() is falsy for an empty-buffer entry, so when a second tool call arrives at the same index, the collision guard is skipped and the first call's metadata is silently overwritten.
  2. Line 149 (replay guard): currentBuffer.trim() is falsy, so a replayed chunk for a completed no-arg call bypasses the guard and corrupts the buffer.
  3. Lines 121 & 345 (continuation routing + findMostRecentIncompleteIndex): !existingBuffer.trim() classifies a completed no-arg call as "incomplete," so continuation chunks from other tool calls get misrouted to it.

None of these paths are covered by the new tests (they require multi-tool-call interleaving). The fix for all three is the same — replace buffer.trim() with a completion signal that accounts for empty-buffer entries, e.g. meta?.name && meta?.id && depth === 0.

— qwen3.7-max via Qwen Code /review

if (meta.id) {
if (emittedIds.has(meta.id)) {
continue;
Expand All @@ -275,22 +285,45 @@ export class StreamingToolCallParser {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The replay-detection guard at line 157 (isKnownId && currentBuffer.trim() && currentDepth === 0) does not protect completed no-argument calls. After this PR, a no-arg tool call has an empty buffer, so currentBuffer.trim() is falsy and the entire guard short-circuits. A subsequent chunk from the provider carrying the same ID — e.g. a replay or duplicate opener — bypasses the guard, overwrites meta.name, and appends to the buffer, corrupting the call.

No existing test covers this scenario (the two replay-guard tests at lines ~629 and ~656 both use calls with non-empty argument buffers).

Extend the replay guard to treat an empty buffer with name metadata as complete:

Suggested change
if (isKnownId && currentDepth === 0 && (currentBuffer.trim() || this.toolCallMeta.get(actualIndex)?.name)) {

Inside the guard, the JSON.parse check also needs a no-arg fast path:

if (isKnownId && currentDepth === 0 && (currentBuffer.trim() || this.toolCallMeta.get(actualIndex)?.name)) {
  let isComplete = !currentBuffer.trim(); // empty buffer + name = complete no-arg call
  if (!isComplete) {
    try {
      JSON.parse(currentBuffer);
      isComplete = true;
    } catch {
      // Not complete yet; append the incoming chunk below.
    }
  }
  if (isComplete) {
    debugLogger.debug(
      `Ignoring replay chunk for completed toolCall id=${id}`,
    );
    return { complete: false };
  }
}

Add a regression test:

it('should ignore replay chunks for a completed no-argument tool call', () => {
  parser.addChunk(0, '', 'call_1', 'list_sessions');
  // Provider replays the same ID with a different name
  parser.addChunk(0, '', 'call_1', 'different_function');
  const completed = parser.getCompletedToolCalls();
  expect(completed).toHaveLength(1);
  expect(completed[0].name).toBe('list_sessions');
});

— qwen3.7-max via Qwen Code /review

let args: Record<string, unknown> = {};

// 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, {});
}
// 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The non-object collapse guard has three branches (typeof !== 'object', === null, Array.isArray), but only the string-value branch is exercised by the existing "polluted buffer" test ('"value"}' repairs to a bare string). The null and array paths are untested — if a future change accidentally drops either check, no test would catch it.

Suggested change
Array.isArray(args)
// Test: parser.addChunk(0, 'null', 'call_1', 'fn');
// expect(completed[0].args).toEqual({});
// Test: parser.addChunk(0, '[1,2,3]', 'call_1', 'fn');
// expect(completed[0].args).toEqual({});
Array.isArray(args)

— qwen3.7-max via Qwen Code /review

) {
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({
Expand All @@ -309,8 +342,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
*/
Expand All @@ -321,22 +355,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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The scanning loop's empty-buffer skip path (lines 384–393) is never tested with a scan that must advance past an occupied empty-buffer slot. Existing tests only trigger findNextAvailableIndex via a single collision where nextAvailableIndex points to an unoccupied slot, so the loop body never iterates. A test that stacks multiple no-arg calls would exercise the new code path:

it('should scan past occupied no-argument slots when relocating', () => {
  parser.addChunk(0, '', 'call_a', 'no_arg_a');
  parser.addChunk(1, '', 'call_b', 'no_arg_b');
  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 },
  ]);
});

— qwen3.7-max via Qwen Code /review

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++;
Expand All @@ -348,8 +383,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
*/
Expand All @@ -360,8 +396,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))) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Same !meta?.name guard applied here but missed at line 130 (no-ID continuation fast-path)

The PR correctly updated three of four sites that used !buffer.trim() as the "incomplete call" signal to also check !meta?.name — here, findNextAvailableIndex (~line 379), and the replay guard (~line 167). But the no-ID continuation fast-path at line 130 was not updated:

// Line 130 (unchanged by this PR, not in diff):
if (existingDepth > 0 || !existingBuffer.trim()) {
  actualIndex = index;

A completed no-argument call has depth=0, buffer="", so !existingBuffer.trim() short-circuits to actualIndex = index. A stray no-ID continuation chunk arriving at that index gets appended to the completed call instead of being routed through findMostRecentIncompleteIndex. The non-object collapse guard in getCompletedToolCalls catches non-object pollution, but a stray chunk forming a valid JSON object (e.g. {"x":1}) passes through undetected — emitting wrong args for the no-arg call while the intended target call stalls with truncated args.

The canonical-shape test (should route ID-less argument fragments to a call whose opener streamed empty arguments) still passes with this fix because findMostRecentIncompleteIndex correctly handles the routing.

Suggested fix (at line 130):

const existingMeta = this.toolCallMeta.get(index);
if (existingDepth > 0 || (!existingBuffer.trim() && !existingMeta?.name)) {
  actualIndex = index;

— qwen3.7-max via Qwen Code /review

maxIndex = Math.max(maxIndex, index);
} else if (buffer.trim()) {
// Check if buffer is parseable (complete)
Expand Down
Loading