pingdotgg / pingdotgg/t3code

[Bug]: ClaudeAdapter finalize/tool_result race silently drops tool output

Open
#6,389 1 comment 0 reactions 0 assignees View on GitHub

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 inFlightTools being cleared before a late tool_result arrives.
  • 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:

  1. Claude Code emits tool_use for a tool; ClaudeAdapter registers it in context.inFlightTools.
  2. completeTurn runs before the tool's tool_result has been processed. This happens when the SDK result message 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.
  3. completeTurn force-emits item.completed for the still-in-flight tool with data: { toolName, input } and no result, deletes the entry, then clears the map.
  4. The real tool_result user message is processed afterwards. The inFlightTools lookup by toolUseId misses and the handler continues.

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 pathcompleteTurn (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 pathhandleUserMessage (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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.