Skip to content

fix(ai-chat): persist tool approval state across page refresh#922

Merged
threepointone merged 1 commit into
mainfrom
sp/fix-approval-persistence
Feb 17, 2026
Merged

fix(ai-chat): persist tool approval state across page refresh#922
threepointone merged 1 commit into
mainfrom
sp/fix-approval-persistence

Conversation

@threepointone

@threepointone threepointone commented Feb 17, 2026

Copy link
Copy Markdown
Contributor

Problem

Three related bugs in the tool approval (needsApproval) flow:

  1. Approval UI doesn't survive page refresh. The server-side message builder didn't handle tool-approval-request chunks from the AI SDK, so the tool part stayed in input-available state on the server. And streaming messages aren't persisted until the stream completes — so when the stream pauses for approval, a refresh reloads from SQLite where the tool was never saved as approval-requested.

  2. Duplicate approval UI. The initial fix used persistMessages() for early persistence, which broadcasts to all clients. Since the client already has the data from the SSE stream, the approval UI rendered twice.

  3. InvalidPromptError after approving a tool. _applyToolApproval set approval: { approved }, replacing the entire object and losing the id field. On the continuation stream, convertToModelMessages reads approval.id to produce approvalId in the tool-approval-request content part. With id gone, approvalId is undefined, and schema validation fails.

Fix

message-builder.ts

  • tool-approval-request — New case: transitions tool part to approval-requested state with approval.id.
  • tool-output-denied — New case: transitions tool part to output-denied state.
  • Added approvalId to StreamChunkData type.

index.ts — Early silent persistence

When applyChunkToParts processes a tool-approval-request chunk, the streaming message is immediately written to SQLite via a direct SQL upsert — no broadcast. The client already has the data from the SSE stream; broadcasting would cause the approval UI to render twice. _approvalPersistedMessageId tracks the early-persisted ID so stream completion can update in place.

index.ts_applyToolApproval preserves approval.id

Changed from approval: { approved } to approval: { ...part.approval, approved }. This merges the approval response with the existing data, preserving the id field that was set during approval-requested. When starting from input-available (no prior approval object), the spread of undefined is empty, so behavior is unchanged.

index.ts — Clean finally handling

_approvalPersistedMessageId is captured into a local earlyPersistedId in the finally block and immediately cleared from the instance field. No stale state on error paths.

Notes for reviewers

  1. Silent SQL write vs persistMessages(): The early persistence writes directly to SQLite without calling persistMessages(). This means this.messages is NOT updated — only the DB row exists. On stream completion, the earlyPersistedId path calls persistMessages() which reloads from DB, so everything converges. The intermediate state where this.messages is out of sync with SQLite is brief and doesn't cause issues because the stream is paused.

  2. _approvalPersistedMessageId is per-instance, not per-stream. Filed #921 to track making it concurrent-safe if we ever support multiple simultaneous streams.

  3. The tool-output-denied chunk handler is the counterpart to tool-approval-request. Without it, a rejected tool stays in approval-requested state on the server even though the client shows it as denied.

  4. Hibernation safe. The SQLite write survives both page refresh and DO hibernation. When the DO wakes from a CF_AGENT_TOOL_APPROVAL message, _applyToolApproval already handles the transition from approval-requested.

  5. earlyPersistedId and continuation are mutually exclusive. Early persistence only happens on the initial stream (before approval). The continuation stream starts fresh after approval — earlyPersistedId is always null.

Tests (5 new, all passing)

  • applyChunkToParts: tool-approval-request > transitions tool part from input-available to approval-requested
  • applyChunkToParts: tool-approval-request > does nothing if tool part not found
  • applyChunkToParts: tool-output-denied > transitions tool part to output-denied state
  • Tool approval persistence across reconnect > persisted messages include approval-requested state after approval-request chunk
  • Updated 3 existing tests to verify approval.id is preserved through the approval flow

Test plan

  • npm run build passes
  • npm run check passes (42 projects typecheck)
  • All 161 ai-chat tests pass (22 test files)
  • All 530 agents tests pass (33 test files)

@changeset-bot

changeset-bot Bot commented Feb 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ccd70e3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/ai-chat Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Feb 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/agents@922

commit: ccd70e3

@threepointone
threepointone force-pushed the sp/fix-approval-persistence branch 2 times, most recently from 692f1a1 to 2f72407 Compare February 17, 2026 10:24
Handle `tool-approval-request` and `tool-output-denied` stream chunks
in the server-side message builder. Previously only handled client-side,
so the server never persisted the approval-requested state.

When a tool enters approval-requested state during streaming, persist
the streaming message to SQLite immediately. On stream completion,
update the early-persisted row in place.

Fixes the bug where refreshing during a tool approval prompt would show
"Running..." instead of the Approve/Reject UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
@threepointone
threepointone force-pushed the sp/fix-approval-persistence branch from 2f72407 to ccd70e3 Compare February 17, 2026 10:28
@threepointone
threepointone merged commit c8e5244 into main Feb 17, 2026
4 checks passed
@threepointone
threepointone deleted the sp/fix-approval-persistence branch February 17, 2026 10:36
@github-actions github-actions Bot mentioned this pull request Feb 16, 2026
taowen pushed a commit to taowen/agents that referenced this pull request Feb 17, 2026
…lare#922)

Handle `tool-approval-request` and `tool-output-denied` stream chunks
in the server-side message builder. Previously only handled client-side,
so the server never persisted the approval-requested state.

When a tool enters approval-requested state during streaming, persist
the streaming message to SQLite immediately. On stream completion,
update the early-persisted row in place.

Fixes the bug where refreshing during a tool approval prompt would show
"Running..." instead of the Approve/Reject UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant