@assistant-ui/ai-sdk 0.0.1 → 0.0.3

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 (32) hide show
  1. package/dist/converters/convertMessage.d.ts.map +1 -1
  2. package/dist/converters/toCreateMessage.d.ts.map +1 -1
  3. package/dist/converters/toCreateMessage.js +6 -2
  4. package/dist/converters/toCreateMessage.js.map +1 -1
  5. package/dist/model-context/injectInteractableContext.d.ts +1 -1
  6. package/dist/model-context/injectInteractableContext.js +1 -1
  7. package/dist/model-context/injectInteractableContext.js.map +1 -1
  8. package/dist/model-context/injectQuoteContext.d.ts +1 -1
  9. package/dist/model-context/injectQuoteContext.js +1 -1
  10. package/dist/model-context/injectQuoteContext.js.map +1 -1
  11. package/dist/runtime/useAISDKRuntime.d.ts +16 -3
  12. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  13. package/dist/runtime/useAISDKRuntime.js +29 -6
  14. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  15. package/dist/runtime/useChatThread.d.ts +7 -1
  16. package/dist/runtime/useChatThread.d.ts.map +1 -1
  17. package/dist/runtime/useChatThread.js +9 -3
  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 +10 -0
  21. package/dist/runtime/useExternalHistory.js.map +1 -1
  22. package/package.json +11 -11
  23. package/src/converters/toCreateMessage.ts +6 -5
  24. package/src/model-context/injectInteractableContext.ts +1 -1
  25. package/src/model-context/injectQuoteContext.ts +1 -1
  26. package/src/runtime/useAISDKRuntime.denied-tool.test.tsx +122 -0
  27. package/src/runtime/useAISDKRuntime.test.ts +191 -7
  28. package/src/runtime/useAISDKRuntime.ts +110 -51
  29. package/src/runtime/useChatRuntime.test.ts +16 -0
  30. package/src/runtime/useChatThread.ts +15 -0
  31. package/src/runtime/useExternalHistory.test.ts +79 -1
  32. package/src/runtime/useExternalHistory.ts +13 -0
@@ -35,7 +35,10 @@ import {
35
35
  getExternalStoreMessages,
36
36
  pickExternalStoreSharedOptions,
37
37
  } from "@assistant-ui/core";
38
- import { consumeSuggestionResult } from "@assistant-ui/core/internal";
38
+ import {
39
+ consumeSuggestionResult,
40
+ MessageRepository,
41
+ } from "@assistant-ui/core/internal";
39
42
  import type { ReadonlyJSONObject } from "assistant-stream/utils";
40
43
  import { sliceMessagesUntil } from "../utils/sliceMessagesUntil";
41
44
  import { toCreateMessage } from "../converters/toCreateMessage";
@@ -70,48 +73,62 @@ const toUIMessage = <UI_MESSAGE extends UIMessage>(
70
73
  role: createMessage.role ?? fallbackRole,
71
74
  }) as UI_MESSAGE;
72
75
 
