fix(runtime): resolve a streamed tool call delta against the aliases that still claim it - #1995
Merged
Merged
Conversation
Astro-Han
force-pushed
the
fix/1976-streamed-tool-call-identity
branch
from
August 3, 2026 12:15
5fb079b to
dc7acbe
Compare
A gateway that labels two distinct streamed tool calls with the same `tool_calls[].index` had them merged into one: arguments concatenated into invalid JSON, the second call's `id` and `name` dropped, and nothing errored. Ollama labels every tool call in a turn with `index: 0` (vercel/ai#14277), and all 45 `openai-compatible` registry entries are `status: 'ready'`, `ollama` and `ollama-cloud` among them. This and the #1967 crash are one modelling defect in `StreamingToolCallTracker`: `index` is an association label a gateway may omit, repeat, or number freely, and it was serving as the storage slot, the identity, and the ordering at once. The class declares `private toolCalls: TrackedToolCall[]` while indexing it by that external number, so the type hid that the array was sparse. Separate the three jobs. Records live in a dense array in creation order; `byId` and `byIndex` alias them; `activeToolCall` tracks the call the stream last touched. `processDelta` resolves an explicit `id` first, falls back to `index` for continuations that carry no `id`, and only then to `activeToolCall` — not to "the last created call", since nothing finishes before the stream ends and so `hasFinished` cannot discriminate mid-turn. `flush()` emits in index order only when every call has a unique index, because a repeated or missing index cannot order anything and sorting by it would let a malformed index reorder the turn. This replaces the #1967 sparse-slot patch rather than stacking on it: the four existing guards stay green with that hunk gone. Known boundary, locked in by test: `@ai-sdk/openai-compatible` keeps its own index-keyed buffer ahead of the tracker, so a reused index whose new call has not sent its `name` yet fails the turn instead of merging. Failing loudly is the intent; recovering needs both layers folded together. No known gateway sends that shape. We are not upstreaming this, so `patches/README.md` now states a deletion condition that does not wait on a release: drop the patch, reinstall, and the 13-case guard answers whether it is still needed. Fixes #1976. Refs #1967.
Review found that the previous commit fixed the reported defect by promoting `id` to sole authority, which reproduced the same defect with the two fields swapped. Three regressions against unpatched upstream, all verified through the real provider stack on both the `openai-compatible` and `openai` paths: - A repeated `id` (including `''`) across two calls at distinct indices merged them into one call with concatenated, invalid JSON input — the #1976 symptom itself. With arguments split across chunks the fragments interleaved (`{"path"{"path":"a"}:"b"}`), which the previous test's `includes('}{')` assertion could not see. - `id: ''` on a continuation delta read as a new identity with no name and killed the whole turn. Gateways send `''` instead of omitting the field. - A delta carrying neither field was absorbed by `activeToolCall`, silently handing one call's arguments to another. Unpatched upstream throws here. `id` is exactly as untrustworthy as `index`. Neither is accepted alone now: when both are present the index selects the slot and the `id` decides whether that slot still holds the same call, so a repeated `id` at another index cannot absorb a delta and a repeated index under a new `id` cannot merge. `''` normalizes to absent. A delta with neither field is attributable only when one call is open; with several it is undecidable and falls through to fail on the absent `id` rather than guessing. This drops `activeToolCall` entirely — with guessing removed it has no job — and records the creation index on each call instead, which collapses the ordering pass to one stable sort. The redundant `byIndex` write in `processNewToolCall` is gone; `processDelta` already covers every path to it. Tests: 5 new cases (repeated `''` and `'dup'` ids, empty-id continuation, and two on the previously uncovered `openai` path). The bare-delta case now asserts the failure it used to assert an attachment for. Merge detection is a property, `assertInputsSelfContained` — every emitted input must parse alone — because the old exact-output check missed interleaving. `patches/README.md`: the stated deletion condition was unsatisfiable, since two cases assert an undecidable shape fails and a genuinely fixed upstream would make them pass, turning the guard red for a better implementation. It now reads by property. Also corrected: the patch survives a dependency bump only as a blocked install, and only the `dist/` half is load-bearing. Refs #1976, #1967.
Second review round found the previous rule still merged distinct calls, on both provider paths, in the intersection of two shapes this patch already treats as real: a gateway that reuses one index AND repeats or blanks the id. Then `index` and `id` both agree and `function.name` is the only discriminator left, so ignoring it produced concatenated invalid JSON — verbatim the #1976 symptom the patch exists to close. The dup-id tests added last round certified that shape as fixed while hiding it, because they asserted only `{toolName, input}` and projected `toolCallId` away. That field decides the outcome: `tool-runtime.ts` derives `operationId` from it, so two calls sharing an id collide and the second `commitToolPrepared` is rejected after the first tool's side effects have run, and an empty id throws out of `runtime-commit-sink.ts` before the call is recorded at all. A test named `keeps calls distinct` passed while the calls were indistinguishable everywhere that matters. A delta now continues a call only when every identifying field it carries agrees — `id` with `''` read as absent, and `function.name`. Candidate selection still comes from the strongest alias present (index, then id, then the single open call), so this is one predicate over three lookups rather than three bespoke conditions. An id that cannot address a call — absent, empty, or taken — is replaced with a generated one, which also makes upstream's `_generateId()` fallback reachable for the first time; it was dead behind a throw. Ordering also required uniqueness, not just presence: a duplicate index no longer orders anything, and a missing one turns the comparator into NaN comparisons that scramble instead of preserving. Deletions, all verified by mutation rather than asserted: - The `src/` half of the patch. Nothing compiles it and `exports` resolves to `dist`, so it was a second implementation to keep in sync, and it had already drifted. Patch drops from 246 to 120 lines. - Three redundant cases: the `index: 7` arm (identical path to `index: 1`), the two-call reused-index arrival-order case (already pinned by the Ollama-shape deepEqual), and the `''` arm of the different-index dup-id loop (detected no mutation the file misses). Tests: 23 cases, every one carrying detection power confirmed by mutating the tracker. Both zero-coverage load-bearing branches are now pinned — the `byId` lookup, and the index-presence guard in ordering. The latter needed five calls with arrival order unlike index order; at three, V8's insertion sort leaves a scrambling comparator looking correct, so the smaller case I first wrote proved nothing. Merge and addressability are properties (`assertInputsSelfContained`, `assertToolCallIdsUsable`) asserted by every multi-call case. `patches/README.md`: corrected a self-contradiction (claimed neither field is accepted alone, then described id-only and index-only lookups), restated the deletion condition to include duplicate and empty ids, and noted that `apply-dependency-patches.mjs` deliberately skips trees where `patch-package` is unresolvable, so "blocked install" is not unconditional. Refs #1976, #1967.
The previous round replaced an unusable wire `id` with a generated one and stored only that, then compared the wire `id` a later delta carried against it. Those can never be equal, so a gateway that repeats one `id` across calls and echoes it on their continuations made every continuation unresolvable: it became a new call with no `function.name` and threw, killing a turn upstream had survived. Two more shapes died the same way, an `id` or an `index` that arrives only on a later delta. A record now keeps the aliases the wire used for it — `index`, and `id` with `''` read as absent — separately from the `id` we emit, and a delta resolves to the most recently created call every alias it carries agrees with. An alias the candidate has never seen cannot disagree, and at least one must actually match, so an index no call has claimed is not absorbed. That replaces the two single-valued lookup maps, which could not hold a repeated alias at all, and drops the priority order between them: contradicting aliases now resolve to nothing rather than to whichever field was consulted first. The guard asserted only the final `tool-call`, so every `tool-input-*` enqueue could be deleted from the tracker with all of it still green. It now asserts the event contract as a property — ordered, same id, deltas reconstructing the input — and drops two cases that detected nothing. Shapes where one alias is shared by two open calls cannot be demultiplexed at all; they get their own suite asserting what does hold, because `patches/README.md` names input self-containment as a deletion criterion and it was never true for them. Refs #1967, #1976
The previous commit dropped both late-alias bindings on the grounds that no wire shape needed them. The evidence for that was "delete them and the suite stays green", which is the same reasoning this PR rejected one commit earlier when it deleted two tests for detecting nothing: green means the guard is silent, not that the code is idle. Both deletions were wrong. A record that never learns an alias stops being addressable by it. When a call's first delta omits `id`, a later delta supplies it, and a third carries only that `id`, the lookup finds no record; with another call open the bare delta fallback does not apply either, so the turn dies on the missing `function.name`. And `flush()` orders by index only when every record has one, so a call that supplies its index on a continuation left the whole turn in arrival order — reversing the tool execution order the gateway asked for. Both shapes are now cases, and each kills exactly the binding it needs. Refs #1967, #1976
Round four found #1976 still open on its own headline shape. Three calls all labelled `index: 0` with the tool name repeating — read, write, read — emitted two calls: the first with `{"path":"p0"}{"path":"p2"}`, unparsable, and the third gone with no error. Four alternating calls lost two. Both provider paths. Resolution walks the records newest-first, and a `function.name` mismatch was skipping a candidate and continuing the scan. So the delta ran past the current claimant of index 0 and reached the `read_file` record whose claim that call had already superseded, filing a whole new call as its continuation. A name that disagrees with the claimant now rejects the delta instead, which starts the new call the gateway asked for. This is not one of the undecidable shapes: index 0 was demonstrably reclaimed. Every case until now used two distinct names, which is why three rounds of review and 25 mutations never saw it. Two more shapes were worse than the code being replaced, both now fixed by one narrower fallback: an `index` or an `id` arriving alone on a continuation, which upstream resolved by array slot. The fallback fires when exactly one call exists and no alias the delta carries disagrees with it, so an alias that contradicts the lone call still fails rather than being absorbed. Deleted: `findByAlias`'s leading early return, provably unreachable as a decision — no alias means no match either way. Guard additions, each killing a mutant that survived: the three-call repeated name on both paths; an alias arriving alone; a differently named call with no alias, which the fallback must not absorb; `index: null`, which `@ai-sdk/openai-compatible` declares legal and which must not sort ahead of real indices; and an id and an index that each disagree with the lone open call. `assertEventLifecycle` now checks that the start announces the name the call runs, and only tolerates an unfinished call when the turn actually failed — a dropped call takes exactly that shape. Refs #1967, #1976
…entity `id` normalized `''` to absent; `function.name` did not. `name` is the field that rejects a match rather than proposing one, so a gateway that blanks it on a continuation delta — the same gateways that blank `id` — had its fragment rejected by the call it was continuing and filed under a new call named `""`. Two calls became four, half of them unnameable. Unpatched upstream handled that shape correctly, so the patch was making it worse. Normalize every optional field once, at the top of `processDelta`, and let the new-call path validate and store the normalized name too, so `''`, `null`, and absent behave identically in all three places instead of two. Also drop the `=== void 0` conjuncts guarding late alias learning: a call returned by resolution has, for each alias the delta carries, either no value or an equal one, so the assignment is already either learning or a no-op. Re-adding them survives all 50 cases, which is what an equivalent mutant looks like. Three shapes the guard did not reach, found by mutation rather than by review: a blank name on a continuation and on a first delta, and a duplicated wire id separated from its original by an unrelated call — minting scans every emitted id, and checking only the previous one survived the whole suite. `patches/README.md` claimed a reused index with a shared id and name kept both calls. It loses one, exactly like the no-index shape. Both are now table rows with cases behind them, along with the shape this patch invents a call for, which is the price of stopping resolution at an alias's newest claimant. Refs #1967, #1976.
…right The loud-failure boundary was described only as "a reused index whose new call has not sent its name yet". The same bytes also read as one call whose id drifted between its own fragments, and upstream continues that correctly — by never looking at id, which is also why it cannot separate two calls sharing an index. One reading has to lose; the one with observed gateways behind it wins. Refs #1976.
The last round normalized `''` to absent for `id` and `function.name`. A whitespace-only value is the same thing with different bytes, and it was still being read as a claim: `name: " "` disagreed with the call it was continuing, so the fragment became a new call nobody can name, and `id: " "` contradicted its own call and killed the turn. Reverse-applied upstream emits the correct single call in both shapes, on both provider paths, because it never consults either field — so the patch was again worse than what it replaces. Same defect found twice in two rounds, so state the rule as a property of the value rather than a list of literals: blank means absent, where blank is `null`, `''`, or nothing but whitespace. One named helper at the single normalization point, which also shortens both derivations. The guard was blind to the whole axis: the fix survives all 50 prior cases as a mutant. Cases now cover empty and whitespace on each of the two fields, and the new-call path on both spellings. The `id`-and-`name`-together case is deleted — the mutants it killed are a strict subset of the two single-field cases'. `patches/README.md` claimed every optional field is normalized. Only the two aliases are; `arguments` and `type` are deliberately untouched, and `arguments: ''` still emits an empty `tool-input-delta` where absent emits nothing. Narrowed to what is true and says why the rest is left alone. Refs #1967, #1976.
Five review rounds argued about the association rule; two of them found the same defect in the value domain of the alias fields. So that axis got measured: 2448 runs over ten base shapes crossed with empty, whitespace, invisible, padded, case-shifted, long and single-character values at every position a call starts or continues, plus pathological index values, each against the same shape on reverse-applied upstream. No failure class beyond the two already documented. Every shape the patch loses is a blank name starting a call (deliberate throw) or a differing alias on a continuation (deliberate new identity), and upstream wins them only by ignoring the fields it would need to read to fix #1976. Labelled as a one-off record rather than a guarantee, since it is not committed and does not run in CI — the guard is what keeps holding. Also: #1967's crash was understated. A negative or fractional index makes upstream drop the call silently, and 1e6 kills the turn. Refs #1967, #1976.
Astro-Han
force-pushed
the
fix/1976-streamed-tool-call-identity
branch
from
August 3, 2026 15:32
e8dfc7d to
1af7a0e
Compare
Astro-Han
marked this pull request as ready for review
August 3, 2026 15:37
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A gateway that labels distinct streamed tool calls with the same
tool_calls[].indexhad them merged: arguments concatenated into invalid JSON, the second call'sidandnamedropped, and nothing errored. Ollama labels every tool call in a turn withindex: 0(vercel/ai#14277); all 45openai-compatibleregistry entries arestatus: 'ready',ollamaandollama-cloudamong them.This and the #1967 crash are one modelling defect in
StreamingToolCallTracker:indexis an association label a gateway may omit, repeat, or number freely, and it served as the storage slot, the identity, and the ordering at once. The class declaresprivate toolCalls: TrackedToolCall[]while indexing it by that external number, so the type hid that the array was sparse.The fix is not "identity lives in
id". That was attempt one, andidis equally untrustworthy — gateways repeat it across calls and send''for absent — so keying on it reproduced #1976 with the fields swapped. Four review rounds got to the current rule; see Root cause for what each one caught.So the tracker keys on nothing. Records live in a dense array in creation order, each learning the aliases the wire uses for it (
index, andidwith''andnullread as absent) separately from theidwe emit. Resolution scans newest-first and stops at the first call one of the delta's aliases claims: a candidate whose alias contradicts the delta is skipped, a candidate that has never seen an alias the delta carries is not contradicted by it, and at least one alias must actually match. Once a claimant is found, afunction.namethat disagrees rejects the delta — continuing the scan reaches a call whose claim the claimant already superseded.Consequences worth naming:
indexoridarriving alone on a continuation from being worse than unpatched, which resolved it by array slot.id— absent, empty, or already emitted — is replaced by a generated one, becausetool-runtime.tsderivesoperationIdfrom it. But the wire'sidstays as an alias: gateways that repeat anidalso echo it on continuations, and comparing those against the generated id killed turns in round two. (This leaves upstream'stoolCall.id ?? this._generateId()provably dead; minting happens at creation.)flush()emits in index order only when every call supplied an index and those indices are unique.This replaces the #1967 sparse-slot patch rather than stacking on it. Not upstreamed by decision, so the README's deletion condition does not wait on a release.
Closes #1976. Refs #1967.
Verification
openai-compatibleandopenaipaths. Unpatched, 34 of 41 fail.matchedrequirement, reverse scan order, the fallback and each of its three conditions separately,''-as-absent,null-as-absent, both minting clauses, both late-alias bindings, both sort guards, and each of the fourtool-input-*enqueues including the name the start announces.provider-conformance+provider-contract-matrix+deferred-tools-wire+deferred-tools-backend— 154/154.@maka/runtimesuite — 2812 tests, 4 failures, identical to the failure set onmain(allbuiltin-tools.test.js, the macOS/private/vartmpdir symlink issue).851fce4);patch-package --error-on-failre-applies to that pristine tree cleanly;npm ciapplies it viapostinstall.npm run lint,npm run format:check,@maka/runtime run typecheck— clean.Root cause
Unpatched upstream vs. this branch, through the real provider stack with a fake
fetch:index: 0'{"path":"a"}{"path":"b"}'; neither tool runsindex: 0, tool name repeatingindex, call spans chunksExpected 'id' to be a string.Downstream, merged invalid JSON reached
repairMakaToolCall, which rewrote it toINVALID_TOOL_NAME— so those shapes were not literally silent, but the error blamed the model's arguments for a transport defect and the natural retry reproduced the merge. The empty-args shape was genuinely silent.What each review round caught in the previous round's fix, all verified against unpatched upstream on both paths:
idpromoted to sole identityid+ different namesidtoolCallIdemitted twiceidechoed on continuationsidorindexarriving only on a later deltaindexoridarriving alone on a continuationRound 4's P1 is the sharpest lesson: it is not undecidable — index 0 had demonstrably been reclaimed — and it is the exact shape the PR headlines. It survived three review rounds and 25 mutations because every case until then used two distinct tool names.
Three defects were hidden by the tests themselves. The dup-id cases asserted only
{toolName, input}, projecting away thetoolCallIdthat decides the outcome downstream. The guard asserted only the finaltool-call, so all fourtool-input-*enqueues could be deleted with every case green. AndassertEventLifecycleaccepted an unfinished call unconditionally — which is exactly the shape a silently dropped call takes — and never checked that the start announced the name the call runs.Deletions
Each verified by removing it and running the suite:
src/half of the patch. Nothing compiles it andexportsresolves todist. Patch: 246 → 143 lines.findByAlias's leading early return — unreachable as a decision, since no alias means no match either way.hasFinishedfilter in the fallback —finishToolCallis only reachable fromflush(), so nothing is finished while deltas are processed.keeps calls distinct when both reuse id "dup" at different indicesthat passed against unpatched upstream and were killed by no mutation, while claiming to pin a priority order that no longer exists.No production line in the patched class is deletable. Each was prototype-deleted and produced a concrete failing shape or a red case.
Review focus
The remaining wrong shapes share one cause — an alias claimed by two calls at once, with the continuation carrying only that alias. The fragment goes to the most recent claimant: required for the sequential shape Ollama produces, wrong for an interleaved one, and nothing in the stream distinguishes them.
patches/README.mdtabulates them with what survives per shape, because it differs: three keep both calls and misplace only argument text, butsame id + same name + no indexloses a call. That row is why the guard's boundary suite states a per-shape expectation instead of one shared assertion — the earlier blanket claim "every call is emitted once" was false.Two accepted non-fixes, both documented. A delta contradicting every record becomes a new call rather than an error, which can add a call the gateway did not send. And after a chunk the provider rejects,
flush()still finalizes, so an already-failed turn can carry onetool-callwith truncated arguments — upstream emitted nothing there only because the sparse-array crash got in first, finalizing inflush()is its documented design, andrepairMakaToolCallrejects the unparsable input.Prevalence is a gap, stated plainly. No deployed gateway is confirmed to emit duplicate or empty tool-call ids, to echo a duplicated id on continuations, or to supply an alias only on a continuation; the repo holds no captured gateway wire samples, so every shape is hand-authored, and even the index-reuse shape this PR headlines rests on an upstream report. The divergences from unpatched upstream stand regardless of prevalence.
Two subtleties worth knowing when editing this. The index-presence guard in
flush()needs five calls with arrival order unlike index order to be observable — at three, V8's insertion sort leaves aNaN-comparing comparator looking correct. And name-related defects need three calls to surface, because at two the names are always distinct.