Skip to content

Tool part state regresses during client tool round-trip; continuation starts but emits no output #1404

Closed

Description

@alexanderjacobsen

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:

  1. The client SDK calls addToolOutput, which transitions the part to output-available locally and syncs that state up to the worker over the WebSocket.
  2. The worker calls _applyToolResult to apply the result and trigger the continuation, which expects the part to still be in input-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

  1. Run an AIChatAgent with a client-defined tool (a synchronous return is fine).
  2. Send a prompt that triggers the tool.
  3. Log chat.messages on every change in the consumer (e.g. console.log(JSON.stringify(messages, null, 2)) from a child of SidekickProvider calling useSidekickChat).
  4. Observe the state regression and the warning.

Suggested direction (not a PR yet)

Either:

  • Treat "already at output-available with the matching toolCallId and 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

  1. alexanderjacobsen commented on Apr 28, 2026

    @alexanderjacobsen
    ContributorAuthor

    Two follow-ups after looking at the SDK source more carefully:

    Race story isn't quite right. I traced the client side and addToolOutput only sends one message to the server (cf_agent_tool_result); it does not send cf_agent_chat_messages. So the original hypothesis — _applyToolResult racing against a persistMessages from the client's synced state — doesn't actually apply here. The state regression we observe in the debug logs (output-available → input-streaming → output-available for the same toolCallId) has to come from something else on the server side. Likely candidates from a quick read are _streamSSEReply rewriting state during the auto-continuation, or the persist/reload round-trip inside _findAndUpdateToolPart exposing 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.

  2. deleted a comment from ask-bonk on Apr 28, 2026
  3. deleted a comment from ask-bonk on Apr 28, 2026
  4. deleted a comment from ask-bonk on Apr 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions