@assistant-ui/react-ai-sdk 1.3.23 → 1.3.26

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 (45) hide show
  1. package/README.md +14 -40
  2. package/dist/frontendTools.d.ts +2 -6
  3. package/dist/frontendTools.d.ts.map +1 -1
  4. package/dist/frontendTools.js +35 -3
  5. package/dist/frontendTools.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/modelContentEnvelope.d.ts +14 -0
  11. package/dist/modelContentEnvelope.d.ts.map +1 -0
  12. package/dist/modelContentEnvelope.js +20 -0
  13. package/dist/modelContentEnvelope.js.map +1 -0
  14. package/dist/ui/resumable.d.ts +20 -0
  15. package/dist/ui/resumable.d.ts.map +1 -0
  16. package/dist/ui/resumable.js +25 -0
  17. package/dist/ui/resumable.js.map +1 -0
  18. package/dist/ui/use-chat/AssistantChatTransport.d.ts +7 -1
  19. package/dist/ui/use-chat/AssistantChatTransport.d.ts.map +1 -1
  20. package/dist/ui/use-chat/AssistantChatTransport.js +73 -2
  21. package/dist/ui/use-chat/AssistantChatTransport.js.map +1 -1
  22. package/dist/ui/use-chat/useAISDKRuntime.d.ts +17 -2
  23. package/dist/ui/use-chat/useAISDKRuntime.d.ts.map +1 -1
  24. package/dist/ui/use-chat/useAISDKRuntime.js +12 -2
  25. package/dist/ui/use-chat/useAISDKRuntime.js.map +1 -1
  26. package/dist/ui/use-chat/useChatRuntime.d.ts +2 -0
  27. package/dist/ui/use-chat/useChatRuntime.d.ts.map +1 -1
  28. package/dist/ui/use-chat/useChatRuntime.js +31 -1
  29. package/dist/ui/use-chat/useChatRuntime.js.map +1 -1
  30. package/dist/ui/utils/convertMessage.d.ts +4 -0
  31. package/dist/ui/utils/convertMessage.d.ts.map +1 -1
  32. package/dist/ui/utils/convertMessage.js +100 -5
  33. package/dist/ui/utils/convertMessage.js.map +1 -1
  34. package/package.json +4 -4
  35. package/src/frontendTools.test.ts +128 -0
  36. package/src/frontendTools.ts +41 -6
  37. package/src/index.ts +8 -0
  38. package/src/modelContentEnvelope.ts +39 -0
  39. package/src/ui/resumable.ts +42 -0
  40. package/src/ui/use-chat/AssistantChatTransport.ts +104 -3
  41. package/src/ui/use-chat/useAISDKRuntime.test.ts +50 -0
  42. package/src/ui/use-chat/useAISDKRuntime.ts +34 -1
  43. package/src/ui/use-chat/useChatRuntime.ts +38 -0
  44. package/src/ui/utils/convertMessage.test.ts +359 -0
  45. package/src/ui/utils/convertMessage.ts +125 -16
@@ -8,9 +8,22 @@ import {
8
8
  type UIMessage,
9
9
  } from "ai";
10
10
  import { toToolsJSONSchema } from "assistant-stream";
11
+ import {
12
+ RESUMABLE_STREAM_ID_HEADER,
13
+ type AssistantChatResumableOptions,
14
+ } from "../resumable";
11
15
 
12
16
  type InitializableThreadListItem = Pick<ThreadListItemRuntime, "initialize">;
13
17
 
18
+ const FINISH_MARKER = '"type":"finish"';
19
+ const FINISH_BUFFER_LIMIT = 4096;
20
+ const FINISH_BUFFER_TAIL = 1024;
21
+
22
+ export type AssistantChatTransportInitOptions<UI_MESSAGE extends UIMessage> =
23
+ HttpChatTransportInitOptions<UI_MESSAGE> & {
24
+ resumable?: AssistantChatResumableOptions;
25
+ };
26
+
14
27
  export class AssistantChatTransport<
15
28
  UI_MESSAGE extends UIMessage,
