Skip to content
5 changes: 5 additions & 0 deletions .changeset/curvy-doors-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'ai': patch
---

chore(ai): add optional ChatRequestOptions to `addToolApprovalResponse` and `addToolOutput`
45 changes: 45 additions & 0 deletions examples/ai-e2e-next/app/api/chat/tool-approval-options/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { anthropic } from '@ai-sdk/anthropic';
import { ToolLoopAgent, dynamicTool, createAgentUIStreamResponse } from 'ai';
import { z } from 'zod';

function randomWeather() {
const weatherOptions = ['sunny', 'cloudy', 'rainy', 'windy'];
return weatherOptions[Math.floor(Math.random() * weatherOptions.length)];
}

const weatherTool = dynamicTool({
description: 'Get the weather in a location',
inputSchema: z.object({ city: z.string() }),
needsApproval: true,
async *execute() {
yield { state: 'loading' as const };
await new Promise(resolve => setTimeout(resolve, 2000));
yield {
state: 'ready' as const,
temperature: 72,
weather: randomWeather(),
};
},
});

const defaultInstructions =
'You are a helpful weather assistant. ' +
'When a tool execution is not approved by the user, do not retry it. ' +
'Just say that the tool execution was not approved.';

export async function POST(request: Request) {
const body = await request.json();

const systemInstruction: string | undefined = body.systemInstruction;

const agent = new ToolLoopAgent({
model: anthropic('claude-sonnet-4-6'),
instructions: systemInstruction ?? defaultInstructions,
tools: { weather: weatherTool },
});

return createAgentUIStreamResponse({
agent,
uiMessages: body.messages,
});
}
72 changes: 72 additions & 0 deletions examples/ai-e2e-next/app/chat/test-tool-approval-options/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use client';

import ChatInput from '@/components/chat-input';
import DynamicToolWithApprovalView from '@/components/tool/dynamic-tool-with-approval-view';
import { useChat } from '@ai-sdk/react';
import {
ChatRequestOptions,
DefaultChatTransport,
lastAssistantMessageIsCompleteWithApprovalResponses,
} from 'ai';
import { useState } from 'react';

export default function TestToolApprovalOptions() {
const [systemInstruction, setSystemInstruction] = useState('');

const requestOptions: ChatRequestOptions = {
body: { systemInstruction: systemInstruction || undefined },
};

const { status, sendMessage, messages, addToolApprovalResponse } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat/tool-approval-options',
}),
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});