73
- export type AISDKRuntimeAdapter = ExternalStoreSharedOptions & {
74
- adapters?:
75
- | (NonNullable<ExternalStoreAdapter["adapters"]> & {
76
- history?: ThreadHistoryAdapter | undefined;
77
- suggestion?: SuggestionAdapter | undefined;
78
- })
79
- | undefined;
80
- toCreateMessage?: CustomToCreateMessageFunction;
81
- /**
82
- * Whether to automatically cancel pending interactive tool calls when the user sends a new message.
83
- *
84
- * When enabled (default), the pending tool calls will be marked as failed with an error message
85
- * indicating the user cancelled the tool call by sending a new message.
86
- *
87
- * @default true
88
- */
89
- cancelPendingToolCallsOnSend?: boolean | undefined;
90
- /**
91
- * Called when `runtime.thread.resumeRun(config)` is invoked.
92
- *
93
- * When omitted, `resumeRun` throws `"Runtime does not support resuming runs."`.
94
- * Provide this to bridge resume invocations into a custom replay channel
95
- * (for example, an SSE reconnect endpoint keyed by turn id).
96
- */
97
- onResume?: ExternalStoreAdapter["onResume"];
98
- /**
99
- * Called when `runtime.thread.resumeToolCall(options)` is invoked for a tool call the in-process tracker does not own.
100
- *
101
- * When omitted, `resumeToolCall` throws `"Tool call ${toolCallId} is not waiting for resume."`.
102
- * Provide this to bridge resume-tool-call invocations into a custom handler.
103
- */
104
- onResumeToolCall?: ExternalStoreAdapter["onResumeToolCall"];
105
- /**
106
- * How consecutive assistant messages are rendered.
107
- *
108
- * `"concat-content"` (the default) merges them into a single thread message.
109
- * `"none"` keeps each assistant message as its own thread message, which is
110
- * useful when a backend persists proactive or consecutive assistant messages
111
- * as separate entries.
112
- */
113
- joinStrategy?: JoinStrategy | undefined;
114
- };
76
+ export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
77
+ ExternalStoreSharedOptions & {
78
+ adapters?:
79
+ | (NonNullable<ExternalStoreAdapter["adapters"]> & {
80
+ history?: ThreadHistoryAdapter | undefined;
81
+ suggestion?: SuggestionAdapter | undefined;
82
+ })
83
+ | undefined;
84
+ toCreateMessage?: CustomToCreateMessageFunction;
85
+ /**
86
+ * Whether to automatically cancel pending interactive tool calls when the user sends a new message.
87
+ *
88
+ * When enabled (default), the pending tool calls will be marked as failed with an error message
89
+ * indicating the user cancelled the tool call by sending a new message.
90
+ *
91
+ * @default true
92
+ */
93
+ cancelPendingToolCallsOnSend?: boolean | undefined;
94
+ /**
95
+ * Called when `runtime.thread.resumeRun(config)` is invoked.
96
+ *
97
+ * When omitted, `resumeRun` throws `"Runtime does not support resuming runs."`.
98
+ * Provide this to bridge resume invocations into a custom replay channel
99
+ * (for example, an SSE reconnect endpoint keyed by turn id).
100
+ */
101
+ onResume?: ExternalStoreAdapter["onResume"];
102
+ /**
103
+ * Called when `runtime.thread.resumeToolCall(options)` is invoked for a tool call the in-process tracker does not own.
104
+ *
105
+ * When omitted, `resumeToolCall` throws `"Tool call ${toolCallId} is not waiting for resume."`.
106
+ * Provide this to bridge resume-tool-call invocations into a custom handler.
107
+ */
108
+ onResumeToolCall?: ExternalStoreAdapter["onResumeToolCall"];
109
+ /**
110
+ * How consecutive assistant messages are rendered.
111
+ *
112
+ * `"concat-content"` (the default) merges them into a single thread message.
113
+ * `"none"` keeps each assistant message as its own thread message, which is
114
+ * useful when a backend persists proactive or consecutive assistant messages
115
+ * as separate entries.
116
+ */
117
+ joinStrategy?: JoinStrategy | undefined;
118
+ /**
119
+ * A branch-aware AI SDK message tree seeded once when `useChat` is empty.
120
+ * After that seed, live updates come only from `useChat`. A later empty
121
+ * chat or a new object identity does not reload the tree.
122
+ */
123
+ messageRepository?: MessageFormatRepository<UI_MESSAGE>;
124
+ /**
125
+ * Called after an explicit `switchToBranch` (for example a BranchPicker
126
+ * click). Complements `setMessages` and does not enable switching by itself.
127
+ *
128
+ * @deprecated This API is still under active development and might change without notice.
129
+ */
130
+ unstable_onBranchChange?: ExternalStoreAdapter["unstable_onBranchChange"];
131
+ };
115
132
 
116
133
  const EMPTY_SUGGESTIONS: readonly ThreadSuggestion[] = [];
117
134
 
@@ -190,7 +207,7 @@ const useGeneratedSuggestions = (
190
207
 
191
208
  export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
192
209
  chatHelpers: ReturnType<typeof useChat<UI_MESSAGE>>,
193
- adapter: AISDKRuntimeAdapter = {},
210
+ adapter: AISDKRuntimeAdapter<UI_MESSAGE> = {},
194
211
  ) => {
195
212
  const {
196
213
  adapters,
@@ -199,6 +216,8 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
199
216
  onResume,
200
217
  onResumeToolCall,
201
218
  joinStrategy,
219
+ messageRepository,
220
+ unstable_onBranchChange,
202
221
  } = adapter;
203
222
  const suggestionAdapter = adapters?.suggestion;
204
223
  const contextAdapters = useRuntimeAdapters();
@@ -217,10 +236,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
217
236
  const hasExecutingTools = Object.values(toolStatuses).some(
218
237
  (s) => s?.type === "executing",
219
238
  );
220
- const isRunning =
221
- chatHelpers.status === "submitted" ||
222
- chatHelpers.status === "streaming" ||
223
- hasExecutingTools;
239
+ const providerIsRunning =
240
+ chatHelpers.status === "submitted" || chatHelpers.status === "streaming";
241
+ const isRunning = providerIsRunning || hasExecutingTools;
224
242
 
225
243
  const messageTiming = useStreamingTiming(chatHelpers.messages, isRunning);
226
244
 
@@ -248,6 +266,17 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
248
266
  ),
249
267
  });
250
268
 
269
+ const exportedMessageRepository = useMemo(() => {
270
+ if (!messageRepository) return undefined;
271
+ const converted = toExportedMessageRepository(
272
+ AISDKMessageConverter.toThreadMessages as (
273
+ messages: UI_MESSAGE[],
274
+ ) => ThreadMessage[],
275
+ messageRepository,
276
+ );
277
+ return converted.messages.length > 0 ? converted : undefined;
278
+ }, [messageRepository]);
279
+
251
280
  const generatedSuggestions = useGeneratedSuggestions(
252
281
  suggestionAdapter,
253
282
  messages,
@@ -306,7 +335,11 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
306
335
  let hasChanges = false;
307
336
  const parts = lastMessage.parts?.map((part) => {
308
337
  if (!isToolUIPart(part)) return part;
309
- if (part.state === "output-available" || part.state === "output-error")
338
+ if (
339
+ part.state === "output-available" ||
340
+ part.state === "output-error" ||
341
+ part.state === "output-denied"
342
+ )
310
343
  return part;
311
344
 
312
345
  hasChanges = true;
@@ -323,9 +356,17 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
323
356
  });
324
357
  };
325
358
 
359
+ const hasSeededRepositoryRef = useRef(false);
360
+ const shouldFeedRepository =
361
+ exportedMessageRepository != null &&
362
+ !hasSeededRepositoryRef.current &&
363
+ messages.length === 0;
364
+
326
365
  const runtime = useExternalStoreRuntime({
327
- isRunning,
328
- messages,
366
+ isRunning: providerIsRunning,
367
+ ...(shouldFeedRepository
368
+ ? { messageRepository: exportedMessageRepository }
369
+ : { messages }),
329
370
  unstable_enableToolInvocations: true,
330
371
  setToolStatuses,
331
372
  setMessages: (messages) =>
@@ -511,6 +552,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
511
552
  ...(suggestionAdapter ? { suggestions: generatedSuggestions } : {}),
512
553
  ...(onResume && { onResume }),
513
554
  ...(onResumeToolCall && { onResumeToolCall }),
555
+ ...(unstable_onBranchChange && { unstable_onBranchChange }),
514
556
  adapters: {
515
557
  attachments: vercelAttachmentAdapter,
516
558
  ...contextAdapters,
@@ -520,5 +562,22 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
520
562
  isLoading,
521
563
  });
522
564
 
565
+ const setMessagesRef = useRef(chatHelpers.setMessages);
566
+ setMessagesRef.current = chatHelpers.setMessages;
567
+
568
+ useEffect(() => {
569
+ if (hasSeededRepositoryRef.current) return;
570
+ if (!exportedMessageRepository) return;
571
+ if (chatHelpers.messages.length > 0) {
572
+ hasSeededRepositoryRef.current = true;
573
+ return;
574
+ }
575
+ const tempRepo = new MessageRepository();
576
+ tempRepo.import(exportedMessageRepository);
577
+ setMessagesRef.current(
578
+ tempRepo.getMessages().flatMap(getExternalStoreMessages<UI_MESSAGE>),
579
+ );
580
+ hasSeededRepositoryRef.current = true;
581
+ }, [exportedMessageRepository, chatHelpers.messages.length]);
523
582
  return runtime;
524
583
  };
@@ -96,6 +96,22 @@ describe("useChatRuntime", () => {
96
96
  window.sessionStorage.clear();
97
97
  });
98
98
 
