@assistant-ui/ai-sdk 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/converters/convertMessage.d.ts.map +1 -1
- package/dist/converters/convertMessage.js +23 -2
- package/dist/converters/convertMessage.js.map +1 -1
- package/dist/runtime/AISDKChat.js +1 -1
- package/dist/runtime/AISDKThreads.d.ts +10 -8
- package/dist/runtime/AISDKThreads.d.ts.map +1 -1
- package/dist/runtime/AISDKThreads.js +34 -27
- package/dist/runtime/AISDKThreads.js.map +1 -1
- package/dist/runtime/useAISDKRuntime.d.ts +2 -0
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
- package/dist/runtime/useAISDKRuntime.js +171 -68
- package/dist/runtime/useAISDKRuntime.js.map +1 -1
- package/dist/runtime/useChatRuntime.js +1 -1
- package/dist/runtime/useChatThread.d.ts +7 -0
- package/dist/runtime/useChatThread.d.ts.map +1 -1
- package/dist/runtime/useChatThread.js +2 -1
- package/dist/runtime/useChatThread.js.map +1 -1
- package/dist/runtime/useExternalHistory.d.ts.map +1 -1
- package/dist/runtime/useExternalHistory.js +42 -47
- package/dist/runtime/useExternalHistory.js.map +1 -1
- package/dist/runtime/useResourceCleanup.js +1 -1
- package/dist/usage.d.ts +1 -2
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +5 -7
- package/dist/usage.js.map +1 -1
- package/package.json +10 -10
- package/src/converters/convertMessage.test.ts +22 -0
- package/src/converters/convertMessage.ts +26 -2
- package/src/runtime/AISDKThreads.cloud.test.ts +11 -1
- package/src/runtime/AISDKThreads.test.ts +102 -0
- package/src/runtime/AISDKThreads.ts +16 -9
- package/src/runtime/useAISDKRuntime.test.ts +288 -3
- package/src/runtime/useAISDKRuntime.ts +90 -5
- package/src/runtime/useChatRuntime.integration.test.tsx +46 -0
- package/src/runtime/useChatThread.ts +11 -0
- package/src/runtime/useExternalHistory.test.ts +64 -0
- package/src/runtime/useExternalHistory.ts +40 -56
- package/src/usage.test.ts +26 -8
- package/src/usage.ts +4 -9
|
@@ -95,7 +95,7 @@ describe("AISDKThreads cloud", () => {
|
|
|
95
95
|
handle.destroy();
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
it("
|
|
98
|
+
it("keeps an in-flight cloud chat running across a switch and stops it on delete", async () => {
|
|
99
99
|
const chat = createCancellableTransport();
|
|
100
100
|
const handle = createAssistantClient(
|
|
101
101
|
AuiConfig({
|
|
@@ -126,6 +126,16 @@ describe("AISDKThreads cloud", () => {
|
|
|
126
126
|
await vi.waitFor(() => {
|
|
127
127
|
expect(handle.getClient().threads.getState().mainThreadId).toBe("t2");
|
|
128
128
|
});
|
|
129
|
+
expect(chat.getCancelCount()).toBe(0);
|
|
130
|
+
await vi.waitFor(() => {
|
|
131
|
+
expect(
|
|
132
|
+
handle.getClient().threads.item({ id: "t1" }).getState().isRunning,
|
|
133
|
+
).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
flushTapSync(() =>
|
|
137
|
+
handle.getClient().threads.item({ id: "t1" }).delete(),
|
|
138
|
+
);
|
|
129
139
|
await vi.waitFor(() => {
|
|
130
140
|
expect(chat.getCancelCount()).toBe(1);
|
|
131
141
|
});
|
|
@@ -546,3 +546,105 @@ describe("AISDKThreads", () => {
|
|
|
546
546
|
}
|
|
547
547
|
});
|
|
548
548
|
});
|
|
549
|
+
|
|
550
|
+
describe("AISDKThreads branch isolation", () => {
|
|
551
|
+
const completeRound = async (
|
|
552
|
+
handle: ReturnType<typeof createAssistantClient>,
|
|
553
|
+
emit: (...chunks: never[]) => void,
|
|
554
|
+
close: () => void,
|
|
555
|
+
question: string,
|
|
556
|
+
answer: string,
|
|
557
|
+
) => {
|
|
558
|
+
const aui = handle.getClient();
|
|
559
|
+
flushTapSync(() => aui.composer.setText(question));
|
|
560
|
+
flushTapSync(() => aui.composer.send());
|
|
561
|
+
await vi.waitFor(() => {
|
|
562
|
+
expect(aui.thread.getState().messages.length).toBeGreaterThan(0);
|
|
563
|
+
});
|
|
564
|
+
emit(...(textReply(answer) as never[]));
|
|
565
|
+
close();
|
|
566
|
+
await vi.waitFor(() => {
|
|
567
|
+
expect(handle.getClient().thread.getState().isRunning).toBe(false);
|
|
568
|
+
});
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
it("keeps switching between populated threads free of cross-thread branches", async () => {
|
|
572
|
+
const { transport, emit, close } = createControlledTransport();
|
|
573
|
+
const handle = createAssistantClient(
|
|
574
|
+
AuiConfig({ threads: AISDKThreads({ transport }) }),
|
|
575
|
+
);
|
|
576
|
+
handle.subscribe(() => {});
|
|
577
|
+
const aui = handle.getClient();
|
|
578
|
+
|
|
579
|
+
await completeRound(
|
|
580
|
+
handle,
|
|
581
|
+
emit as never,
|
|
582
|
+
close,
|
|
583
|
+
"thread a question",
|
|
584
|
+
"thread a answer",
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
flushTapSync(() => aui.threads.switchToNewThread());
|
|
588
|
+
await completeRound(
|
|
589
|
+
handle,
|
|
590
|
+
emit as never,
|
|
591
|
+
close,
|
|
592
|
+
"thread b question",
|
|
593
|
+
"thread b answer",
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
flushTapSync(() => aui.threads.switchToThread("main"));
|
|
597
|
+
await vi.waitFor(() => {
|
|
598
|
+
expect(threadText(handle as never)).toEqual([
|
|
599
|
+
"thread a question",
|
|
600
|
+
"thread a answer",
|
|
601
|
+
]);
|
|
602
|
+
});
|
|
603
|
+
expect(aui.thread.message({ index: 0 }).getState().branchCount).toBe(1);
|
|
604
|
+
expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(1);
|
|
605
|
+
|
|
606
|
+
handle.destroy();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("preserves intra-thread branches across a switch through an empty thread", async () => {
|
|
610
|
+
const { transport, emit, close } = createControlledTransport();
|
|
611
|
+
const handle = createAssistantClient(
|
|
612
|
+
AuiConfig({ threads: AISDKThreads({ transport }) }),
|
|
613
|
+
);
|
|
614
|
+
handle.subscribe(() => {});
|
|
615
|
+
const aui = handle.getClient();
|
|
616
|
+
|
|
617
|
+
await completeRound(
|
|
618
|
+
handle,
|
|
619
|
+
emit as never,
|
|
620
|
+
close,
|
|
621
|
+
"branchy question",
|
|
622
|
+
"first answer",
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
flushTapSync(() => aui.thread.message({ index: 1 }).reload());
|
|
626
|
+
await vi.waitFor(() => {
|
|
627
|
+
expect(handle.getClient().thread.getState().isRunning).toBe(true);
|
|
628
|
+
});
|
|
629
|
+
emit(...(textReply("second answer") as never[]));
|
|
630
|
+
close();
|
|
631
|
+
await vi.waitFor(() => {
|
|
632
|
+
expect(handle.getClient().thread.getState().isRunning).toBe(false);
|
|
633
|
+
expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(2);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
flushTapSync(() => aui.threads.switchToNewThread());
|
|
637
|
+
expect(handle.getClient().thread.getState().messages).toHaveLength(0);
|
|
638
|
+
|
|
639
|
+
flushTapSync(() => handle.getClient().threads.switchToThread("main"));
|
|
640
|
+
await vi.waitFor(() => {
|
|
641
|
+
expect(threadText(handle as never)).toEqual([
|
|
642
|
+
"branchy question",
|
|
643
|
+
"second answer",
|
|
644
|
+
]);
|
|
645
|
+
});
|
|
646
|
+
expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(2);
|
|
647
|
+
|
|
648
|
+
handle.destroy();
|
|
649
|
+
});
|
|
650
|
+
});
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
useChatThread,
|
|
25
25
|
type ChatThreadOptions,
|
|
26
26
|
} from "./useChatThread";
|
|
27
|
+
import { MessageRepository } from "@assistant-ui/core/internal";
|
|
27
28
|
import { useResourceCleanup } from "./useResourceCleanup";
|
|
28
29
|
|
|
29
30
|
export type AISDKThreadsOptions<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
@@ -41,9 +42,9 @@ export type AISDKThreadsOptions<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
|
41
42
|
| undefined;
|
|
42
43
|
/**
|
|
43
44
|
* When set, the thread list is a `RemoteThreadList` backed by this
|
|
44
|
-
* assistant-cloud. Omit it to keep the in-memory list.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
45
|
+
* assistant-cloud. Omit it to keep the in-memory list. Every visited
|
|
46
|
+
* cloud thread stays mounted, so an in-flight run continues after a
|
|
47
|
+
* switch and stops on delete; per-thread history loads once per thread.
|
|
47
48
|
*/
|
|
48
49
|
cloud?: AssistantCloud | undefined;
|
|
49
50
|
/**
|
|
@@ -64,6 +65,7 @@ type AISDKThreadChatOptions<UI_MESSAGE extends UIMessage = UIMessage> = Omit<
|
|
|
64
65
|
type ChatEntry<UI_MESSAGE extends UIMessage> = {
|
|
65
66
|
chat: Chat<UI_MESSAGE>;
|
|
66
67
|
transport: ChatTransport<UI_MESSAGE>;
|
|
68
|
+
repository: MessageRepository;
|
|
67
69
|
};
|
|
68
70
|
|
|
69
71
|
const createChatEntry = <UI_MESSAGE extends UIMessage>(
|
|
@@ -84,6 +86,7 @@ const createChatEntry = <UI_MESSAGE extends UIMessage>(
|
|
|
84
86
|
return {
|
|
85
87
|
chat: new Chat<UI_MESSAGE>({ ...chatInit, id: threadId, transport }),
|
|
86
88
|
transport,
|
|
89
|
+
repository: new MessageRepository(),
|
|
87
90
|
};
|
|
88
91
|
};
|
|
89
92
|
|
|
@@ -113,7 +116,7 @@ const useAISDKChatThread = <UI_MESSAGE extends UIMessage = UIMessage>({
|
|
|
113
116
|
const [owned] = useState(() =>
|
|
114
117
|
cloud ? createChatEntry(threadId, options) : undefined,
|
|
115
118
|
);
|
|
116
|
-
const { chat, transport } =
|
|
119
|
+
const { chat, transport, repository } =
|
|
117
120
|
owned ?? getOrCreateChatEntry(threadId, options, chats);
|
|
118
121
|
|
|
119
122
|
useEffect(() => {
|
|
@@ -145,6 +148,7 @@ const useAISDKChatThread = <UI_MESSAGE extends UIMessage = UIMessage>({
|
|
|
145
148
|
: undefined
|
|
146
149
|
: fallbackItem,
|
|
147
150
|
chat,
|
|
151
|
+
messageRepositoryInstance: repository,
|
|
148
152
|
stopOnClientDestroy: cloud,
|
|
149
153
|
},
|
|
150
154
|
);
|
|
@@ -190,6 +194,7 @@ const useAISDKThreads = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
190
194
|
bindCloud
|
|
191
195
|
? RemoteThreadList({
|
|
192
196
|
adapter: cloudAdapter,
|
|
197
|
+
backgroundThreads: true,
|
|
193
198
|
thread,
|
|
194
199
|
threadId,
|
|
195
200
|
onThreadIdChange,
|
|
@@ -212,11 +217,13 @@ const useAISDKThreads = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
212
217
|
* per-thread orchestration as {@link AISDKChat} inside the client's own
|
|
213
218
|
* resource tree, so it works with any `AssistantClient` host, React or not.
|
|
214
219
|
* Without `cloud`, threads live in memory for the client's lifetime and keep
|
|
215
|
-
* their history across switches;
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
+
* their history across switches; only the visible thread is mounted, and a
|
|
221
|
+
* switched-away chat keeps streaming into its stored state until it settles
|
|
222
|
+
* or the thread is deleted. With `cloud`, the list is a
|
|
223
|
+
* `RemoteThreadList` with `backgroundThreads`: every visited thread stays
|
|
224
|
+
* mounted with its own history, a run continues after a switch and stops on
|
|
225
|
+
* delete, and a freshly created thread titles itself. Model context is
|
|
226
|
+
* registered on every mounted thread.
|
|
220
227
|
*/
|
|
221
228
|
export const AISDKThreads = resource(useAISDKThreads);
|
|
222
229
|
|
|
@@ -27,6 +27,7 @@ const createChatHelpers = (messages: any[] = []) => {
|
|
|
27
27
|
let currentMessages = [...messages];
|
|
28
28
|
|
|
29
29
|
const chatHelpers: any = {
|
|
30
|
+
id: "chat-1",
|
|
30
31
|
status: "ready",
|
|
31
32
|
error: null,
|
|
32
33
|
messages: currentMessages,
|
|
@@ -135,10 +136,11 @@ describe("useAISDKRuntime", () => {
|
|
|
135
136
|
abortError.name = "AbortError";
|
|
136
137
|
const chat = createChatHelpers();
|
|
137
138
|
let stopCalls = 0;
|
|
139
|
+
let rejectStop!: (error: unknown) => void;
|
|
138
140
|
chat.stop = () => {
|
|
139
141
|
stopCalls += 1;
|
|
140
142
|
return new Promise((_, reject) => {
|
|
141
|
-
|
|
143
|
+
rejectStop = reject;
|
|
142
144
|
});
|
|
143
145
|
};
|
|
144
146
|
const consoleError = vi
|
|
@@ -147,8 +149,11 @@ describe("useAISDKRuntime", () => {
|
|
|
147
149
|
|
|
148
150
|
try {
|
|
149
151
|
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
150
|
-
const unhandledRejections = await captureUnhandledRejections(() => {
|
|
151
|
-
|
|
152
|
+
const unhandledRejections = await captureUnhandledRejections(async () => {
|
|
153
|
+
await act(async () => {
|
|
154
|
+
result.current.thread.cancelRun();
|
|
155
|
+
rejectStop(abortError);
|
|
156
|
+
});
|
|
152
157
|
});
|
|
153
158
|
|
|
154
159
|
expect(stopCalls).toBe(1);
|
|
@@ -159,6 +164,286 @@ describe("useAISDKRuntime", () => {
|
|
|
159
164
|
}
|
|
160
165
|
});
|
|
161
166
|
|
|
167
|
+
it("marks only the stopped output cancelled", async () => {
|
|
168
|
+
let resolveStop!: () => void;
|
|
169
|
+
const chat = createChatHelpers([
|
|
170
|
+
{
|
|
171
|
+
id: "assistant-1",
|
|
172
|
+
role: "assistant",
|
|
173
|
+
parts: [{ type: "text", text: "partial", state: "streaming" }],
|
|
174
|
+
},
|
|
175
|
+
]);
|
|
176
|
+
chat.status = "streaming";
|
|
177
|
+
chat.stop = vi.fn(
|
|
178
|
+
() =>
|
|
179
|
+
new Promise<void>((resolve) => {
|
|
180
|
+
resolveStop = resolve;
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const { result, rerender } = renderHook(() => useAISDKRuntime(chat));
|
|
185
|
+
|
|
186
|
+
act(() => {
|
|
187
|
+
result.current.thread.cancelRun();
|
|
188
|
+
});
|
|
189
|
+
rerender();
|
|
190
|
+
|
|
191
|
+
expect(
|
|
192
|
+
result.current.thread.getState().messages.at(-1)?.status,
|
|
193
|
+
).toMatchObject({ type: "running" });
|
|
194
|
+
|
|
195
|
+
act(() => {
|
|
196
|
+
chat.status = "ready";
|
|
197
|
+
rerender();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await waitFor(() => {
|
|
201
|
+
expect(
|
|
202
|
+
result.current.thread.getState().messages.at(-1)?.status,
|
|
203
|
+
).toMatchObject({
|
|
204
|
+
type: "incomplete",
|
|
205
|
+
reason: "cancelled",
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await act(async () => {
|
|
210
|
+
resolveStop();
|
|
211
|
+
await Promise.resolve();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
act(() => {
|
|
215
|
+
chat.setMessages([
|
|
216
|
+
{
|
|
217
|
+
id: "assistant-2",
|
|
218
|
+
role: "assistant",
|
|
219
|
+
parts: [{ type: "text", text: "replacement" }],
|
|
220
|
+
},
|
|
221
|
+
]);
|
|
222
|
+
rerender();
|
|
223
|
+
});
|
|
224
|
+
await waitFor(() => {
|
|
225
|
+
expect(
|
|
226
|
+
result.current.thread.getState().messages.at(-1)?.status,
|
|
227
|
+
).toMatchObject({ type: "complete", reason: "unknown" });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
act(() => {
|
|
231
|
+
chat.setMessages([
|
|
232
|
+
{
|
|
233
|
+
id: "assistant-1",
|
|
234
|
+
role: "assistant",
|
|
235
|
+
parts: [{ type: "text", text: "partial" }],
|
|
236
|
+
},
|
|
237
|
+
]);
|
|
238
|
+
chat.status = "streaming";
|
|
239
|
+
rerender();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(
|
|
243
|
+
result.current.thread.getState().messages.at(-1)?.status,
|
|
244
|
+
).toMatchObject({ type: "running" });
|
|
245
|
+
|
|
246
|
+
act(() => {
|
|
247
|
+
chat.status = "ready";
|
|
248
|
+
rerender();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await waitFor(() => {
|
|
252
|
+
expect(
|
|
253
|
+
result.current.thread.getState().messages.at(-1)?.status,
|
|
254
|
+
).toMatchObject({
|
|
255
|
+
type: "complete",
|
|
256
|
+
reason: "unknown",
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("keeps the stopped output cancelled through the next turn", async () => {
|
|
262
|
+
const chat = createChatHelpers([
|
|
263
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
264
|
+
{
|
|
265
|
+
id: "assistant-1",
|
|
266
|
+
role: "assistant",
|
|
267
|
+
parts: [{ type: "text", text: "partial", state: "streaming" }],
|
|
268
|
+
},
|
|
269
|
+
]);
|
|
270
|
+
chat.status = "streaming";
|
|
271
|
+
|
|
272
|
+
const { result, rerender } = renderHook(() => useAISDKRuntime(chat));
|
|
273
|
+
|
|
274
|
+
await act(async () => {
|
|
275
|
+
result.current.thread.cancelRun();
|
|
276
|
+
});
|
|
277
|
+
act(() => {
|
|
278
|
+
chat.status = "ready";
|
|
279
|
+
rerender();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await waitFor(() => {
|
|
283
|
+
expect(
|
|
284
|
+
result.current.thread.getState().messages.at(-1)?.status,
|
|
285
|
+
).toMatchObject({ type: "incomplete", reason: "cancelled" });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
act(() => {
|
|
289
|
+
chat.setMessages([
|
|
290
|
+
...chat.messages,
|
|
291
|
+
{ id: "u2", role: "user", parts: [{ type: "text", text: "next" }] },
|
|
292
|
+
{
|
|
293
|
+
id: "assistant-2",
|
|
294
|
+
role: "assistant",
|
|
295
|
+
parts: [{ type: "text", text: "answer" }],
|
|
296
|
+
},
|
|
297
|
+
]);
|
|
298
|
+
rerender();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await waitFor(() => {
|
|
302
|
+
const messages = result.current.thread.getState().messages;
|
|
303
|
+
expect(
|
|
304
|
+
messages.find((message) => message.id === "assistant-1")?.status,
|
|
305
|
+
).toMatchObject({ type: "incomplete", reason: "cancelled" });
|
|
306
|
+
expect(messages.at(-1)?.status).toMatchObject({
|
|
307
|
+
type: "complete",
|
|
308
|
+
reason: "unknown",
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("retracts the cancellation when the provider picks the message back up", async () => {
|
|
314
|
+
const chat = createChatHelpers([
|
|
315
|
+
{
|
|
316
|
+
id: "assistant-1",
|
|
317
|
+
role: "assistant",
|
|
318
|
+
parts: [{ type: "text", text: "partial", state: "streaming" }],
|
|
319
|
+
},
|
|
320
|
+
]);
|
|
321
|
+
chat.status = "streaming";
|
|
322
|
+
|
|
323
|
+
const { result, rerender } = renderHook(() => useAISDKRuntime(chat));
|
|
324
|
+
|
|
325
|
+
await act(async () => {
|
|
326
|
+
result.current.thread.cancelRun();
|
|
327
|
+
});
|
|
328
|
+
act(() => {
|
|
329
|
+
chat.status = "ready";
|
|
330
|
+
rerender();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await waitFor(() => {
|
|
334
|
+
expect(
|
|
335
|
+
result.current.thread.getState().messages.at(-1)?.status,
|
|
336
|
+
).toMatchObject({ type: "incomplete", reason: "cancelled" });
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
act(() => {
|
|
340
|
+
chat.status = "streaming";
|
|
341
|
+
rerender();
|
|
342
|
+
});
|
|
343
|
+
act(() => {
|
|
344
|
+
chat.status = "ready";
|
|
345
|
+
rerender();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await waitFor(() => {
|
|
349
|
+
expect(
|
|
350
|
+
result.current.thread.getState().messages.at(-1)?.status,
|
|
351
|
+
).toMatchObject({ type: "complete", reason: "unknown" });
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("does not mark completed output cancelled when already idle", async () => {
|
|
356
|
+
const chat = createChatHelpers([
|
|
357
|
+
{
|
|
358
|
+
id: "assistant-1",
|
|
359
|
+
role: "assistant",
|
|
360
|
+
parts: [{ type: "text", text: "finished" }],
|
|
361
|
+
},
|
|
362
|
+
]);
|
|
363
|
+
|
|
364
|
+
const { result, rerender } = renderHook(() => useAISDKRuntime(chat));
|
|
365
|
+
|
|
366
|
+
act(() => {
|
|
367
|
+
result.current.thread.cancelRun();
|
|
368
|
+
rerender();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
await waitFor(() => {
|
|
372
|
+
expect(
|
|
373
|
+
result.current.thread.getState().messages.at(-1)?.status,
|
|
374
|
+
).toMatchObject({ type: "complete", reason: "unknown" });
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("marks output cancelled while a client tool is still executing", async () => {
|
|
379
|
+
let resolveTool!: (value: string) => void;
|
|
380
|
+
const execute = vi.fn(
|
|
381
|
+
() =>
|
|
382
|
+
new Promise<string>((resolve) => {
|
|
383
|
+
resolveTool = resolve;
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
const chat = createChatHelpers();
|
|
387
|
+
|
|
388
|
+
const { result, rerender } = renderHook(() => useAISDKRuntime(chat));
|
|
389
|
+
const unregister = result.current.registerModelContextProvider({
|
|
390
|
+
getModelContext: () => ({
|
|
391
|
+
tools: {
|
|
392
|
+
weather: {
|
|
393
|
+
parameters: { type: "object", properties: {} },
|
|
394
|
+
execute,
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
}),
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
act(() => {
|
|
402
|
+
chat.setMessages([
|
|
403
|
+
{
|
|
404
|
+
id: "assistant-1",
|
|
405
|
+
role: "assistant",
|
|
406
|
+
parts: [
|
|
407
|
+
{
|
|
408
|
+
type: "tool-weather",
|
|
409
|
+
toolCallId: "tool-1",
|
|
410
|
+
state: "input-available",
|
|
411
|
+
input: { city: "London" },
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
},
|
|
415
|
+
]);
|
|
416
|
+
rerender();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
await waitFor(() => {
|
|
420
|
+
expect(execute).toHaveBeenCalledOnce();
|
|
421
|
+
expect(result.current.thread.getState().isRunning).toBe(true);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
act(() => {
|
|
425
|
+
result.current.thread.cancelRun();
|
|
426
|
+
chat.setMessages([
|
|
427
|
+
{
|
|
428
|
+
id: "assistant-1",
|
|
429
|
+
role: "assistant",
|
|
430
|
+
parts: [{ type: "text", text: "stopped" }],
|
|
431
|
+
},
|
|
432
|
+
]);
|
|
433
|
+
resolveTool("sunny");
|
|
434
|
+
rerender();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await waitFor(() => {
|
|
438
|
+
expect(
|
|
439
|
+
result.current.thread.getState().messages.at(-1)?.status,
|
|
440
|
+
).toMatchObject({ type: "incomplete", reason: "cancelled" });
|
|
441
|
+
});
|
|
442
|
+
} finally {
|
|
443
|
+
unregister();
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
162
447
|
it("reports non-AbortError cancellation failures", async () => {
|
|
163
448
|
const stopError = new Error("stop failed");
|
|
164
449
|
const chat = createChatHelpers();
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useInsertionEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
4
11
|
import type {
|
|
5
12
|
UIMessage,
|
|
6
13
|
useChat,
|
|
@@ -82,6 +89,7 @@ export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
|
82
89
|
})
|
|
83
90
|
| undefined;
|
|
84
91
|
toCreateMessage?: CustomToCreateMessageFunction;
|
|
92
|
+
unstable_messageRepositoryInstance?: MessageRepository | undefined;
|
|
85
93
|
/**
|
|
86
94
|
* Whether to automatically cancel pending interactive tool calls when the user sends a new message.
|
|
87
95
|
*
|
|
@@ -142,9 +150,13 @@ const useGeneratedSuggestions = (
|
|
|
142
150
|
const controllerRef = useRef<AbortController | null>(null);
|
|
143
151
|
const wasRunningRef = useRef(false);
|
|
144
152
|
const messagesRef = useRef(messages);
|
|
145
|
-
|
|
153
|
+
useInsertionEffect(() => {
|
|
154
|
+
messagesRef.current = messages;
|
|
155
|
+
}, [messages]);
|
|
146
156
|
const adapterRef = useRef(suggestionAdapter);
|
|
147
|
-
|
|
157
|
+
useInsertionEffect(() => {
|
|
158
|
+
adapterRef.current = suggestionAdapter;
|
|
159
|
+
}, [suggestionAdapter]);
|
|
148
160
|
const hasAdapter = suggestionAdapter != null;
|
|
149
161
|
|
|
150
162
|
useEffect(() => {
|
|
@@ -205,6 +217,8 @@ const useGeneratedSuggestions = (
|
|
|
205
217
|
return suggestions;
|
|
206
218
|
};
|
|
207
219
|
|
|
220
|
+
const NO_CANCELLED_MESSAGE_IDS: ReadonlySet<string> = new Set();
|
|
221
|
+
|
|
208
222
|
export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
209
223
|
chatHelpers: ReturnType<typeof useChat<UI_MESSAGE>>,
|
|
210
224
|
adapter: AISDKRuntimeAdapter<UI_MESSAGE> = {},
|
|
@@ -224,6 +238,10 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
224
238
|
const [toolStatuses, setToolStatuses] = useState<
|
|
225
239
|
Record<string, ToolExecutionStatus>
|
|
226
240
|
>({});
|
|
241
|
+
const [cancelledMessages, setCancelledMessages] = useState<{
|
|
242
|
+
chatId: string;
|
|
243
|
+
ids: ReadonlySet<string>;
|
|
244
|
+
} | null>(null);
|
|
227
245
|
const toolArgsKeyOrderCacheRef = useRef<Map<string, Map<string, string[]>>>(
|
|
228
246
|
new Map(),
|
|
229
247
|
);
|
|
@@ -239,6 +257,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
239
257
|
const providerIsRunning =
|
|
240
258
|
chatHelpers.status === "submitted" || chatHelpers.status === "streaming";
|
|
241
259
|
const isRunning = providerIsRunning || hasExecutingTools;
|
|
260
|
+
const wasProviderRunningRef = useRef(providerIsRunning);
|
|
242
261
|
|
|
243
262
|
const messageTiming = useStreamingTiming(chatHelpers.messages, isRunning);
|
|
244
263
|
|
|
@@ -248,6 +267,42 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
248
267
|
const optimisticMessageId =
|
|
249
268
|
isRunning && lastMessage?.role === "assistant" ? lastMessage.id : undefined;
|
|
250
269
|
|
|
270
|
+
const cancelledMessageIds =
|
|
271
|
+
cancelledMessages?.chatId === chatHelpers.id
|
|
272
|
+
? cancelledMessages.ids
|
|
273
|
+
: NO_CANCELLED_MESSAGE_IDS;
|
|
274
|
+
|
|
275
|
+
const retractCancellation = useCallback(
|
|
276
|
+
(chatId: string, messageId: string) => {
|
|
277
|
+
setCancelledMessages((prev) => {
|
|
278
|
+
if (prev?.chatId !== chatId || !prev.ids.has(messageId)) return prev;
|
|
279
|
+
const ids = new Set(prev.ids);
|
|
280
|
+
ids.delete(messageId);
|
|
281
|
+
return { chatId, ids };
|
|
282
|
+
});
|
|
283
|
+
},
|
|
284
|
+
[],
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// A provider run that resumes the stopped response retracts its cancellation;
|
|
288
|
+
// a run that starts a new response leaves the stopped one marked.
|
|
289
|
+
const resumedMessageId =
|
|
290
|
+
providerIsRunning && lastMessage?.role === "assistant"
|
|
291
|
+
? lastMessage.id
|
|
292
|
+
: undefined;
|
|
293
|
+
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
const wasProviderRunning = wasProviderRunningRef.current;
|
|
296
|
+
wasProviderRunningRef.current = providerIsRunning;
|
|
297
|
+
if (wasProviderRunning || !resumedMessageId) return;
|
|
298
|
+
retractCancellation(chatHelpers.id, resumedMessageId);
|
|
299
|
+
}, [
|
|
300
|
+
providerIsRunning,
|
|
301
|
+
resumedMessageId,
|
|
302
|
+
chatHelpers.id,
|
|
303
|
+
retractCancellation,
|
|
304
|
+
]);
|
|
305
|
+
|
|
251
306
|
const messages = AISDKMessageConverter.useThreadMessages({
|
|
252
307
|
isRunning,
|
|
253
308
|
messages: chatHelpers.messages,
|
|
@@ -261,8 +316,15 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
261
316
|
mcpAppMetadataCache: mcpAppMetadataCacheRef.current,
|
|
262
317
|
...(optimisticMessageId && { optimisticMessageId }),
|
|
263
318
|
...(chatHelpers.error && { error: chatHelpers.error.message }),
|
|
319
|
+
...(cancelledMessageIds.size > 0 && { cancelledMessageIds }),
|
|
264
320
|
}),
|
|
265
|
-
[
|
|
321
|
+
[
|
|
322
|
+
toolStatuses,
|
|
323
|
+
messageTiming,
|
|
324
|
+
optimisticMessageId,
|
|
325
|
+
chatHelpers.error,
|
|
326
|
+
cancelledMessageIds,
|
|
327
|
+
],
|
|
266
328
|
),
|
|
267
329
|
});
|
|
268
330
|
|
|
@@ -432,10 +494,27 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
432
494
|
runtimeRef.current.thread.import(exportedRepo);
|
|
433
495
|
},
|
|
434
496
|
onCancel: async () => {
|
|
497
|
+
const message = chatHelpers.messages.at(-1);
|
|
498
|
+
const cancelledId =
|
|
499
|
+
isRunning && message?.role === "assistant" ? message.id : undefined;
|
|
500
|
+
if (cancelledId) {
|
|
501
|
+
const liveIds = new Set(chatHelpers.messages.map((m) => m.id));
|
|
502
|
+
setCancelledMessages((prev) => {
|
|
503
|
+
const kept =
|
|
504
|
+
prev?.chatId === chatHelpers.id
|
|
505
|
+
? [...prev.ids].filter((id) => liveIds.has(id))
|
|
506
|
+
: [];
|
|
507
|
+
return {
|
|
508
|
+
chatId: chatHelpers.id,
|
|
509
|
+
ids: new Set([...kept, cancelledId]),
|
|
510
|
+
};
|
|
511
|
+
});
|
|
512
|
+
}
|
|
435
513
|
try {
|
|
436
514
|
await chatHelpers.stop();
|
|
437
515
|
} catch (error) {
|
|
438
516
|
if (!(error instanceof Error && error.name === "AbortError")) {
|
|
517
|
+
if (cancelledId) retractCancellation(chatHelpers.id, cancelledId);
|
|
439
518
|
throw error;
|
|
440
519
|
}
|
|
441
520
|
}
|
|
@@ -549,6 +628,10 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
549
628
|
}),
|
|
550
629
|
),
|
|
551
630
|
...pickExternalStoreSharedOptions(adapter),
|
|
631
|
+
...(adapter.unstable_messageRepositoryInstance && {
|
|
632
|
+
unstable_messageRepositoryInstance:
|
|
633
|
+
adapter.unstable_messageRepositoryInstance,
|
|
634
|
+
}),
|
|
552
635
|
...(suggestionAdapter ? { suggestions: generatedSuggestions } : {}),
|
|
553
636
|
...(onResume && { onResume }),
|
|
554
637
|
...(onResumeToolCall && { onResumeToolCall }),
|
|
@@ -563,7 +646,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
563
646
|
});
|
|
564
647
|
|
|
565
648
|
const setMessagesRef = useRef(chatHelpers.setMessages);
|
|
566
|
-
|
|
649
|
+
useInsertionEffect(() => {
|
|
650
|
+
setMessagesRef.current = chatHelpers.setMessages;
|
|
651
|
+
}, [chatHelpers.setMessages]);
|
|
567
652
|
|
|
568
653
|
useEffect(() => {
|
|
569
654
|
if (hasSeededRepositoryRef.current) return;
|