Uh oh!
There was an error while loading. Please reload this page.
fix(session): stop creating phantom "unknown" tool parts on re-emitted deltas - #44535
fix(session): stop creating phantom "unknown" tool parts on re-emitted deltas#44535internetisalie wants to merge 1 commit into
Conversation
…d deltas When a provider re-sends `tool_calls[i].function.arguments` deltas for a call that already produced its result, the AI SDK emits tool-input-delta / tool-input-end for a call id the processor has already settled. Two things then go wrong: - the adapter deleted `state.toolNames[callID]` on tool-result, so the late chunks resolve to the literal name "unknown" - `ensureToolCall` finds no live call and creates a second pending part for the same call id, which `cleanup` then sweeps into `error: "Tool execution aborted"`, `metadata.interrupted: true` The result is a phantom part paired 1:1 with a real completed one. Both serialize into the next request, so the assistant turn carries two tool_use blocks sharing a toolCallId, one of them named "unknown". Track settled call ids on the processor context and refuse to mint a new part for them; keep tool names for the life of the stream so late chunks still resolve; and dedupe tool parts by call id when building model messages, which is what heals sessions already recorded with the duplicate.
The following comment was made by an LLM, it may be inaccurate: Based on my search results, I found one potentially related PR: Related PR:
However, this related PR (#33622) appears to be a different approach to the same underlying problem. Your PR (#44535) is the current PR being analyzed, and the search confirms there are no other open PRs that are duplicates of this fix. The related PR might be a previous or alternative attempt at the same issue. No duplicate PRs found |
Enough1122
commented
Aug 24, 2026
Overall: nice root-cause fix with layered defenses — keeping tool names for the stream lifetime stops the immediate
The |
Issue for this PR
Closes#33618
Type of change
What does this PR do?
The phantom
unknowntool calls in #33618 are created by opencode, not emitted by the model. laradji's measurement in that issue (~21% of tool-part events, 0 orphans, everyunknownpaired with acompletedpart on the samecallID) is exactly what this code path produces.The sequence:
tool-resultarrives, the processor marks the partcompleted, andsettleToolCalldrops the entry fromctx.toolcalls. In the AI SDK adapter,tool-resultalso doesdelete state.toolNames[toolCallId].tool_calls[i].function.argumentsdeltas for that same call id, without repeating.function.name. The AI SDK turns those intotool-input-delta/tool-input-end."unknown"(llm/ai-sdk.ts,state.toolNames[event.id] ?? "unknown").tool-input-deltaandtool-input-endcallensureToolCall. It finds no live call for the id and creates a brand new pending part namedunknown.cleanupsweeps every still-pending call intostatus: "error",error: "Tool execution aborted",metadata.interrupted: true.That is the whole reported symptom, including
input: {}andraw: "".The TUI-side escalation is a second-order effect. Both parts are replayed by
toModelMessagesEffect, so the next request carries twotool_useblocks with the sametoolCallId, one of them namedunknown. That is what providers reject, and it is a plausible explanation for the reported loops where the model itself starts calling a tool namedunknown— it is copying a tool name it can see in its own previous turn. (The duplicate id is verified; the copying is inference.)Nothing here is specific to a model version, which matches reports against 3.7 Plus, 3.7 Max and 3.8 Max.
The changes:
session/processor.ts— the actual defect.ProcessorContextgains asettledset of call ids that reached a terminal state;ensureToolCallrefuses to create a fresh part for one. Cleared next toctx.toolcallsincleanup, so it has the same lifetime as the map it guards.session/llm/ai-sdk.ts— stop deletingtoolNamesontool-result/tool-error. Late chunks then resolve to the real name instead of"unknown". The map is bounded by the number of tool calls in one stream.session/message-v2.ts— skip a tool part whosecallIDwas already emitted for that assistant message. This is the only part of the change that helps sessions already recorded with the duplicate; the two fixes above cannot clean up rows that are already on disk.The first change alone stops new phantoms. The third is what makes an affected session usable again without editing the database.
How did you verify your code works?
Live against the real provider.
opencode run --format jsononopenrouter/qwen/qwen3.8-max, same prompt each time (a small TDD exercise: write two source files plus tests, runbun test, read the files back, add a third module and rerun). Three runs on the shipped v1.18.21 binary, three on this branch, then grouped thetool_useparts bycallID:before (v1.18.21)
unknownafter (this branch)
unknownZero orphans on both sides: every phantom was paired with a
completedpart on the samecallID, which is what laradji measured. The rate here (35%) runs a bit higher than the ~21% reported, likely because this prompt is tool-dense.A sanitized pair from one before-run, same
callID, emitted 0.3s apart:{"type":"tool_use","part":{"type":"tool","tool":"write","callID":"call_8f053c86…", "state":{"status":"completed","input":{"filePath":"…/src/greet.test.ts","content":"…"}, "output":"Wrote file successfully."}}} {"type":"tool_use","part":{"type":"tool","tool":"unknown","callID":"call_8f053c86…", "state":{"status":"error","input":{},"raw":"","error":"Tool execution aborted", "metadata":{"interrupted":true}}}}One before-run also produced a phantom on top of a call whose real part was the
invalidsentinel, so the two paths stack rather than being alternatives.Regression tests. Two, both mutation-checked — I reverted each fix on its own and confirmed the matching test goes red, so neither is a test that cannot fail:
test/session/processor-effect.test.ts— stubs anLLM.Servicestream that completescall-1, then replaystool-input-delta/tool-input-endforcall-1with no name. Before the fix the message ends up with[{tool: "lookup", status: "completed"}, {tool: "unknown", status: "error"}]; after it, only the first.test/session/message-v2.test.ts— an assistant message holding both parts for onecallIDnow serializes to a singletool-call/tool-resultpair instead of twotool_useblocks sharing an id.Suites on this branch.
bun test test/session— 413 pass, 0 failbun test test/tool test/server test/cli— 1002 pass, 1 fail:tool.write > sets file permissions when writing sensitive data. That one fails identically on a clean checkout ofdevon my machine (umask-related), so it is pre-existing and unrelated to this change.tsgo --noEmitclean;oxlintreports 0 errors on the three changed source files.Screenshots / recordings
n/a — no UI change.
Checklist