99
+ it("forwards a defined chat update throttle to useChat", () => {
100
+ mocks.useChat.mockReturnValue({
101
+ resumeStream: vi.fn(),
102
+ status: "ready",
103
+ });
104
+
105
+ renderHook(() => useChatRuntime({ throttle: 50 }));
106
+ renderHook(() => useChatRuntime({ throttle: undefined }));
107
+
108
+ expect(mocks.useChat).toHaveBeenNthCalledWith(
109
+ 1,
110
+ expect.objectContaining({ throttle: 50 }),
111
+ );
112
+ expect(mocks.useChat.mock.calls[1]?.[0]).not.toHaveProperty("throttle");
113
+ });
114
+
99
115
  it("waits for external history to load before resuming a stream", async () => {
100
116
  mocks.state.isLoadingHistory = true;
101
117
  const resumeStream = vi.fn().mockResolvedValue(undefined);
@@ -32,6 +32,7 @@ import { useResourceCleanup } from "./useResourceCleanup";
32
32
  export type ChatThreadOptions<UI_MESSAGE extends UIMessage = UIMessage> =
33
33
  ChatInit<UI_MESSAGE> &
34
34
  ExternalStoreSharedOptions & {
35
+ throttle?: number | undefined;
35
36
  adapters?: AISDKRuntimeAdapter["adapters"] | undefined;
36
37
  toCreateMessage?: CustomToCreateMessageFunction;
37
38
  onResume?: AISDKRuntimeAdapter["onResume"];
@@ -44,6 +45,8 @@ export type ChatThreadOptions<UI_MESSAGE extends UIMessage = UIMessage> =
44
45
  */
45
46
  onResumeError?: ((error: unknown) => void) | undefined;
46
47
  joinStrategy?: AISDKRuntimeAdapter["joinStrategy"];
48
+ messageRepository?: AISDKRuntimeAdapter<UI_MESSAGE>["messageRepository"];
49
+ unstable_onBranchChange?: AISDKRuntimeAdapter["unstable_onBranchChange"];
47
50
  };
48
51
 
49
52
  export type ChatThreadEnvironment<UI_MESSAGE extends UIMessage = UIMessage> = {
@@ -122,6 +125,7 @@ export const splitChatThreadOptions = <UI_MESSAGE extends UIMessage>(
122
125
  const {
123
126
  adapters,
124
127
  transport,
128
+ throttle,
125
129
  toCreateMessage,
126
130
  isDisabled: _isDisabled,
127
131
  isSendDisabled: _isSendDisabled,
@@ -131,6 +135,8 @@ export const splitChatThreadOptions = <UI_MESSAGE extends UIMessage>(
131
135
  onResumeToolCall,
132
136
  onResumeError,
133
137
  joinStrategy,
138
+ messageRepository,
139
+ unstable_onBranchChange,
134
140
  ...chatInit
135
141
  } = options ?? {};
136
142
  // peel guard: any shared key left in `chatInit` collapses this to `never`
@@ -141,11 +147,14 @@ export const splitChatThreadOptions = <UI_MESSAGE extends UIMessage>(
141
147
  return {
142
148
  adapters,
143
149
  transport,
150
+ throttle,
144
151
  toCreateMessage,
145
152
  onResume,
146
153
  onResumeToolCall,
147
154
  onResumeError,
148
155
  joinStrategy,
156
+ messageRepository,
157
+ unstable_onBranchChange,
149
158
  chatInit,
150
159
  };
151
160
  };
@@ -157,11 +166,14 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
157
166
  const {
158
167
  adapters,
159
168
  transport: transportOptions,
169
+ throttle,
160
170
  toCreateMessage,
161
171
  onResume,
162
172
  onResumeToolCall,
163
173
  onResumeError,
164
174
  joinStrategy,
175
+ messageRepository,
176
+ unstable_onBranchChange,
165
177
  chatInit: chatOptions,
166
178
  } = splitChatThreadOptions(options);
167
179
 
@@ -181,6 +193,7 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
181
193
  ...chatOptions,
182
194
  id,
183
195
  transport,
196
+ ...(throttle !== undefined && { throttle }),
184
197
  ...(externalChat !== undefined && { chat: externalChat }),
185
198
  });
186
199
 
@@ -195,6 +208,8 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
195
208
  ...(onResume && { onResume }),
196
209
  ...(onResumeToolCall && { onResumeToolCall }),
197
210
  ...(joinStrategy && { joinStrategy }),
211
+ ...(messageRepository && { messageRepository }),
212
+ ...(unstable_onBranchChange && { unstable_onBranchChange }),
198
213
  });
199
214
 
200
215
  if (sourceTransport instanceof AssistantChatTransport) {
@@ -392,6 +392,7 @@ describe("useExternalHistory persistence", () => {
392
392
  options?: {
393
393
  loadMessages?: MessageFormatRepository<InnerMessage>;
394
394
  toThreadMessages?: (messages: InnerMessage[]) => ThreadMessage[];
395
+ initialIsRunning?: boolean;
395
396
  },
396
397
  ) => {
397
398
  const append = vi.fn(
@@ -426,7 +427,7 @@ describe("useExternalHistory persistence", () => {
426
427
  };
427
428
 
428
429
  let listener: (() => void) | undefined;
429
- let isRunning = false;
430
+ let isRunning = options?.initialIsRunning ?? false;
430
431
  let messages: ThreadMessage[] = [];
431
432
  const getState = vi.fn(() => ({ isRunning, messages }));
432
433
  const thread = {
@@ -497,6 +498,83 @@ describe("useExternalHistory persistence", () => {
497
498
  };
498
499
  };
499
500
 
501
+ it("persists a settled turn when the history adapter becomes active after it", async () => {
502
+ let listener: (() => void) | undefined;
503
+ let isRunning = false;
504
+ let messages: ThreadMessage[] = [];
505
+ const append = vi.fn(async () => {});
506
+ const formattedAdapter = {
507
+ load: vi.fn().mockResolvedValue({ messages: [] }),
508
+ append,
509
+ };
510
+ const historyAdapter: ThreadHistoryAdapter = {
511
+ load: vi.fn(),
512
+ append: vi.fn(),
513
+ withFormat: vi.fn().mockReturnValue(formattedAdapter),
514
+ };
515
+ let activeAdapter: ThreadHistoryAdapter | undefined;
516
+ const thread = {
517
+ subscribe: (next: () => void) => {
518
+ listener = next;
519
+ return () => {};
520
+ },
521
+ getState: () => ({ isRunning, messages }),
522
+ import: vi.fn(),
523
+ export: vi.fn(() => ({ headId: null, messages: [] })),
524
+ } as unknown as AssistantRuntime["thread"];
525
+ const persistenceRuntimeRef = {
526
+ current: { thread } as AssistantRuntime,
527
+ };
528
+ const message = createAssistantMessage(
529
+ { type: "complete", reason: "stop" },
530
+ [{ id: "inner-1", parts: ["answer"] }],
531
+ );
532
+
533
+ mocks.hasThreadListItem = true;
534
+ mocks.remoteId = "remote-thread";
535
+
536
+ const { rerender } = renderHook(() =>
537
+ useExternalHistory(
538
+ persistenceRuntimeRef,
539
+ activeAdapter,
540
+ () => [],
541
+ persistenceStorageFormat,
542
+ () => {},
543
+ ),
544
+ );
545
+
546
+ await act(async () => {
547
+ messages = [message];
548
+ isRunning = true;
549
+ listener?.();
550
+ isRunning = false;
551
+ listener?.();
552
+ });
553
+
554
+ activeAdapter = historyAdapter;
555
+ await act(async () => rerender());
556
+
557
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
558
+ expect(append).toHaveBeenCalledWith({
559
+ parentId: null,
560
+ message: { id: "inner-1", parts: ["answer"] },
561
+ });
562
+ });
563
+
564
+ it("persists a turn that is already running when the subscription starts", async () => {
565
+ const { append, step } = createPersistenceHarness(false, {
566
+ initialIsRunning: true,
567
+ });
568
+ const message = createAssistantMessage(
569
+ { type: "complete", reason: "stop" },
570
+ [{ id: "inner-1", parts: ["answer"] }],
571
+ );
572
+
573
+ await step({ isRunning: false, messages: [message] });
574
+
575
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
576
+ });
577
+
500
578
  it("retries a failed append on the next persistence pass", async () => {
501
579
  const consoleError = vi
502
580
  .spyOn(console, "error")
@@ -234,6 +234,19 @@ export const useExternalHistory = <TMessage>(
234
234
  }, 0);
235
235
  });
236
236
 
237
+ const initialThreadState = runtimeRef.current.thread.getState();
238
+ wasRunningRef.current = initialThreadState.isRunning;
239
+ if (initialThreadState.isRunning) {
240
+ if (runStartRef.current == null) {
241
+ runStartRef.current = Date.now();
242
+ stepBoundariesRef.current = [];
243
+ toolCallCountRef.current = 0;
244
+ adapter.pin?.();
245
+ }
246
+ } else if (initialThreadState.messages.length > 0) {
247
+ persistSettled(true);
248
+ }
249
+
237
250
  function persistSettled(ignoreRunning: boolean) {
238
251
  persistTimerRef.current = null;
239
252
  const latest = runtimeRef.current.thread.getState();