Description
Calling addToolApprovalResponse() twice for the same approval ID while an async sendAutomaticallyWhen predicate is pending sends two continuation requests. The first call changes the part to approval-responded. The second call no longer finds a pending approval with that ID, but it still evaluates the predicate and submits the last message. Both requests contain the same approval response.
Expected behavior: responding to one approval ID should cause at most one continuation request. A repeated response should be ignored or reported as invalid without sending the same approval again.
This is reproducible through the public React Chat class with a local mock transport. The transport observes duplicate requests; the reproduction does not claim the server executes the tool twice.
Reproduction
Using Node.js v26.7.0, run:
npm init -y
npm pkg set type=module
npm install @ai-sdk/[email protected] [email protected] [email protected]
Save this as repro.mjs and run node repro.mjs:
import assert from 'node:assert/strict';
import { Chat } from '@ai-sdk/react';
import { lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai';
let sendCount = 0;
const submittedApprovals = [];
const chat = new Chat({
id: 'chat-1',
messages: [{
id: 'assistant-1',
role: 'assistant',
parts: [{
type: 'tool-updateProduct',
toolCallId: 'call-1',
state: 'approval-requested',
input: { price: 12 },
approval: { id: 'approval-1' },
}],
}],
sendAutomaticallyWhen: ({ messages }) =>
Promise.resolve().then(() =>
lastAssistantMessageIsCompleteWithApprovalResponses({ messages }),
),
transport: {
async sendMessages({ messages }) {
sendCount++;
submittedApprovals.push(messages.flatMap(message => message.parts
.filter(part => part.type === 'tool-updateProduct')
.map(part => ({ state: part.state, approval: part.approval }))));
return new ReadableStream({
start(controller) {
controller.enqueue({
type: 'tool-output-available',
toolCallId: 'call-1',
output: { price: 12 },
});
controller.enqueue({ type: 'finish', finishReason: 'stop' });
controller.close();
},
});
},
async reconnectToStream() {
return null;
},
},
});
await Promise.all([
chat.addToolApprovalResponse({ id: 'approval-1', approved: true }),
chat.addToolApprovalResponse({ id: 'approval-1', approved: true }),
]);
await new Promise(resolve => setTimeout(resolve, 0));
console.log(JSON.stringify({ sendCount, submittedApprovals }, null, 2));
assert.equal(sendCount, 1, 'one approval ID should trigger one continuation request');