Description
sendAutomaticallyWhen accepts a callback returning boolean | PromiseLike<boolean>. The useChat reference says it is called both when a stream finishes and when a tool call is added. When the callback rejects while either addToolOutput() or addToolApprovalResponse() checks it, the chat method resolves successfully and the rejection becomes a process-level unhandledRejection. The chat remains in ready with no error.
The same rejected callback behaves differently when evaluated after a completed stream: sendMessage() rejects with predicate failed, so its caller can handle the failure. The documented callback runs both when a stream finishes and when a tool call is added, but the tool-output path drops the rejection.
Expected behavior: a rejected async predicate is surfaced through the SDK's error handling or returned operation consistently, without an unhandled promise rejection.
Reproduction
With Node.js v26.7.0, run npm install [email protected], save the following as repro.mjs, then run node repro.mjs. No provider or network is used.
import assert from 'node:assert/strict';
import { AbstractChat } from 'ai';
const unhandled = [];
process.on('unhandledRejection', error => unhandled.push(error.message));
class State {
status = 'ready';
error = undefined;
messages = [];
pushMessage = message => { this.messages = [...this.messages, message]; };
popMessage = () => { this.messages = this.messages.slice(0, -1); };
replaceMessage = (index, message) => {
this.messages = this.messages.map((old, i) => i === index ? message : old);
};
snapshot = value => structuredClone(value);
}
const predicate = () => Promise.reject(new Error('predicate failed'));
function createChat(transport) {
return new (class extends AbstractChat {
constructor() {
super({ id: 'chat-1', state: new State(), sendAutomaticallyWhen: predicate, transport });
}
})();
}
const toolChat = createChat({
sendMessages: async () => { throw new Error('unexpected sendMessages'); },
reconnectToStream: async () => null,
});
toolChat.state.messages = [{
id: 'assistant-1',
role: 'assistant',
parts: [{ type: 'tool-test', toolCallId: 'call-1', state: 'input-available', input: {} }],
}];
let toolCallResult = 'pending';
await toolChat.addToolOutput({
state: 'output-available', toolCallId: 'call-1', output: 'done',
}).then(
() => { toolCallResult = 'resolved'; },
error => { toolCallResult = `rejected: ${error.message}`; },
);
const approvalChat = createChat({
sendMessages: async () => { throw new Error('unexpected sendMessages'); },
reconnectToStream: async () => null,
});
approvalChat.state.messages = [{
id: 'assistant-2',
role: 'assistant',
parts: [{
type: 'tool-test',
toolCallId: 'call-2',
state: 'approval-requested',
input: {},
approval: { id: 'approval-1' },
}],
}];
let approvalCallResult = 'pending';
await approvalChat.addToolApprovalResponse({ id: 'approval-1', approved: true }).then(
() => { approvalCallResult = 'resolved'; },
error => { approvalCallResult = `rejected: ${error.message}`; },
);
const finishChat = createChat({
sendMessages: async () => new ReadableStream({
start(controller) {
controller.enqueue({ type: 'start' });
controller.enqueue({ type: 'start-step' });
controller.enqueue({ type: 'text-start', id: 'text-1' });
controller.enqueue({ type: 'text-delta', id: 'text-1', delta: 'done' });
controller.enqueue({ type: 'text-end', id: 'text-1' });
controller.enqueue({ type: 'finish', finishReason: 'stop' });
controller.close();
},
}),
reconnectToStream: async () => null,
});
let finishCallResult = 'pending';
await finishChat.sendMessage({ text: 'hello' }).then(
() => { finishCallResult = 'resolved'; },
error => { finishCallResult = `rejected: ${error.message}`; },
);
await new Promise(resolve => setTimeout(resolve, 10));
console.log({ toolCallResult, approvalCallResult, finishCallResult, unhandled });
assert.equal(toolCallResult, 'rejected: predicate failed');
assert.equal(approvalCallResult, 'rejected: predicate failed');
assert.equal(finishCallResult, 'rejected: predicate failed');
assert.deepEqual(unhandled, []);