@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.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/dist/converters/convertMessage.d.ts.map +1 -1
  3. package/dist/converters/convertMessage.js +23 -2
  4. package/dist/converters/convertMessage.js.map +1 -1
  5. package/dist/runtime/AISDKChat.js +1 -1
  6. package/dist/runtime/AISDKThreads.d.ts +10 -8
  7. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  8. package/dist/runtime/AISDKThreads.js +34 -27
  9. package/dist/runtime/AISDKThreads.js.map +1 -1
  10. package/dist/runtime/useAISDKRuntime.d.ts +2 -0
  11. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  12. package/dist/runtime/useAISDKRuntime.js +171 -68
  13. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  14. package/dist/runtime/useChatRuntime.js +1 -1
  15. package/dist/runtime/useChatThread.d.ts +7 -0
  16. package/dist/runtime/useChatThread.d.ts.map +1 -1
  17. package/dist/runtime/useChatThread.js +2 -1
  18. package/dist/runtime/useChatThread.js.map +1 -1
  19. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  20. package/dist/runtime/useExternalHistory.js +42 -47
  21. package/dist/runtime/useExternalHistory.js.map +1 -1
  22. package/dist/runtime/useResourceCleanup.js +1 -1
  23. package/dist/usage.d.ts +1 -2
  24. package/dist/usage.d.ts.map +1 -1
  25. package/dist/usage.js +5 -7
  26. package/dist/usage.js.map +1 -1
  27. package/package.json +10 -10
  28. package/src/converters/convertMessage.test.ts +22 -0
  29. package/src/converters/convertMessage.ts +26 -2
  30. package/src/runtime/AISDKThreads.cloud.test.ts +11 -1
  31. package/src/runtime/AISDKThreads.test.ts +102 -0
  32. package/src/runtime/AISDKThreads.ts +16 -9
  33. package/src/runtime/useAISDKRuntime.test.ts +288 -3
  34. package/src/runtime/useAISDKRuntime.ts +90 -5
  35. package/src/runtime/useChatRuntime.integration.test.tsx +46 -0
  36. package/src/runtime/useChatThread.ts +11 -0
  37. package/src/runtime/useExternalHistory.test.ts +64 -0
  38. package/src/runtime/useExternalHistory.ts +40 -56
  39. package/src/usage.test.ts +26 -8
  40. package/src/usage.ts +4 -9
@@ -95,7 +95,7 @@ describe("AISDKThreads cloud", () => {
95
95
  handle.destroy();
96
96
  });
97
97
 
98
- it("stops an in-flight cloud chat when switching away", async () => {
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. The thread
45
- * factory is keyed so cloud history reloads on a switch, and an
46
- * in-flight run does not continue in the background.
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; each thread's chat id is its thread id.
216
- * With `cloud`, the list is a `RemoteThreadList` and the factory is keyed so
217
- * cloud history reloads on a switch. The store entry mounts only the visible
218
- * thread, so a switch cancels an in-flight run. Model context is
219
- * registered on the visible thread only.
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
- setTimeout(() => reject(abortError), 5);
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
- result.current.thread.cancelRun();
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 { useEffect, useMemo, useRef, useState } from "react";
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
- messagesRef.current = messages;
153
+ useInsertionEffect(() => {
154
+ messagesRef.current = messages;
155
+ }, [messages]);
146
156
  const adapterRef = useRef(suggestionAdapter);
147
- adapterRef.current = suggestionAdapter;
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
- [toolStatuses, messageTiming, optimisticMessageId, chatHelpers.error],
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
- setMessagesRef.current = chatHelpers.setMessages;
649
+ useInsertionEffect(() => {
650
+ setMessagesRef.current = chatHelpers.setMessages;
651
+ }, [chatHelpers.setMessages]);
567
652
 
568
653
  useEffect(() => {
569
654
  if (hasSeededRepositoryRef.current) return;