return (
<div className="flex flex-col py-24 mx-auto w-full max-w-md stretch">
<h1 className="mb-4 text-xl font-bold">
Tool Approval with Options Test
</h1>

<label className="mb-4">
<span className="block mb-1 text-sm font-medium">
System Instruction (optional):
</span>
<input
className="w-full p-2 border border-gray-300 rounded"
placeholder="e.g. Always respond in French."
value={systemInstruction}
onChange={e => setSystemInstruction(e.target.value)}
/>
</label>

{messages.map(message => (
<div key={message.id} className="whitespace-pre-wrap">
{message.role === 'user' ? 'User: ' : 'AI: '}
{message.parts.map((part, index) => {
switch (part.type) {
case 'text':
return <div key={index}>{part.text}</div>;
case 'dynamic-tool':
return (
<DynamicToolWithApprovalView
key={index}
invocation={part}
addToolApprovalResponse={addToolApprovalResponse}
requestOptions={requestOptions}
/>
);
}
})}
</div>
))}

<ChatInput
status={status}
onSubmit={text => sendMessage({ text }, requestOptions)}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import type { ChatAddToolApproveResponseFunction, DynamicToolUIPart } from 'ai';
import type {
ChatAddToolApproveResponseFunction,
ChatRequestOptions,
DynamicToolUIPart,
} from 'ai';

export default function WeatherWithApprovalView({
invocation,
addToolApprovalResponse,
requestOptions,
}: {
invocation: DynamicToolUIPart;
addToolApprovalResponse: ChatAddToolApproveResponseFunction;
requestOptions?: ChatRequestOptions;
}) {
switch (invocation.state) {
case 'approval-requested':
Expand All @@ -26,6 +32,7 @@ export default function WeatherWithApprovalView({
addToolApprovalResponse({
id: invocation.approval.id,
approved: true,
options: requestOptions,
})
}
>
Expand All @@ -37,6 +44,7 @@ export default function WeatherWithApprovalView({
addToolApprovalResponse({
id: invocation.approval.id,
approved: false,
options: requestOptions,
})
}
>
Expand Down
157 changes: 157 additions & 0 deletions packages/ai/src/ui/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2427,6 +2427,163 @@ describe('Chat', () => {
});
});

describe('addToolOutput options forwarding', () => {
it('should forward options to makeRequest when auto-sending', async () => {
server.urls['http://localhost:3000/api/chat'].response = [
{
type: 'stream-chunks',
chunks: [
formatChunk({ type: 'start' }),
formatChunk({ type: 'start-step' }),
formatChunk({
type: 'tool-input-available',
dynamic: true,
toolCallId: 'tool-call-0',
toolName: 'test-tool',
input: { testArg: 'test-value' },
}),
formatChunk({ type: 'finish-step' }),
formatChunk({ type: 'finish' }),
],
},
{
type: 'stream-chunks',
chunks: [
formatChunk({ type: 'start' }),
formatChunk({ type: 'start-step' }),
formatChunk({ type: 'finish-step' }),
formatChunk({ type: 'finish' }),
],
},
];

let callCount = 0;
const onFinishPromise = createResolvablePromise<void>();

const chat = new TestChat({
id: '123',
generateId: mockId(),
transport: new DefaultChatTransport({
api: 'http://localhost:3000/api/chat',
}),
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
onFinish: () => {
callCount++;
if (callCount === 2) {
onFinishPromise.resolve();
}
},
});

await chat.sendMessage({
text: 'Hello, world!',
});

await chat.addToolOutput({
state: 'output-available',
tool: 'test-tool',
toolCallId: 'tool-call-0',
output: 'test-output',
options: {
headers: { 'x-custom': 'test-value' },
body: { extra: 'data' },
},
});

await onFinishPromise.promise;

expect(server.calls.length).toBe(2);
expect(server.calls[1].requestHeaders['x-custom']).toBe('test-value');
expect(await server.calls[1].requestBodyJson).toMatchObject({
extra: 'data',
});
});
});

describe('addToolApprovalResponse options forwarding', () => {
it('should forward options to makeRequest when auto-sending', async () => {
server.urls['http://localhost:3000/api/chat'].response = [
{
type: 'stream-chunks',
chunks: [
formatChunk({ type: 'start' }),
formatChunk({ type: 'start-step' }),
formatChunk({
type: 'tool-output-available',
toolCallId: 'call-1',
output: { temperature: 72, weather: 'sunny' },
}),
formatChunk({ type: 'text-start', id: 'txt-1' }),
formatChunk({
type: 'text-delta',
id: 'txt-1',
delta: 'The weather in Tokyo is sunny.',
}),
formatChunk({ type: 'text-end', id: 'txt-1' }),
formatChunk({ type: 'finish-step' }),
formatChunk({
type: 'finish',
finishReason: 'stop',
}),
],
},
];

const onFinishPromise = createResolvablePromise<void>();

const chat = new TestChat({
id: '123',
generateId: mockId({ prefix: 'newid' }),
transport: new DefaultChatTransport({
api: 'http://localhost:3000/api/chat',
}),
messages: [
{
id: 'id-0',
role: 'user',
parts: [{ text: 'What is the weather in Tokyo?', type: 'text' }],
},
{
id: 'id-1',
role: 'assistant',
parts: [
{ type: 'step-start' },
{
type: 'tool-weather',
toolCallId: 'call-1',
state: 'approval-requested',
input: { city: 'Tokyo' },
approval: { id: 'approval-1' },
},
],
},
],
sendAutomaticallyWhen:
lastAssistantMessageIsCompleteWithApprovalResponses,
onFinish: () => {
onFinishPromise.resolve();
},
});

await chat.addToolApprovalResponse({
id: 'approval-1',
approved: true,
options: {
headers: { 'x-custom': 'test-value' },
body: { extra: 'data' },
},
});

await onFinishPromise.promise;

expect(server.calls.length).toBe(1);
expect(server.calls[0].requestHeaders['x-custom']).toBe('test-value');
expect(await server.calls[0].requestBodyJson).toMatchObject({
extra: 'data',
});
});
});

describe('addToolApprovalResponse', () => {
describe('approved', () => {
let chat: TestChat;
Expand Down
Loading
Loading