Skip to content

fix: stop emitting duplicate tool-call events on trailing-whitespace deltas#489

Merged
robert-j-y merged 1 commit into
OpenRouterTeam:mainfrom
0age:fix/streaming-merge-path-duplicate-tool-call
Apr 28, 2026
Merged

fix: stop emitting duplicate tool-call events on trailing-whitespace deltas#489
robert-j-y merged 1 commit into
OpenRouterTeam:mainfrom
0age:fix/streaming-merge-path-duplicate-tool-call

Conversation

@0age

@0age 0age commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Summary

The streaming chat handler merge-into-existing-tool-call path emits a tool-call stream event whenever the accumulated function.arguments is parsable JSON. Because JSON.parse accepts trailing whitespace, any subsequent argument delta for the same tool-call index — a stray space, newline, or closing-token chunk — leaves the arguments parsable and re-triggers the emit. The result is a second tool-call event with the same toolCallId as 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.6 via OpenRouter from a Vercel AI SDK streamText agent 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:

Tool call (same id) input
functions.bash:15 ' {"command": "cat MEMORY.md | head -30"}'
functions.bash:15 ' {"command": "cat MEMORY.md | head -30"} 'trailing space
functions.send:1 ' {"actions":[…"apologies — duplicated the send there. fixed."}]}'
functions.send:1 ' {"actions":[…"apologies — duplicated the send there. fixed."}]} 'trailing space

Every 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:

  1. New-path (toolCalls[index] == null) — creates the slot, dedupes the id via seenToolCallIds, emits tool-call if the arguments are already complete, sets toolCall.sent = true.
  2. Merge-path (existing slot) — concatenates the incoming arguments and emits tool-call if 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 set sent = true, any subsequent argument delta for the same index whose appended result is still parsable JSON (which trailing whitespace trivially is) re-emits the tool call.

The existing seenToolCallIds dedup at lines 930-933 doesn't help here because (a) it lives in the new-path, and (b) the duplicate emit reuses the same toolCallId deliberately — it is the same logical tool call.

Fix

Gate the merge-path tool-call emit on !toolCall.sent, mirroring the new-path behavior. The flag was already being set; this just teaches the merge-path to read it.

if (
  !toolCall.sent &&
  toolCall.function?.name != null &&
  toolCall.function?.arguments != null &&
  isParsableJson(toolCall.function.arguments)
) {
  // ...emit tool-input-end + tool-call...
  toolCall.sent = true;
}

Test plan

  • Added regression test in src/chat/index.test.ts under describe('includeRawChunks') that streams a complete tool call followed by a trailing-whitespace-only argument delta for the same index, and asserts exactly one tool-call event is emitted.
  • Confirmed the new test fails on main (1 tool-call event expected, 2 received).
  • Confirmed the new test passes with the fix.
  • Full pnpm test:node suite passes (423/423, including the existing parallel/multi-chunk tool-call tests).
  • pnpm typecheck clean.
  • pnpm stylecheck (biome) clean.
  • Added a patch changeset.

Suggested review order

  1. src/chat/index.ts — one-line guard added (plus a comment explaining the trailing-whitespace gotcha).
  2. src/chat/index.test.ts — regression test demonstrating the bug.
  3. .changeset/fix-streaming-merge-path-duplicate-tool-call.md.

Notes

  • This shouldn't affect any provider that flushes tool-call args in a single chunk or whose final arg-fragment is the closing brace (so parsability becomes true exactly once on the final delta). It will silence a class of duplicate emits for providers that emit a trailing-whitespace or trailing-token chunk after a complete tool call — which appears to include current Moonshot/Kimi routing on OpenRouter.
  • I did not file an issue first; happy to do so if preferred. The trace evidence is included in this PR description for visibility.

…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).
@robert-j-y

Copy link
Copy Markdown
Contributor

Cool!

@robert-j-y robert-j-y merged commit bb2d4cb into OpenRouterTeam:main Apr 28, 2026
1 check passed
@github-actions github-actions Bot mentioned this pull request Apr 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants