cloudflare / cloudflare/agents
AIChatAgent re-arms stale tool auto-continuation after active stream finishes with stop
- Dominant language
- TypeScript
- Stars
- 5.6k
- Forks
- 711
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 53
Description
**Describe the bug**
`AIChatAgent` can invoke `onChatMessage` a second time for a stale automatic continuation after the original active stream has already completed with `finishReason: "stop"`.
A parsed WebSocket `tool-result` or `tool-approval` message can set `continuation.pending` while an SSE reply is active. The stream-active gate correctly prevents the continuation from firing immediately. However, if the original stream subsequently completes a normal assistant response, `_onStreamingTurnFinalized()` still calls `rearmForBatch()` unconditionally. After the 50 ms coalescing window, this can invoke `onChatMessage` again even though the turn has already completed.
The persisted conversation then ends with the completed assistant response. Anthropic Claude 4.6 rejects the extra request as an unsupported assistant prefill:
```text
This model does not support assistant message prefill. The conversation must end with a user message.
```
This is distinct from interrupted-assistant recovery in #1618: the assistant response here finished with `"stop"`, so appending an ephemeral `user: "continue"` could generate an unsolicited duplicate response.
**To Reproduce**
The following gives a deterministic reproduction against current `main`:
1. Start a chat request whose SSE response emits `tool-input-available` for a client tool.
2. Delay the remaining SSE chunks so the stream stays active.
3. Send `CF_AGENT_TOOL_RESULT` with `autoContinue: true` while that stream is active.
4. Have the original stream emit a later assistant text step and finish with `finishReason: "stop"`.
5. Wait past the 50 ms coalescing window.
6. Observe that `onChatMessage` was called twice. The second call is the stale continuation.
A complementary control finishes the original stream with `finishReason: "tool-calls"`; in that case, the second `onChatMessage` call remains necessary and must be preserved.
The reproduction uses a client-tool result because that is a deterministic, documented path into the auto-continuation controller. The finalization defect itself is about what happens once an automatic continuation is pending; it does not depend on retaining a particular tool-call ID.
We observed the stale-turn sequence twice in production, with no message contents logged:
```text
an incoming WebSocket tool-result event occurs while the original stream is active
original turn completes successfully (finishReason: "stop", multiple steps)
+50 ms: a continuation starts with request history ending in assistant text
Anthropic rejects the request as assistant prefill
```
In both occurrences, the original turn completed successfully before the extra continuation started. The second occurrence had a three-step original turn and the continuation checkpoint appeared exactly 50 ms after its successful completion.
Production evidence has an important limitation: the SDK's `tool:result` observability event omits the incoming `autoContinue` value, connection identity, and client build identity. The named tools also executed server-side during their original turns. Therefore, these logs establish the stale assistant-ended continuation but do **not** establish which specific tool interaction scheduled it or that the original stream consumed that exact result payload.
The extra continuation was not tied to an active tool at provider-request time: its final message role was `assistant` and that message contained only a text part.
**Expected behavior**
If an active stream finishes with `finishReason: "stop"`, a pending automatic continuation should be cleared rather than rearmed. No second `onChatMessage` call should occur.
Valid continuations must remain unchanged:
- `finishReason: "tool-calls"` should still rearm and continue after the tool result.
- Streams with no finish reason, including aborted/error recovery paths, should preserve the existing behavior.
- Explicit recovery such as `continueLastTurn` should remain unaffected.
A narrow implementation is to propagate the SSE finish reason to `_onStreamingTurnFinalized(finishReason)` and call `_clearPendingAutoContinuation(true)` when `finishReason === "stop"` and a continuation is pending; otherwise call `rearmForBatch()` as today.
**Version:**
Observed in production with:
- `@cloudflare/ai-chat@0.9.3`
- `agents@0.17.3`
- `ai@6.0.219`
Reproduced against `cloudflare/agents` `main` at commit `1062f847` with:
- `@cloudflare/ai-chat@0.11.0`
- `agents@0.22.0`
**Additional context**
Relevant code is the unconditional finalization rearm:
```ts
private _onStreamingTurnFinalized(): void {
this._streamingTurnActive = false;
this._autoContinuation.rearmForBatch();
}
```
Related work:
- #1618 handles intentional continuation of a partial assistant response.
- #1667 added the active-stream gate and post-stream rearm behavior; this report covers the remaining case where the active stream completes before rearm.
- #1649 and #1650 provide related continuation/recovery context.
I have a patch with deterministic `"stop"` regression coverage, a `"tool-calls"` control, and a package changeset ready to submit.
Contributor guide
Research direction
Start at AIChatAgent’s _onStreamingTurnFinalized() and the automatic continuation controller, then trace how the SSE finish reason reaches finalization. Run the deterministic "stop" regression coverage and the "tool-calls" control described in the issue. Done means no stale second onChatMessage call after "stop", while valid tool-call and recovery continuations remain unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend-api-design, testing
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 38/100