16
29
  > extends DefaultChatTransport<UI_MESSAGE> {
@@ -18,9 +31,22 @@ export class AssistantChatTransport<
18
31
  private getThreadListItem:
19
32
  | (() => InitializableThreadListItem | undefined)
20
33
  | undefined;
21
- constructor(initOptions?: HttpChatTransportInitOptions<UI_MESSAGE>) {
34
+ private readonly resumable: AssistantChatResumableOptions | undefined;
35
+
36
+ constructor(initOptions?: AssistantChatTransportInitOptions<UI_MESSAGE>) {
37
+ const { resumable, ...rest } = initOptions ?? {};
38
+ const userFetch = rest.fetch;
39
+ const userPrepareReconnect = rest.prepareReconnectToStreamRequest;
40
+
22
41
  super({
23
- ...initOptions,
42
+ ...rest,
43
+ ...(resumable && {
44
+ fetch: wrapFetchWithResumable(resumable, userFetch),
45
+ prepareReconnectToStreamRequest: wrapPrepareReconnect(
46
+ resumable,
47
+ userPrepareReconnect,
48
+ ),
49
+ }),
24
50
  prepareSendMessagesRequest: async (options) => {
25
51
  const context = this.runtime?.thread.getModelContext();
26
52
  const threadListItem =
@@ -38,7 +64,7 @@ export class AssistantChatTransport<
38
64
  },
39
65
  };
40
66
  const preparedRequest =
41
- await initOptions?.prepareSendMessagesRequest?.(optionsEx);
67
+ await rest.prepareSendMessagesRequest?.(optionsEx);
42
68
 
43
69
  return {
44
70
  ...preparedRequest,
@@ -53,15 +79,90 @@ export class AssistantChatTransport<
53
79
  };
54
80
  },
55
81
  });
82
+
83
+ this.resumable = resumable;
56
84
  }
57
85
 
58
86
  setRuntime(runtime: AssistantRuntime) {
59
87
  this.runtime = runtime;
60
88
  }
61
89
 
90
+ getResumableAdapter(): AssistantChatResumableOptions | undefined {
91
+ return this.resumable;
92
+ }
93
+
62
94
  __internal_setGetThreadListItem(
63
95
  getter: () => InitializableThreadListItem | undefined,
64
96
  ) {
65
97
  this.getThreadListItem = getter;
66
98
  }
67
99
  }
100
+
101
+ function wrapFetchWithResumable(
102
+ resumable: AssistantChatResumableOptions,
103
+ userFetch: HttpChatTransportInitOptions<UIMessage>["fetch"],
104
+ ): NonNullable<HttpChatTransportInitOptions<UIMessage>["fetch"]> {
105
+ const baseFetch: typeof globalThis.fetch = userFetch
106
+ ? (input, init) => userFetch(input as RequestInfo | URL, init)
107
+ : globalThis.fetch.bind(globalThis);
108
+
109
+ return async (input, init) => {
110
+ const res = await baseFetch(input, init);
111
+ const id = res.headers.get(RESUMABLE_STREAM_ID_HEADER);
112
+ if (id) resumable.storage.setStreamId(id);
113
+ if (!res.body) return res;
114
+
115
+ const detectFinish = resumable.isFinishEvent ?? defaultIsFinishEvent;
116
+ // a single decoder is required so multi-byte sequences split across
117
+ // chunks buffer via stream: true rather than getting dropped.
118
+ const decoder = new TextDecoder();
119
+ let accumulator = "";
120
+ const tap = new TransformStream<Uint8Array, Uint8Array>({
121
+ transform(chunk, controller) {
122
+ controller.enqueue(chunk);
123
+ accumulator += decoder.decode(chunk, { stream: true });
124
+ if (detectFinish(chunk, accumulator)) {
125
+ resumable.storage.clear();
126
+ accumulator = "";
127
+ } else if (accumulator.length > FINISH_BUFFER_LIMIT) {
128
+ accumulator = accumulator.slice(-FINISH_BUFFER_TAIL);
129
+ }
130
+ },
131
+ });
132
+
133
+ return new Response(res.body.pipeThrough(tap), {
134
+ status: res.status,
135
+ statusText: res.statusText,
136
+ headers: res.headers,
137
+ });
138
+ };
139
+ }
140
+
141
+ function defaultIsFinishEvent(_chunk: Uint8Array, accumulator: string) {
142
+ return accumulator.includes(FINISH_MARKER);
143
+ }
144
+
145
+ function wrapPrepareReconnect(
146
+ resumable: AssistantChatResumableOptions,
147
+ userPrepareReconnect: HttpChatTransportInitOptions<UIMessage>["prepareReconnectToStreamRequest"],
148
+ ): NonNullable<
149
+ HttpChatTransportInitOptions<UIMessage>["prepareReconnectToStreamRequest"]
150
+ > {
151
+ return async (options) => {
152
+ const streamId = resumable.storage.getStreamId();
153
+ if (!streamId) {
154
+ throw new Error(
155
+ "AssistantChatTransport: no resumable stream id available; nothing to resume",
156
+ );
157
+ }
158
+ const api =
159
+ typeof resumable.resumeApi === "function"
160
+ ? resumable.resumeApi(streamId)
161
+ : resumable.resumeApi;
162
+ const userPrepared = await userPrepareReconnect?.({ ...options, api });
163
+ return {
164
+ ...userPrepared,
165
+ api: userPrepared?.api ?? api,
166
+ };
167
+ };
168
+ }
@@ -266,6 +266,56 @@ describe("useAISDKRuntime", () => {
266
266
  );
267
267
  });
