fix: stop emitting duplicate tool-call events on trailing-whitespace deltas#489
Merged
robert-j-y merged 1 commit intoApr 28, 2026
Conversation
…deltas The streaming chat handler merge-into-existing-tool-call path emits a tool-call event whenever the accumulated function.arguments is parsable JSON. Because JSON.parse accepts trailing whitespace, any subsequent argument delta for the same index (e.g. a stray space, newline, or closing-token chunk) leaves the arguments parsable and re-triggers the emit, producing a second tool-call event with the same toolCallId. Downstream tool runners (e.g. Vercel AI SDK streamText) then execute the tool twice. Observed in production with moonshotai/kimi-k2.6 via OpenRouter, where the user-visible effect was every outbound message being delivered twice. Trace fingerprint: paired tool-call events with identical toolCallId and inputs differing only by a trailing space. Gate the merge-path tool-call emit on !toolCall.sent, mirroring the new-path behavior. The sent flag was already being set after the first emit but was never read on this path. Adds a regression test under describe(includeRawChunks).
Contributor
|
Cool! |
Merged
3 tasks
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
The streaming chat handler merge-into-existing-tool-call path emits a
tool-callstream event whenever the accumulatedfunction.argumentsis parsable JSON. BecauseJSON.parseaccepts trailing whitespace, any subsequent argument delta for the same tool-callindex— a stray space, newline, or closing-token chunk — leaves the arguments parsable and re-triggers the emit. The result is a secondtool-callevent with the sametoolCallIdas the first.Downstream tool runners (e.g. Vercel AI SDK
streamText) treat the two events as two independent tool calls and execute the tool twice.Where I observed this
Production traffic using
moonshotai/kimi-k2.6via OpenRouter from a Vercel AI SDKstreamTextagent loop. The user-visible effect was every outbound message being delivered twice, every shell command running twice, etc. Once the model saw the duplicate in history it would attempt to apologize via the same tool, and that apology was duplicated too — producing a self-reinforcing loop the model could not escape because the duplication was happening downstream of its own output.The braintrust trace fingerprint:
functions.bash:15' {"command": "cat MEMORY.md | head -30"}'functions.bash:15' {"command": "cat MEMORY.md | head -30"} '← trailing spacefunctions.send:1' {"actions":[…"apologies — duplicated the send there. fixed."}]}'functions.send:1' {"actions":[…"apologies — duplicated the send there. fixed."}]} '← trailing spaceEvery duplicate pair we saw differed only by trailing whitespace, which is exactly the signature of merge-path re-emission.
Root cause
In
src/chat/index.ts, the streaming chunk handler has two code paths for tool-call deltas:toolCalls[index] == null) — creates the slot, dedupes the id viaseenToolCallIds, emitstool-callif the arguments are already complete, setstoolCall.sent = true.argumentsand emitstool-callif the result is parsable JSON.The merge-path never reads
toolCall.sent. It only sets it after emitting. So once the new-path has emitted a complete tool call and setsent = true, any subsequent argument delta for the sameindexwhose appended result is still parsable JSON (which trailing whitespace trivially is) re-emits the tool call.The existing
seenToolCallIdsdedup at lines 930-933 doesn't help here because (a) it lives in the new-path, and (b) the duplicate emit reuses the sametoolCallIddeliberately — it is the same logical tool call.Fix
Gate the merge-path
tool-callemit on!toolCall.sent, mirroring the new-path behavior. The flag was already being set; this just teaches the merge-path to read it.Test plan
src/chat/index.test.tsunderdescribe('includeRawChunks')that streams a complete tool call followed by a trailing-whitespace-only argument delta for the sameindex, and asserts exactly onetool-callevent is emitted.main(1 tool-call event expected, 2 received).pnpm test:nodesuite passes (423/423, including the existing parallel/multi-chunk tool-call tests).pnpm typecheckclean.pnpm stylecheck(biome) clean.patchchangeset.Suggested review order
src/chat/index.ts— one-line guard added (plus a comment explaining the trailing-whitespace gotcha).src/chat/index.test.ts— regression test demonstrating the bug..changeset/fix-streaming-merge-path-duplicate-tool-call.md.Notes