Skip to content

fix: keep tool input when the approval request arrives before it (#1872) - #2391

Merged
threepointone merged 4 commits into
mainfrom
fix/1872-approval-tool-input
Sep 28, 2026
Merged

threepointone merged 4 commits into
mainfrom
fix/1872-approval-tool-input

Conversation

@threepointone

@threepointone threepointone commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1872. Supersedes #2021.

Problem

A tool call that needs approval could reach the approval card with input: undefined:

  • AI SDK 7 streams tool arguments as tool-input-delta chunks with the text in inputTextDelta. The message builder only read the older input field, so the streamed arguments were dropped.
  • Some custom and older streams send tool-approval-request before tool-input-available. The late tool-input-available was either ignored, or it moved the part back to input-available and hid the approval card.

Change

  • applyChunkToParts (agents/chat) collects the inputTextDelta text per tool part. On tool-approval-request it parses that text if the input isn't set yet. It still takes chunk.input from older emitters.
  • A tool-input-available that arrives after the approval request fills in the input but keeps the approval state. It only replaces input that is missing or came from the partial deltas, and it also takes the chunk's title. New exported helpers: isLateToolInputChunk, applyLateToolInput and lateToolInputForwardChunks.
  • AIChatAgent and Think both apply the late input, then store and send the client two chunks: the tool-input-available, followed by the part's tool-approval-request again. The AI SDK client fills in the input on the first and goes back to showing the approval on the second, and stream replay rebuilds the same state. AIChatAgent also re-persists the approval snapshot. Think's RPC stream passes both chunks to onEvent. Nothing is forwarded once the user has responded to the approval.

Tests

  • message-builder-approval-input.test.ts: tests covering both chunk orders, the forwarded chunk pair,, the old input field, malformed text, and not overwriting canonical input.
  • ai-chat late-tool-input.test.ts: the client, rebuilt from the streamed chunks, shows the approval with its input, and the persisted part carries it too.
  • The ai-chat test fails without the fix.

Credit to #2021 for the original reproduction and fix direction.

@changeset-bot

changeset-bot Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f41e4f2

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

This PR includes changesets to release 4 packages
Name Type
agents Patch
@cloudflare/ai-chat Patch
@cloudflare/think Patch
@cloudflare/agent-think 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

@threepointone
threepointone added this pull request to stack #2395 September 28, 2026 10:50
@threepointone threepointone changed the title fix(agents,ai-chat,think): keep tool input when approval arrives before it (#1872) fix: keep tool input when the approval request arrives before it (#1872) Sep 28, 2026
@agent-think

agent-think Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

🟡 agents import sizes: 1 entry point grew

Entry point Exports Largest gzip change Size now
🟡 agents/chat 102 resized, 3 new up to +189 B (+7.67%) 2.6 KiB
Changed exports (105)
Import Gzip change Size now
🟡 agents/chat#partAwaitsClientInteraction +189 B (+7.67%) 2.6 KiB
🟡 agents/chat#unwrapChatFiberSnapshot +189 B (+7.6%) 2.6 KiB
🟡 agents/chat#byteLength +189 B (+7.52%) 2.6 KiB
🟡 agents/chat#MessageType +188 B (+7.54%) 2.6 KiB
🟡 agents/chat#isDurableObjectResetError +188 B (+7.32%) 2.7 KiB
🟡 agents/chat#resolveChatRecoveryConfig +188 B (+7.17%) 2.7 KiB
🟡 agents/chat#truncateOlderMessages +188 B (+5.74%) 3.4 KiB
🟡 agents/chat#toolPartHasSettledResult +187 B (+7.82%) 2.5 KiB
🟡 agents/chat#originMessageIds +187 B (+7.7%) 2.6 KiB
🟡 agents/chat#applyToolUpdate +187 B (+7.66%) 2.6 KiB
🟡 agents/chat#createChatFiberSnapshot +187 B (+7.35%) 2.7 KiB
🟡 agents/chat#runChatRecoveryExhaustion +187 B (+7.14%) 2.7 KiB
🟡 agents/chat#withOriginMessageIds +186 B (+7.74%) 2.5 KiB
🟡 agents/chat#STREAM_RESUME_NONE_REASONS +186 B (+7.74%) 2.5 KiB
🟡 agents/chat#drainInteractionApplies +186 B (+7.73%) 2.5 KiB
🟡 agents/chat#DEFAULT_CHAT_RECOVERY_TERMINAL_MESSAGE +186 B (+7.7%) 2.5 KiB
🟡 agents/chat#recordChatTerminal +186 B (+7.65%) 2.6 KiB
🟡 agents/chat#sendIfOpen +186 B (+7.6%) 2.6 KiB
🟡 agents/chat#pausedExecutionUpdate +186 B (+7.59%) 2.6 KiB
🟡 agents/chat#awaitWithDeadline +186 B (+7.56%) 2.6 KiB
🟡 agents/chat#listActiveChatRecoveryIncidents +186 B (+7.54%) 2.6 KiB
🟡 agents/chat#toolApprovalUpdate +186 B (+7.54%) 2.6 KiB
🟡 agents/chat#buildInClauseStrings +186 B (+7.48%) 2.6 KiB
🟡 agents/chat#hasIncompleteToolBatch +186 B (+7.46%) 2.6 KiB
🟡 agents/chat#chatRecoveryTaskRunOptions +186 B (+7.44%) 2.6 KiB
🟡 agents/chat#repairInterruptedToolParts +186 B (+6.93%) 2.8 KiB
🟡 agents/chat#CHAT_RECOVERING_FLAG_TTL_MS +185 B (+7.83%) 2.5 KiB
🟡 agents/chat#aiSdkRecoveryCodec +185 B (+7.83%) 2.5 KiB
🟡 agents/chat#CHAT_RECOVERY_ALARM_DEBOUNCE_MS +185 B (+7.83%) 2.5 KiB
🟡 agents/chat#CHAT_RECOVERY_INCIDENT_TTL_MS +185 B (+7.83%) 2.5 KiB
🟡 agents/chat#DEFAULT_CHAT_RECOVERY_NO_PROGRESS_TIMEOUT_MS +185 B (+7.83%) 2.5 KiB
🟡 agents/chat#partialHasSettledToolResults +185 B (+7.83%) 2.5 KiB
🟡 agents/chat#CHAT_RECOVERY_STABLE_RETRY_DELAY_SECONDS +185 B (+7.81%) 2.5 KiB
🟡 agents/chat#DEFAULT_CHAT_RECOVERY_MAX_OOM_RETRIES +185 B (+7.81%) 2.5 KiB
🟡 agents/chat#AGENT_TOOL_STREAM_PROGRESS_BUMP_THROTTLE_MS +185 B (+7.81%) 2.5 KiB
🟡 agents/chat#CHAT_STREAM_PROGRESS_CREDIT_THROTTLE_MS +185 B (+7.81%) 2.5 KiB
🟡 agents/chat#DEFAULT_CHAT_RECOVERY_MAX_ATTEMPTS +185 B (+7.81%) 2.5 KiB
🟡 agents/chat#MAX_BOUND_PARAMS +185 B (+7.81%) 2.5 KiB
🟡 agents/chat#KV_DELETE_MAX_KEYS +185 B (+7.8%) 2.5 KiB
🟡 agents/chat#ROW_MAX_BYTES +185 B (+7.8%) 2.5 KiB
🟡 agents/chat#CHAT_RECOVERY_CANCELLED_REASON +185 B (+7.79%) 2.5 KiB
🟡 agents/chat#CHAT_RECOVERING_KEY +185 B (+7.79%) 2.5 KiB
🟡 agents/chat#CHAT_LAST_TERMINAL_KEY +185 B (+7.76%) 2.5 KiB
🟡 agents/chat#ChatStreamStalledError +185 B (+7.67%) 2.5 KiB
🟡 agents/chat#bumpChatRecoveryProgress +185 B (+7.65%) 2.5 KiB
🟡 agents/chat#shouldCreditStreamProgress +185 B (+7.64%) 2.5 KiB
🟡 agents/chat#resolveToolMergeId +185 B (+7.52%) 2.6 KiB
🟡 agents/chat#classifyAgentToolChildRecovery +185 B (+7.43%) 2.6 KiB
🟡 agents/chat#sweepStaleChatRecoveryIncidents +185 B (+7.39%) 2.6 KiB
🟡 agents/chat#iterateWithStallWatchdog +185 B (+6.95%) 2.8 KiB

…and 55 more exports in the workflow artifact.

How this works

Each runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement.

Compared c55ec800 → f41e4f2e · workflow run · reported by agent-think[bot]

…re it (#1872)

Co-authored-by: Cursor <cursoragent@cursor.com>
@threepointone
threepointone force-pushed the fix/1872-approval-tool-input branch from ba804ae to dfa4bad Compare September 28, 2026 10:57
@threepointone
threepointone marked this pull request as ready for review September 28, 2026 10:57

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 4 potential issues.

Devin Review

Comment thread packages/think/src/think.ts Outdated
Comment thread packages/ai-chat/src/index.ts
Comment on lines +146 to +147
if (p.input !== undefined && !inputFromRawText.has(part)) return false;
p.input = normalizeToolInput(chunk.input).input;

@devin-ai-integration devin-ai-integration Bot Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Recovered approvals retain incomplete tool input

After recovery recreates an approval part, applyLateToolInput rejects the canonical input if partial deltas already populated it. provisionalInput only tracks the original part object, so the recovered approval keeps incomplete arguments.

Learn more

The provisional-input marker exists only in a WeakSet keyed by the original tool-part object. An approval part rebuilt from an early-persisted message or reconstructed stream can contain the fallback input without that marker. When the canonical tool-input-available arrives after recovery, the non-undefined fallback input triggers the first-write guard, so the corrected arguments never reach storage or the approved call. StreamAccumulator copies existing parts, and reconcileOrphanPartial preserves existing tool parts on orphan recovery.

Example: Deltas contain only {"path": before the approval request, so the fallback input becomes {}. An interruption restores the approval part from storage; a later canonical {path:"notes.txt"} cannot replace {}.

Recommended fix: Persist or reconstruct whether the approval input is provisional, or distinguish canonical input from fallback input when rebuilding approval parts. Cover an interruption between the approval request and canonical input.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +14807 to 14812
const lateToolInput = isLateToolInputChunk(
accumulator.parts,
streamChunk
);
const { action } = accumulator.applyChunk(streamChunk);
this._applyActionApprovalDescriptorToParts(

@devin-ai-integration devin-ai-integration Bot Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Action descriptor can lag tool input

For text-only deltas, Think constructs the action descriptor before parsing the tool input at approval. Its descriptor can contain {} while the tool part holds the reconstructed arguments; check consumers of descriptor input.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

threepointone and others added 2 commits September 28, 2026 12:20
… recovery keep it

Addresses review on #2391: store and broadcast a late tool-input-available
followed by the approval request again (so stream replay and live clients
get the input without losing the approval card), treat an older emitter's
partial delta input as provisional, and build Think's approval descriptor
input from streamed delta text.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Devin Review

Comment on lines +153 to +158
if (chunk.providerExecuted != null) {
p.providerExecuted = chunk.providerExecuted;
}
if (chunk.providerMetadata != null) {
p.callProviderMetadata = chunk.providerMetadata;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Late tool input drops display title

When tool-input-available supplies a title after approval, applyLateToolInput retains the arguments but drops that title. The approval card loses its tool label if tool-input-start omitted it.

Learn more

The normal tool-input-available branch copies chunk.title to the tool part, but the new late-input branch only copies the input and provider fields. A stream that supplies its display title with the canonical input therefore loses it when approval precedes input availability.

Example: tool-input-start identifies deleteFile without a title; after approval, tool-input-available supplies title: "Delete notes.txt". The approval part retains no title.

Recommended fix: Copy chunk.title in applyLateToolInput under the same condition used by the normal available-input handler, and test both chunk orders.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +14319 to +14341
if (lateToolInput) {
for (const forwarded of lateToolInputForwardChunks(
accumulator.parts,
streamChunk,
approvalRequests.get(streamChunk.toolCallId ?? "")
)) {
const chunkBody = JSON.stringify(forwarded);
const seq = await this._storeChunkDurably(
streamId,
forwarded,
chunkBody,
flushState
);
this._broadcastChat({
type: MSG_CHAT_RESPONSE,
id: requestId,
body: chunkBody,
done: false,
...(seq !== undefined && { seq })
});
await callback.onEvent(chunkBody);
}
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Think forwarding differs from described behavior

The description says Think suppresses late input. Both streaming paths instead store and broadcast it with a repeated approval request; RPC also emits both events. Confirm which stream contract consumers need.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Co-authored-by: Cursor <cursoragent@cursor.com>
@pkg-pr-new

pkg-pr-new Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2391

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@2391

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@2391

hono-agents

npm i https://pkg.pr.new/hono-agents@2391

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@2391

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@2391

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@2391

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@2391

commit: f41e4f2

@threepointone
threepointone merged commit d3fe93c into main Sep 28, 2026
16 checks passed
@threepointone
threepointone deleted the fix/1872-approval-tool-input branch September 28, 2026 12:29
@github-actions github-actions Bot mentioned this pull request Oct 2, 2026
tombeckenham added a commit to tombeckenham/agents-tom that referenced this pull request Oct 7, 2026
…gine

Ports four upstream changes to the legacy AIChatAgent onto AGUIChatAgent:

- cloudflare#2352: clear a stale auto-continuation when the active stream finishes
  with stop and the tool batch is complete (finishReason read from
  RUN_FINISHED via the accumulator).
- cloudflare#2391: a tool call whose arguments complete after its approval request
  refreshes the persisted approval snapshot; the event-to-chunk projection
  re-sends the approval request after the late tool-input-available.
- cloudflare#2392: settle an approved tool call that never ran once a new turn moves
  past it. The pre-turn repair now also runs on submitted turns.
- cloudflare#2040: reconcile reused tool-call IDs one-to-one in the AG-UI reconciler
  (same-call claim, per-row result merge, stale-copy drop).

Test plumbing: the legacy-wire WebSocket wrapper translated each frame once
per listener through a stateful projector, so a second listener lost
chunks. It now translates once per frame.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant