Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,99 @@ describe('StreamingToolCallParser', () => {
});
});

it('routes id-less continuation chunks to a slot claimed by a colliding opener delta', () => {
parser.addChunk(0, '{"a":1}', 'call_1', 'function1');
Comment on lines +1015 to +1016

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 new test covers the removed !meta.id guard (remap recording) but does not exercise the added guard !this.toolCallMeta.get(...)?.id on pending-remap adoption. In this test, step 1 (call_1 at index 0) never creates a remap because actualIndex === index, so the guarded else if branch is never reached. A third id-bearing call at the same provider index would be needed to hit that branch.

Failure scenario: If the !meta.id guard were accidentally removed, all existing tests still pass — a third tool call at the same index would silently hijack the second call's remapped slot, dropping its continuation arguments.

it('does not let a third colliding opener hijack a remapped slot', () => {
  parser.addChunk(0, '{"a":1}', 'call_1', 'function1');
  parser.addChunk(0, '', 'call_2', 'function2'); // creates remap 0→1
  const third = parser.addChunk(0, '', 'call_3', 'function3');
  expect(third.actualIndex).toBe(2); // must NOT adopt slot 1
  // call_2's continuation still routes correctly
  expect(parser.addChunk(0, '{"b":2}').actualIndex).toBe(1);
});

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The added guard's block direction is covered by the third test, does not let a brand-new tool-call id adopt a remap slot that already has an id: after call_2 claims the 0->1 remap with its own id, a third call reusing index 0 with a fresh id asserts third.actualIndex !== 1, which fails if the !toolCallMeta.get(remap)?.id guard is removed. This first test intentionally covers the record path; the third covers the adopt-guard path.


// The provider reuses index 0 for a second tool call whose id and name
// arrive together on an empty opener delta (the standard OpenAI streaming
// shape: function: { name, arguments: '' }).
const opener = parser.addChunk(0, '', 'call_2', 'function2');
expect(opener.actualIndex).toBe(1);

// The following id-less argument chunk must land on call_2's slot, not on
// a fresh orphan slot that would drop the arguments and get the call
// flagged as malformed.
const continuation = parser.addChunk(0, '{"b":2}');
expect(continuation.actualIndex).toBe(1);

expect(parser.getCompletedToolCalls()).toContainEqual({
id: 'call_2',
name: 'function2',
args: { b: 2 },
index: 1,
});
// call_1's arguments must survive the collision intact.
expect(parser.getCompletedToolCalls()).toContainEqual({
id: 'call_1',
name: 'function1',
args: { a: 1 },
index: 0,
});
});

it('routes id-less continuations after a content-bearing colliding opener', () => {
parser.addChunk(0, '{"a":1}', 'call_1', 'function1');

// Same collision as above, but call_2's opener already carries a partial
// arguments fragment alongside its id and name — the line-239 remap-record
// path, as opposed to the empty-opener early return.
const opener = parser.addChunk(0, '{"b":', 'call_2', 'function2');
expect(opener.actualIndex).toBe(1);

const continuation = parser.addChunk(0, '2}');
expect(continuation.actualIndex).toBe(1);

expect(parser.getCompletedToolCalls()).toContainEqual({
id: 'call_2',
name: 'function2',
args: { b: 2 },
index: 1,
});
});

it('does not let a brand-new tool-call id adopt a remap slot that already has an id', () => {
// Exercises the added `!toolCallMeta.get(remap)?.id` guard on pending-remap
// adoption: after call_2 claims the 0->1 remap (with its own id), a third
// tool call that reuses index 0 with a fresh id must NOT hijack call_2's
// slot via that remap — it has to fall through to collision handling and
// get its own slot.
parser.addChunk(0, '{"a":1}', 'call_1', 'function1');
parser.addChunk(0, '', 'call_2', 'function2');
parser.addChunk(0, '{"b":2}');

const third = parser.addChunk(0, '{"c":3}', 'call_3', 'function3');
expect(third.actualIndex).not.toBe(1);

const completed = parser.getCompletedToolCalls();
const call2 = completed.find((tc) => tc.id === 'call_2');
const call3 = completed.find((tc) => tc.id === 'call_3');
expect(call2?.args).toEqual({ b: 2 });
expect(call3?.args).toEqual({ c: 3 });
});

it('routes an id-less continuation to the newest of three colliding openers', () => {
// Three tool calls reuse provider index 0 in sequence, each opener remapping
// to a fresh slot. An id-less continuation after the third opener must land on
// the third call's slot. Guarding the remap overwrite to keep the *first*
// mapping would pin the remap at call_2's slot and misroute this chunk.
parser.addChunk(0, '{"a":1}', 'call_1', 'function1'); // slot 0
parser.addChunk(0, '', 'call_2', 'function2'); // opener -> slot 1
parser.addChunk(0, '{"b":2}'); // call_2 args -> slot 1
const opener3 = parser.addChunk(0, '', 'call_3', 'function3'); // opener -> slot 2
expect(opener3.actualIndex).toBe(2);

const continuation = parser.addChunk(0, '{"c":3}'); // id-less -> must be call_3's slot
expect(continuation.actualIndex).toBe(2);

const completed = parser.getCompletedToolCalls();
expect(completed.find((tc) => tc.id === 'call_3')?.args).toEqual({
c: 3,
});
expect(completed.find((tc) => tc.id === 'call_2')?.args).toEqual({
b: 2,
});
});

it('should handle rapid tool call switching at same index', () => {
// Rapid switching between different tool calls at index 0
parser.addChunk(0, '{"step1":', 'call_1', 'function1');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ export class StreamingToolCallParser {
private namelessToolCallIndices = new Set<number>();
/** Map from tool call ID to actual index used for storage */
private idToIndexMap: Map<string, number> = new Map();
/** Remapped slots awaiting a stable ID from a later chunk. */
/**
* Maps a provider index to the actual slot it was remapped to on collision.
* Two readers consume it: an id that arrives after its name/args adopts the
* slot, and later id-less continuation chunks at the same provider index are
* routed to it.
*/
private pendingIndexRemaps: Map<number, number> = new Map();
/** Counter for generating new indices when collisions occur */
private nextAvailableIndex: number = 0;
Expand Down Expand Up @@ -110,10 +115,18 @@ export class StreamingToolCallParser {
if (this.idToIndexMap.has(id)) {
// We've seen this ID before, use the existing mapped index
actualIndex = this.idToIndexMap.get(id)!;
} else if (this.pendingIndexRemaps.has(index)) {
// Some providers stream name or arguments before the stable ID.
} else if (
this.pendingIndexRemaps.has(index) &&
!this.toolCallMeta.get(this.pendingIndexRemaps.get(index)!)?.id
) {
// Some providers stream name or arguments before the stable ID. Only
// adopt the remapped slot while it has not yet been claimed by an id:
// once it has one, the remap only exists to route later id-less
// continuation chunks, so a brand-new id must not hijack that slot.
// The remap is deliberately left in place — the re-registration on the
// common path below keeps `index -> actualIndex` alive so subsequent
// id-less continuation chunks at this provider index still resolve here.
actualIndex = this.pendingIndexRemaps.get(index)!;
this.pendingIndexRemaps.delete(index);
this.idToIndexMap.set(id, actualIndex);
} else {
// New tool call ID
Expand Down Expand Up @@ -206,7 +219,7 @@ export class StreamingToolCallParser {
} else {
this.namelessToolCallIndices.delete(actualIndex);
}
if (!meta.id && actualIndex !== index) {
if (actualIndex !== index) {
this.pendingIndexRemaps.set(index, actualIndex);
}
return { actualIndex, complete: false };
Expand Down Expand Up @@ -236,7 +249,7 @@ export class StreamingToolCallParser {
meta.name = validName;
}
}
if (!meta.id && actualIndex !== index) {
if (actualIndex !== index) {
this.pendingIndexRemaps.set(index, actualIndex);
}
Comment on lines 249 to 254

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 common-path remap recording unconditionally overwrites an existing pendingIndexRemaps entry when a third tool call collides at the same provider index. While this 3+ collision scenario is pre-existing (the old code also fails via slot hijacking), guarding the overwrite would prevent the failure without breaking the 2-call fix.

Failure scenario: three tool calls at provider index 0 with content-bearing openers -- call_2 at slot 1 (incomplete args), call_3 at slot 2 (incomplete args). Remap 0->1 is overwritten to 0->2. An id-less continuation for call_2 routes to slot 2 (call_3's incomplete buffer) and is appended there, corrupting call_3's args and leaving call_2 incomplete.

Suggested change
meta.name = validName;
}
}
if (!meta.id && actualIndex !== index) {
if (actualIndex !== index) {
this.pendingIndexRemaps.set(index, actualIndex);
}
if (actualIndex !== index && !this.pendingIndexRemaps.has(index)) {
this.pendingIndexRemaps.set(index, actualIndex);
}

-- qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks — I looked at this closely and verified it empirically, and I'm going to keep the unconditional overwrite. The suggested guard (!pendingIndexRemaps.has(index)) keeps the first remap, but an id-less continuation is supposed to belong to the most recent opener at that provider index (the sequential-completion assumption the whole collision path relies on). Keeping the first mapping pins the remap at call_2's slot, so a continuation after call_3's opener is misrouted to call_2.

I added a regression test for exactly the well-formed three-call case (6b62711): call_1@0, call_2 opener→slot 1 with its args, call_3 opener→slot 2, then an id-less {"c":3} that must land on slot 2. It passes with the overwrite and fails with the suggested guard (the continuation lands on slot 1 — the findMostRecentIncompleteIndex fallback doesn't recover it). So the guard would trade the pre-existing 3-call interleaved-corruption case (which it only half-fixes — it moves the corruption from call_2 to call_3) for a regression in the well-formed 3-call case. The interleaved-malformed 3-call case is genuinely pre-existing and out of this PR's 2-call scope.


Expand Down
Loading