Repository navigation
Tool part state regresses during client tool round-trip; continuation starts but emits no output #1404
Description
Context
Client-defined tools (registered via the tools option on useAgentChat, with execute running in the browser).
Using @cloudflare/ai-chat@0.5.1, agents@0.11.5.
Symptom
After a client tool's addToolOutput is called, the agent attempts to continue but the second step emits no output. The assistant message ends with metadata.finishReason: "tool-calls" and the tool's output is rendered, but no natural-language follow-up is generated.
The worker logs:
(warn) [AIChatAgent] _applyToolResult: Tool part with toolCallId call_xxx
not in expected state (expected: input-available|approval-requested|approval-responded)
The frequency depends heavily on network latency between client and worker — under sub-millisecond latency it's rare, under realistic latency it reproduces on most tool calls.
Direct evidence: the tool part's state regresses
Logging chat.messages on every change shows the tool part's state going backwards during the round-trip. Excerpt (timestamps elided), same toolCallId throughout:
parts: [step-start, { tool, state: "output-available", output: "..." }]
parts: [step-start, { tool, state: "output-available", output: "..." }, { type: "step-start" }]
parts: [step-start, { tool, state: "input-streaming" }, { type: "step-start" }] ← regressed
parts: [step-start, { tool, state: "input-streaming", input: {} }, { type: "step-start" }]
parts: [step-start, { tool, state: "output-available", output: "..." }, { type: "step-start" }]
parts: [step-start, { tool, state: "output-available", output: "..." }, { type: "step-start" }] + metadata.finishReason: "tool-calls"
The second step-start is the continuation actually beginning — so the continuation is firing — but it's born into a corrupted view where the tool part has been clobbered back to input-streaming. The model has nothing useful to look at, the warning above fires, and the step finishes with finishReason: "tool-calls" and no content.
Hypothesis
There are two writers to the tool part's state during the round-trip:
- The client SDK calls
addToolOutput, which transitions the part tooutput-availablelocally and syncs that state up to the worker over the WebSocket. - The worker calls
_applyToolResultto apply the result and trigger the continuation, which expects the part to still be ininput-available.
These two writes race. When the server-side replay/sync wins, it clobbers the already-advanced output-available state back to an earlier value. The continuation then starts against the regressed state and produces nothing useful.
Repro
- Run an
AIChatAgentwith a client-defined tool (a synchronous return is fine). - Send a prompt that triggers the tool.
- Log
chat.messageson every change in the consumer (e.g.console.log(JSON.stringify(messages, null, 2))from a child ofSidekickProvidercallinguseSidekickChat). - Observe the state regression and the warning.
Suggested direction (not a PR yet)
Either:
- Treat "already at
output-availablewith the matchingtoolCallIdand matching output" as a valid starting state for_applyToolResult(idempotent), and never let any sync path regress a part to an earlier state. Or - Have the WebSocket sync path skip writing tool-part state when the server is the authority for that part of the lifecycle.
Happy to PR if there's a preferred direction.
Related
#1365 (closed) is in the same neighborhood — client-tool round-trip state — but distinct: that one was about exposed state fields not signaling activity coherently. This is about the tool part's state itself getting corrupted mid-round-trip.
Activity
alexanderjacobsen commentedon Apr 28, 2026
Two follow-ups after looking at the SDK source more carefully:
Race story isn't quite right. I traced the client side and
addToolOutputonly sends one message to the server (cf_agent_tool_result); it does not sendcf_agent_chat_messages. So the original hypothesis —_applyToolResultracing against apersistMessagesfrom the client's synced state — doesn't actually apply here. The state regression we observe in the debug logs (output-available→input-streaming→output-availablefor the sametoolCallId) has to come from something else on the server side. Likely candidates from a quick read are_streamSSEReplyrewriting state during the auto-continuation, or the persist/reload round-trip inside_findAndUpdateToolPartexposing a stale view. I'm not confident enough to say which without help.Currently can't reproduce. Same deployed setup, same browser, same project, same network — the bug isn't firing right now. It was firing reliably a couple of hours ago. Consistent with a real but timing-sensitive issue, but worth flagging so you don't chase a phantom if you can't repro on your end either. The original logs in the issue body still stand as evidence it does happen.
Happy to capture more diagnostic data when it starts firing again.
Stop provider tool-call replays from regressing tool part state (#1404)…
fix(think): harden approved tool continuation for current main
Fix approved Think tool continuation results reaching clients (#1629)
chat-recovery (phase 5): correct orphan-persist (c) — downstream of (…