268
268
 
269
+ it("forwards onResume so runtime.thread.resumeRun is delivered to the adapter", async () => {
270
+ const chat = createChatHelpers([
271
+ { id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
272
+ ]);
273
+ const onResume = vi.fn().mockResolvedValue(undefined);
274
+
275
+ const { result } = renderHook(() => useAISDKRuntime(chat, { onResume }));
276
+
277
+ await waitFor(() => {
278
+ expect(result.current.thread.getState().messages.length).toBe(1);
279
+ });
280
+
281
+ act(() => {
282
+ result.current.thread.resumeRun({
283
+ parentId: "u1",
284
+ runConfig: { custom: { turnId: "t-42" } },
285
+ });
286
+ });
287
+
288
+ await waitFor(() => {
289
+ expect(onResume).toHaveBeenCalledTimes(1);
290
+ });
291
+
292
+ expect(onResume).toHaveBeenCalledWith(
293
+ expect.objectContaining({
294
+ parentId: "u1",
295
+ sourceId: null,
296
+ runConfig: { custom: { turnId: "t-42" } },
297
+ }),
298
+ );
299
+ });
300
+
301
+ it("rejects when resumeRun is called without an onResume adapter", async () => {
302
+ const chat = createChatHelpers([
303
+ { id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
304
+ ]);
305
+
306
+ const { result } = renderHook(() => useAISDKRuntime(chat));
307
+
308
+ await waitFor(() => {
309
+ expect(result.current.thread.getState().messages.length).toBe(1);
310
+ });
311
+
312
+ await expect(
313
+ result.current.thread.resumeRun({
314
+ parentId: "u1",
315
+ }) as unknown as Promise<void>,
316
+ ).rejects.toThrow("Runtime does not support resuming runs.");
317
+ });
318
+
269
319
  it("reload slices history and regenerates with metadata", async () => {
270
320
  const chat = createChatHelpers([
271
321
  { id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
@@ -14,18 +14,22 @@ import type {
14
14
  ThreadHistoryAdapter,
15
15
  AssistantRuntime,
16
16
  ThreadMessage,
17
+ ThreadSuggestion,
17
18
  MessageFormatAdapter,
18
19
  MessageFormatItem,
19
20
  MessageFormatRepository,
20
21
  AppendMessage,
21
22
  RunConfig,
23
+ McpAppMetadata,
22
24
  } from "@assistant-ui/core";
23
25
  import { getExternalStoreMessages } from "@assistant-ui/core";
26
+ import type { ReadonlyJSONObject } from "assistant-stream/utils";
24
27
  import { sliceMessagesUntil } from "../utils/sliceMessagesUntil";
25
28
  import { toCreateMessage } from "../utils/toCreateMessage";
26
29
  import { vercelAttachmentAdapter } from "../utils/vercelAttachmentAdapter";
27
30
  import { getVercelAIMessages } from "../getVercelAIMessages";
28
31
  import { AISDKMessageConverter } from "../utils/convertMessage";
32
+ import { wrapModelContentEnvelope } from "../../modelContentEnvelope";
29
33
  import {
30
34
  type AISDKStorageFormat,
31
35
  aiSDKV6FormatAdapter,
@@ -68,6 +72,21 @@ export type AISDKRuntimeAdapter = {
68
72
  * @default true
69
73
  */
70
74
  cancelPendingToolCallsOnSend?: boolean | undefined;
75
+ /**
76
+ * Called when `runtime.thread.resumeRun(config)` is invoked.
77
+ *
78
+ * When omitted, `resumeRun` throws `"Runtime does not support resuming runs."`.
79
+ * Provide this to bridge resume invocations into a custom replay channel
80
+ * (for example, an SSE reconnect endpoint keyed by turn id).
81
+ */
82
+ onResume?: ExternalStoreAdapter["onResume"];
83
+ /**
84
+ * Follow up suggestions to surface on the thread. Use this to drive
85
+ * dynamic suggestions from application state, tool results, or backend
86
+ * responses; flows into `thread.suggestions` and is rendered by
87
+ * components that read it (such as the shadcn `ThreadFollowupSuggestions`).
88
+ */
89
+ suggestions?: readonly ThreadSuggestion[] | undefined;
71
90
  };
72
91
 
73
92
  export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
@@ -76,6 +95,8 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
76
95
  adapters,
77
96
  toCreateMessage: customToCreateMessage,
78
97
  cancelPendingToolCallsOnSend = true,
98
+ onResume,
99
+ suggestions,
79
100
  }: AISDKRuntimeAdapter = {},
80
101
  ) => {
81
102
  const contextAdapters = useRuntimeAdapters();
@@ -85,6 +106,10 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
85
106
  const toolArgsKeyOrderCacheRef = useRef<Map<string, Map<string, string[]>>>(
86
107
  new Map(),
87
108
  );
109
+ const toolLastInputCacheRef = useRef<Map<string, ReadonlyJSONObject>>(
110
+ new Map(),
111
+ );
112
+ const mcpAppMetadataCacheRef = useRef<Map<string, McpAppMetadata>>(new Map());
88
113
  const lastRunConfigRef = useRef<RunConfig | undefined>(undefined);
89
114
 
90
115
  const hasExecutingTools = Object.values(toolStatuses).some(
@@ -105,6 +130,8 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
105
130
  toolStatuses,
106
131
  messageTiming,
107
132
  toolArgsKeyOrderCache: toolArgsKeyOrderCacheRef.current,
133
+ toolLastInputCache: toolLastInputCacheRef.current,
134
+ mcpAppMetadataCache: mcpAppMetadataCacheRef.current,
108
135
  ...(chatHelpers.error && { error: chatHelpers.error.message }),
109
136
  }),
110
137
  [toolStatuses, messageTiming, chatHelpers.error],
@@ -125,10 +152,14 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
125
152
  getTools: () => runtimeRef.current.thread.getModelContext().tools,
126
153
  onResult: (command) => {
127
154
  if (command.type === "add-tool-result") {
155
+ const output =
156
+ command.modelContent !== undefined
157
+ ? wrapModelContentEnvelope(command.result, command.modelContent)
158
+ : command.result;
128
159
  chatHelpers.addToolResult({
129
160
  tool: command.toolName,
130
161
  toolCallId: command.toolCallId,
131
- output: command.result,
162
+ output,
132
163
  options: { metadata: lastRunConfigRef.current },
133
164
  });
134
165
  }
@@ -319,6 +350,8 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
319
350
  },
320
351
  onResumeToolCall: (options) =>
321
352
  toolInvocations.resume(options.toolCallId, options.payload),
353
+ ...(onResume && { onResume }),
354
+ ...(suggestions && { suggestions }),
322
355
  adapters: {
323
356
  attachments: vercelAttachmentAdapter,
324
357
  ...contextAdapters,
@@ -15,6 +15,7 @@ import {
15
15
  } from "./useAISDKRuntime";
16
16
  import type { ChatInit, ChatTransport } from "ai";
17
17
  import { AssistantChatTransport } from "./AssistantChatTransport";
18
+ import type { AssistantChatResumableOptions } from "../resumable";
18
19
  import { useEffect, useMemo, useRef } from "react";
19
20
 
20
21
  export type UseChatRuntimeOptions<UI_MESSAGE extends UIMessage = UIMessage> =
@@ -22,6 +23,8 @@ export type UseChatRuntimeOptions<UI_MESSAGE extends UIMessage = UIMessage> =
22
23
  cloud?: AssistantCloud | undefined;
23
24
  adapters?: AISDKRuntimeAdapter["adapters"] | undefined;
24
25
  toCreateMessage?: CustomToCreateMessageFunction;
26
+ onResume?: AISDKRuntimeAdapter["onResume"];
27
+ suggestions?: AISDKRuntimeAdapter["suggestions"];
25
28
  };
26
29
 
27
30
  const useDynamicChatTransport = <UI_MESSAGE extends UIMessage = UIMessage>(
@@ -50,6 +53,18 @@ const useDynamicChatTransport = <UI_MESSAGE extends UIMessage = UIMessage>(
50
53
  return dynamicTransport;
51
54
  };
52
55
 
56
+ const getResumableAdapter = <UI_MESSAGE extends UIMessage>(
57
+ transport: ChatTransport<UI_MESSAGE>,
58
+ ): AssistantChatResumableOptions | undefined => {
59
+ if (transport instanceof AssistantChatTransport) {
60
+ return transport.getResumableAdapter();
61
+ }
62
+ const candidate = (transport as { getResumableAdapter?: () => unknown })
63
+ .getResumableAdapter;
64
+ if (typeof candidate !== "function") return undefined;
65
+ return candidate.call(transport) as AssistantChatResumableOptions | undefined;
66
+ };
67
+
53
68
  const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
54
69
  options?: UseChatRuntimeOptions<UI_MESSAGE>,
55
70
  ): AssistantRuntime => {
@@ -57,6 +72,8 @@ const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
57
72
  adapters,
58
73
  transport: transportOptions,
59
74
  toCreateMessage,
75
+ onResume,
76
+ suggestions,
60
77
  ...chatOptions
61
78
  } = options ?? {};
62
79
 
@@ -80,6 +97,8 @@ const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
80
97
  const runtime = useAISDKRuntime(chat, {
81
98
  adapters,
82
99
  ...(toCreateMessage && { toCreateMessage }),
100
+ ...(onResume && { onResume }),
101
+ ...(suggestions && { suggestions }),
83
102
  });
84
103
 
85
104
  if (transport instanceof AssistantChatTransport) {
@@ -89,6 +108,25 @@ const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
89
108
  );
90
109
  }
91
110
 
111
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
112
+ const resumeFiredRef = useRef(false);
113
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
114
+ useEffect(() => {
115
+ if (resumeFiredRef.current) return;
116
+ const adapter = getResumableAdapter(transport);
117
+ if (!adapter) return;
118
+ const pending = adapter.storage.getStreamId();
119
+ if (!pending) return;
120
+ resumeFiredRef.current = true;
121
+ chat.resumeStream().catch((err: unknown) => {
122
+ console.warn(
123
+ "[assistant-ui] resumable: resume failed; clearing stored stream id",
124
+ err,
125
+ );
126
+ adapter.storage.clear();
127
+ });
128
+ }, [transport, chat]);
129
+
92
130
  return runtime;
93
131
  };
94
132