Skip to content

Commit a043b14

Browse files
authored
fix (ui): prevent early addToolResult submission (#5427)
1 parent 48772c6 commit a043b14

File tree

10 files changed

+316
-20
lines changed

10 files changed

+316
-20
lines changed

.changeset/empty-dolphins-ring.md

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@ai-sdk/svelte': patch
3+
'@ai-sdk/react': patch
4+
'@ai-sdk/solid': patch
5+
'@ai-sdk/vue': patch
6+
---
7+
8+
fix (ui): prevent early addToolResult submission

examples/sveltekit-openai/src/routes/chat/+page.svelte

+15-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,21 @@
44
import { Textarea } from '$lib/components/ui/textarea/index.js';
55
import { Chat } from '@ai-sdk/svelte';
66
7-
const chat = new Chat();
7+
const chat = new Chat({
8+
maxSteps: 5,
9+
10+
// run client-side tools that are automatically executed:
11+
async onToolCall({ toolCall }) {
12+
// artificial 2 second delay
13+
await new Promise(resolve => setTimeout(resolve, 2000));
14+
15+
if (toolCall.toolName === 'getLocation') {
16+
const cities = ['New York', 'Los Angeles', 'Chicago', 'San Francisco'];
17+
return cities[Math.floor(Math.random() * cities.length)];
18+
}
19+
},
20+
});
21+
822
const disabled = $derived(chat.status !== 'ready');
923
1024
function mapRoleToClass(role: string) {

packages/react/src/use-chat.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -548,15 +548,27 @@ By default, it's set to 1, which means that only a single LLM call is made.
548548
toolResult: result,
549549
});
550550

551-
mutate(currentMessages, false);
551+
// array mutation is required to trigger a re-render
552+
mutate(
553+
[
554+
...currentMessages.slice(0, currentMessages.length - 1),
555+
{ ...currentMessages[currentMessages.length - 1] },
556+
],
557+
false,
558+
);
559+
560+
// when the request is ongoing, the auto-submit will be triggered after the request is finished
561+
if (status === 'submitted' || status === 'streaming') {
562+
return;
563+
}
552564

553565
// auto-submit when all tool calls in the last assistant message have results:
554566
const lastMessage = currentMessages[currentMessages.length - 1];
555567
if (isAssistantMessageWithCompletedToolCalls(lastMessage)) {
556568
triggerRequest({ messages: currentMessages });
557569
}
558570
},
559-
[mutate, triggerRequest],
571+
[mutate, status, triggerRequest],
560572
);
561573

562574
return {

packages/react/src/use-chat.ui.test.tsx

+71-9
Original file line numberDiff line numberDiff line change
@@ -387,9 +387,6 @@ describe('text stream', () => {
387387
server.urls['/api/chat'].response = {
388388
type: 'controlled-stream',
389389
controller,
390-
headers: {
391-
'Content-Type': 'text/event-stream',
392-
},
393390
};
394391

395392
await userEvent.click(screen.getByTestId('do-append-text-stream'));
@@ -735,7 +732,9 @@ describe('onToolCall', () => {
735732

736733
describe('tool invocations', () => {
737734
setupTestComponent(() => {
738-
const { messages, append, addToolResult } = useChat();
735+
const { messages, append, addToolResult } = useChat({
736+
maxSteps: 5,
737+
});
739738

740739
return (
741740
<div>
@@ -852,15 +851,11 @@ describe('tool invocations', () => {
852851
});
853852
});
854853

855-
it('should display partial tool call and tool result (when there is no tool call streaming)', async () => {
854+
it('should display tool call and tool result (when there is no tool call streaming)', async () => {
856855
const controller = new TestResponseController();
857-
858856
server.urls['/api/chat'].response = {
859857
type: 'controlled-stream',
860858
controller,
861-
headers: {
862-
'Content-Type': 'text/event-stream',
863-
},
864859
};
865860

866861
await userEvent.click(screen.getByTestId('do-append'));
@@ -922,6 +917,73 @@ describe('tool invocations', () => {
922917
);
923918
});
924919
});
920+
921+
it('should delay tool result submission until the stream is finished', async () => {
922+
const controller1 = new TestResponseController();
923+
const controller2 = new TestResponseController();
924+
925+
server.urls['/api/chat'].response = [
926+
{ type: 'controlled-stream', controller: controller1 },
927+
{ type: 'controlled-stream', controller: controller2 },
928+
];
929+
930+
await userEvent.click(screen.getByTestId('do-append'));
931+
932+
// start stream
933+
controller1.write(
934+
formatDataStreamPart('start_step', {
935+
messageId: '1234',
936+
}),
937+
);
938+
939+
// tool call
940+
controller1.write(
941+
formatDataStreamPart('tool_call', {
942+
toolCallId: 'tool-call-0',
943+
toolName: 'test-tool',
944+
args: { testArg: 'test-value' },
945+
}),
946+
);
947+
948+
await waitFor(() => {
949+
expect(screen.getByTestId('message-1')).toHaveTextContent(
950+
'{"state":"call","step":0,"toolCallId":"tool-call-0","toolName":"test-tool","args":{"testArg":"test-value"}}',
951+
);
952+
});
953+
954+
// user submits the tool result
955+
await userEvent.click(screen.getByTestId('add-result-0'));
956+
957+
// UI should show the tool result
958+
await waitFor(() => {
959+
expect(screen.getByTestId('message-1')).toHaveTextContent(
960+
'{"state":"result","step":0,"toolCallId":"tool-call-0","toolName":"test-tool","args":{"testArg":"test-value"},"result":"test-result"}',
961+
);
962+
});
963+
964+
// should not have called the API yet
965+
expect(server.calls.length).toBe(1);
966+
967+
// finish stream
968+
controller1.write(
969+
formatDataStreamPart('finish_step', {
970+
isContinued: false,
971+
finishReason: 'tool-calls',
972+
}),
973+
);
974+
controller1.write(
975+
formatDataStreamPart('finish_message', {
976+
finishReason: 'tool-calls',
977+
}),
978+
);
979+
980+
await controller1.close();
981+
982+
// 2nd call should happen after the stream is finished
983+
await waitFor(() => {
984+
expect(server.calls.length).toBe(2);
985+
});
986+
});
925987
});
926988

927989
describe('maxSteps', () => {

packages/solid/src/use-chat.ts

+5
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,11 @@ export function useChat(
516516

517517
mutate(currentMessages);
518518

519+
// when the request is ongoing, the auto-submit will be triggered after the request is finished
520+
if (status() === 'submitted' || status() === 'streaming') {
521+
return;
522+
}
523+
519524
// auto-submit when all tool calls in the last assistant message have results:
520525
const lastMessage = currentMessages[currentMessages.length - 1];
521526
if (isAssistantMessageWithCompletedToolCalls(lastMessage)) {

packages/svelte/src/chat.svelte.test.ts

+95-1
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,9 @@ describe('tool invocations', () => {
535535
let chat: Chat;
536536

537537
beforeEach(() => {
538-
chat = new Chat();
538+
chat = new Chat({
539+
maxSteps: 5,
540+
});
539541
});
540542

541543
it('should display partial tool call, tool call, and tool result', async () => {
@@ -774,6 +776,98 @@ describe('tool invocations', () => {
774776
);
775777
});
776778
});
779+
780+
it('should delay tool result submission until the stream is finished', async () => {
781+
const controller1 = new TestResponseController();
782+
const controller2 = new TestResponseController();
783+
784+
server.urls['/api/chat'].response = [
785+
{ type: 'controlled-stream', controller: controller1 },
786+
{ type: 'controlled-stream', controller: controller2 },
787+
];
788+
789+
chat.append({ role: 'user', content: 'hi' });
790+
791+
// start stream
792+
controller1.write(
793+
formatDataStreamPart('start_step', {
794+
messageId: '1234',
795+
}),
796+
);
797+
798+
// tool call
799+
controller1.write(
800+
formatDataStreamPart('tool_call', {
801+
toolCallId: 'tool-call-0',
802+
toolName: 'test-tool',
803+
args: { testArg: 'test-value' },
804+
}),
805+
);
806+
807+
await vi.waitFor(() => {
808+
expect(chat.messages.at(1)).toStrictEqual(
809+
expect.objectContaining({
810+
toolInvocations: [
811+
{
812+
state: 'call',
813+
step: 0,
814+
toolCallId: 'tool-call-0',
815+
toolName: 'test-tool',
816+
args: { testArg: 'test-value' },
817+
},
818+
],
819+
}),
820+
);
821+
});
822+
823+
// user submits the tool result
824+
chat.addToolResult({
825+
toolCallId: 'tool-call-0',
826+
result: 'test-result',
827+
});
828+
829+
// UI should show the tool result
830+
await vi.waitFor(() => {
831+
expect(chat.messages.at(1)).toStrictEqual(
832+
expect.objectContaining({
833+
toolInvocations: [
834+
{
835+
state: 'result',
836+
step: 0,
837+
toolCallId: 'tool-call-0',
838+
toolName: 'test-tool',
839+
args: { testArg: 'test-value' },
840+
result: 'test-result',
841+
},
842+
],
843+
}),
844+
);
845+
});
846+
847+
// should not have called the API yet
848+
expect(server.calls.length).toBe(1);
849+
850+
// finish stream
851+
controller1.write(
852+
formatDataStreamPart('finish_step', {
853+
isContinued: false,
854+
finishReason: 'tool-calls',
855+
}),
856+
);
857+
858+
controller1.write(
859+
formatDataStreamPart('finish_message', {
860+
finishReason: 'tool-calls',
861+
}),
862+
);
863+
864+
await controller1.close();
865+
866+
// 2nd call should happen after the stream is finished
867+
await vi.waitFor(() => {
868+
expect(server.calls.length).toBe(2);
869+
});
870+
});
777871
});
778872

779873
describe('maxSteps', () => {

packages/svelte/src/chat.svelte.ts

+8
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,14 @@ export class Chat {
220220
toolResult: result,
221221
});
222222

223+
// when the request is ongoing, the auto-submit will be triggered after the request is finished
224+
if (
225+
this.#store.status === 'submitted' ||
226+
this.#store.status === 'streaming'
227+
) {
228+
return;
229+
}
230+
223231
const lastMessage = this.messages[this.messages.length - 1];
224232
if (isAssistantMessageWithCompletedToolCalls(lastMessage)) {
225233
await this.#triggerRequest({ messages: this.messages });

packages/vue/src/TestChatToolInvocationsComponent.vue

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
<script setup lang="ts">
22
import { useChat } from './use-chat';
33
4-
const { messages, append, addToolResult } = useChat();
4+
const { messages, append, addToolResult } = useChat({
5+
maxSteps: 5,
6+
});
57
</script>
68

79
<template>
@@ -27,6 +29,9 @@ const { messages, append, addToolResult } = useChat();
2729
"
2830
/>
2931
</div>
32+
<div :data-testid="`text-${idx}`">
33+
{{ m.content }}
34+
</div>
3035
</div>
3136

3237
<button

packages/vue/src/use-chat.ts

+5
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,11 @@ export function useChat(
424424

425425
mutate(currentMessages);
426426

427+
// when the request is ongoing, the auto-submit will be triggered after the request is finished
428+
if (status.value === 'submitted' || status.value === 'streaming') {
429+
return;
430+
}
431+
427432
// auto-submit when all tool calls in the last assistant message have results:
428433
const lastMessage = currentMessages[currentMessages.length - 1];
429434
if (isAssistantMessageWithCompletedToolCalls(lastMessage)) {

0 commit comments

Comments
 (0)