Skip to content

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

Description

@alexanderjacobsen

Context

Client-defined tools (registered via the tools option on useAgentChat, with execute running in the browser).

Using @cloudflare/[email protected], [email protected].

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions