[Bug]: ClaudeAdapter finalize/tool_result race silently drops tool output
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 23k
- Forks
- 5.9k
- Avg merge
- 11h 14m
- Merged PRs (30d)
- 357
Description
Before submitting
- I searched existing issues for duplicates; I did not find one covering
inFlightToolsbeing cleared before a latetool_resultarrives. - I included enough detail (exact code path and interleaving).
Area
apps/server
Steps to reproduce
No deterministic repro — this is a code-level race I found by inspection. The interleaving:
- Claude Code emits
tool_usefor a tool;ClaudeAdapterregisters it incontext.inFlightTools. completeTurnruns before the tool'stool_resulthas been processed. This happens when the SDKresultmessage is delivered first, or — more plausibly — when finalize is driven from a path that is genuinely concurrent with the SDK stream: interrupt, stream exit, session stop, or a non-steering turn handoff.completeTurnforce-emitsitem.completedfor the still-in-flight tool withdata: { toolName, input }and noresult, deletes the entry, then clears the map.- The real
tool_resultusermessage is processed afterwards. TheinFlightToolslookup bytoolUseIdmisses and the handlercontinues.
Net effect: the tool's output is never emitted as a runtime event, never persisted, and no warning is logged.
Expected behavior
A tool_result that arrives after turn finalize still produces the output — e.g. a late item.updated + item.completed carrying data.result keyed by toolUseId — or at minimum a runtime warning that output was discarded.
Actual behavior
Silent drop. The persisted item.completed for that tool carries only toolName and input; the client then has nothing to render, and the output is unrecoverable from the server DB.
Impact
Minor bug or occasional failure
Version or commit
v0.0.33 @ 3b72d17cb; re-read on origin/main @ ac1264e2c — unchanged.
Environment
- Server: Linux (
t3 serve), SQLite state store - Client: web
- Provider: Claude Code
Evidence
Code inspection only, in apps/server/src/provider/Layers/ClaudeAdapter.ts (line numbers on ac1264e2c):
Finalize path — completeTurn (defined at L2168), the in-flight drain loop (L2276-2305):
for (const [index, tool] of context.inFlightTools.entries()) {
...
payload: {
itemType: tool.itemType,
status: status === "completed" ? "completed" : "failed",
title: tool.title,
...(tool.detail ? { detail: tool.detail } : {}),
data: {
toolName: tool.toolName,
input: tool.input, // <- no `result`
},
},
...
context.inFlightTools.delete(index);
}
// Clear any remaining stale entries (e.g. from interrupted content blocks)
context.inFlightTools.clear();
Tool-result path — handleUserMessage (defined at L2665), the lookup miss (L2677-2683):
for (const toolResult of toolResultBlocksFromUserMessage(message)) {
const toolEntry = Array.from(context.inFlightTools.entries()).find(
([, tool]) => tool.itemId === toolResult.toolUseId,
);
if (!toolEntry) {
continue; // <- silent; no warning, no event
}
...
The rest of that loop is the correct path: it builds toolData = { toolName, input, result: toolResult.block } and emits item.updated, an optional content.delta, then item.completed with data: toolData. Everything downstream depends on the inFlightTools hit.
Why it is a genuine race, not just a theoretical one: completeTurn is not reached solely via the SDK result message (handleResultMessage). It is also called from handleStreamExit for interrupt, stream failure and "stream ended", from the session-stop path, and from the prompt path when a turn is superseded without steering. Those are driven by API/session activity, not by the SDK stream's own ordering, so they can land between a tool_use and its tool_result. Even on the pure-stream path, the SDK does not guarantee that result is delivered after every tool's user result message.
Candidate fix
Either drain already-received SDK messages before force-completing in-flight tools, or keep the inFlightTools entries (flagged finalized) so a late tool_result still emits item.updated + item.completed with data.result, instead of continueing. The second is the smaller diff.
Workaround
None.
I found this while investigating a display-loss incident that turned out to be a client-side rendering bug (the companion issue, #6388) — the server had persisted that output correctly. This one is a separate, real drop that I have not observed in the wild.
Happy to send a small focused PR if wanted.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in apps/server/src/provider/Layers/ClaudeAdapter.ts by reading completeTurn and its inFlightTools drain, then trace handleUserMessage's tool-result lookup. Confirm how a late tool_result is handled after finalization and preserve the result or emit a warning instead of silently continuing. Verify that finalized tools still produce the expected item.updated and item.completed data.result events.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100