@assistant-ui/react-ai-sdk 1.3.21 → 1.3.25

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 +35 -7
  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 +87 -5
  33. package/dist/ui/utils/convertMessage.js.map +1 -1
  34. package/package.json +9 -10
  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 +132 -0
  42. package/src/ui/use-chat/useAISDKRuntime.ts +69 -12
  43. package/src/ui/use-chat/useChatRuntime.ts +38 -0
  44. package/src/ui/utils/convertMessage.test.ts +331 -0
  45. package/src/ui/utils/convertMessage.ts +107 -16
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import type { ReadonlyJSONObject } from "assistant-stream/utils";
2
3
  import { AISDKMessageConverter } from "./convertMessage";
3
4
 
4
5
  describe("AISDKMessageConverter", () => {
@@ -37,6 +38,94 @@ describe("AISDKMessageConverter", () => {
37
38
  expect(converted[0]?.attachments?.[1]?.type).toBe("file");
38
39
  });
39
40
 
41
+ it("converts source-document parts into document sources", () => {
42
+ const converted = AISDKMessageConverter.toThreadMessages([
43
+ {
44
+ id: "a1",
45
+ role: "assistant",
46
+ parts: [
47
+ {
48
+ type: "source-document",
49
+ sourceId: "doc_123",
50
+ title: "proposal.pdf",
51
+ mediaType: "application/pdf",
52
+ filename: "proposal.pdf",
53
+ providerMetadata: {
54
+ openai: {
55
+ type: "file_citation",
56
+ fileId: "file_123",
57
+ index: 0,
58
+ },
59
+ },
60
+ },
61
+ ],
62
+ } as any,
63
+ ]);
64
+
65
+ expect(converted).toHaveLength(1);
66
+ expect(converted[0]?.role).toBe("assistant");
67
+
68
+ const sourcePart = converted[0]?.content.find(
69
+ (part): part is any => part.type === "source",
70
+ );
71
+
72
+ expect(sourcePart).toMatchObject({
73
+ type: "source",
74
+ sourceType: "document",
75
+ id: "doc_123",
76
+ title: "proposal.pdf",
77
+ mediaType: "application/pdf",
78
+ filename: "proposal.pdf",
79
+ providerMetadata: {
80
+ openai: {
81
+ type: "file_citation",
82
+ fileId: "file_123",
83
+ index: 0,
84
+ },
85
+ },
86
+ });
87
+ });
88
+
89
+ it("converts source-url parts without synthesizing missing optional fields", () => {
90
+ const converted = AISDKMessageConverter.toThreadMessages([
91
+ {
92
+ id: "a1",
93
+ role: "assistant",
94
+ parts: [
95
+ {
96
+ type: "source-url",
97
+ sourceId: "url_123",
98
+ url: "https://example.com/report",
99
+ providerMetadata: {
100
+ openai: {
101
+ type: "url_citation",
102
+ index: 1,
103
+ },
104
+ },
105
+ },
106
+ ],
107
+ } as any,
108
+ ]);
109
+
110
+ const sourcePart = converted[0]?.content.find(
111
+ (part): part is any => part.type === "source",
112
+ );
113
+
114
+ expect(sourcePart).toMatchObject({
115
+ type: "source",
116
+ sourceType: "url",
117
+ id: "url_123",
118
+ url: "https://example.com/report",
119
+ providerMetadata: {
120
+ openai: {
121
+ type: "url_citation",
122
+ index: 1,
123
+ },
124
+ },
125
+ });
126
+ expect(sourcePart).not.toHaveProperty("title");
127
+ });
128
+
40
129
  it("converts assistant image file parts into file content", () => {
41
130
  const converted = AISDKMessageConverter.toThreadMessages([
42
131
  {
@@ -307,4 +396,246 @@ describe("AISDKMessageConverter", () => {
307
396
  limit: 5,
308
397
  });
309
398
  });
399
+
400
+ it("preserves last good input when AI SDK briefly emits null input", () => {
401
+ const metadata = {
402
+ toolArgsKeyOrderCache: new Map<string, Map<string, string[]>>(),
403
+ toolLastInputCache: new Map<string, ReadonlyJSONObject>(),
404
+ };
405
+
406
+ const convertWithInput = (input: unknown) =>
407
+ AISDKMessageConverter.toThreadMessages(
408
+ [
409
+ {
410
+ id: "a1",
411
+ role: "assistant",
412
+ parts: [
413
+ {
414
+ type: "tool-weather",
415
+ toolCallId: "tc-1",
416
+ state: "input-streaming",
417
+ input,
418
+ },
419
+ ],
420
+ } as any,
421
+ ],
422
+ false,
423
+ metadata,
424
+ )[0]?.content.find((part): part is any => part.type === "tool-call");
425
+
426
+ const first = convertWithInput({ city: "NYC" });
427
+ expect(first?.argsText).toBe('{"city":"NYC');
428
+ expect(first?.args).toEqual({ city: "NYC" });
429
+
430
+ const dropped = convertWithInput(null);
431
+ expect(dropped?.argsText).toBe('{"city":"NYC');
432
+ expect(dropped?.args).toEqual({ city: "NYC" });
433
+
434
+ const undef = convertWithInput(undefined);
435
+ expect(undef?.argsText).toBe('{"city":"NYC');
436
+ expect(undef?.args).toEqual({ city: "NYC" });
437
+
438
+ const grown = convertWithInput({ city: "NYC", units: "F" });
439
+ expect(grown?.argsText).toBe('{"city":"NYC","units":"F');
440
+ expect(grown?.args).toEqual({ city: "NYC", units: "F" });
441
+ });
442
+
443
+ it("preserves last good input across terminal state transitions", () => {
444
+ const metadata = {
445
+ toolArgsKeyOrderCache: new Map<string, Map<string, string[]>>(),
446
+ toolLastInputCache: new Map<string, ReadonlyJSONObject>(),
447
+ };
448
+
449
+ AISDKMessageConverter.toThreadMessages(
450
+ [
451
+ {
452
+ id: "a1",
453
+ role: "assistant",
454
+ parts: [
455
+ {
456
+ type: "tool-weather",
457
+ toolCallId: "tc-1",
458
+ state: "input-available",
459
+ input: { city: "NYC" },
460
+ },
461
+ ],
462
+ } as any,
463
+ ],
464
+ false,
465
+ metadata,
466
+ );
467
+
468
+ const terminal = AISDKMessageConverter.toThreadMessages(
469
+ [
470
+ {
471
+ id: "a1",
472
+ role: "assistant",
473
+ parts: [
474
+ {
475
+ type: "tool-weather",
476
+ toolCallId: "tc-1",
477
+ state: "output-available",
478
+ input: null,
479
+ output: { temp: 70 },
480
+ },
481
+ ],
482
+ } as any,
483
+ ],
484
+ false,
485
+ metadata,
486
+ );
487
+
488
+ const call = terminal[0]?.content.find(
489
+ (part): part is any => part.type === "tool-call",
490
+ );
491
+ expect(call?.args).toEqual({ city: "NYC" });
492
+ expect(call?.result).toEqual({ temp: 70 });
493
+ });
494
+
495
+ it("unwraps the modelContent envelope produced by frontend tool execution", () => {
496
+ const converted = AISDKMessageConverter.toThreadMessages([
497
+ {
498
+ id: "a1",
499
+ role: "assistant",
500
+ parts: [
501
+ {
502
+ type: "tool-readPdf",
503
+ toolCallId: "tc-pdf",
504
+ state: "output-available",
505
+ input: {},
506
+ output: {
507
+ __aui_modelContent: [
508
+ { type: "text", text: "PDF contents:" },
509
+ {
510
+ type: "file",
511
+ data: "JVBERi0xLjQK",
512
+ mediaType: "application/pdf",
513
+ },
514
+ ],
515
+ value: { mediaType: "application/pdf", base64: "JVBERi0xLjQK" },
516
+ },
517
+ },
518
+ ],
519
+ } as any,
520
+ ]);
521
+
522
+ const call = converted[0]?.content.find(
523
+ (part): part is any => part.type === "tool-call",
524
+ );
525
+ expect(call?.result).toEqual({
526
+ mediaType: "application/pdf",
527
+ base64: "JVBERi0xLjQK",
528
+ });
529
+ expect(call?.modelContent).toEqual([
530
+ { type: "text", text: "PDF contents:" },
531
+ {
532
+ type: "file",
533
+ data: "JVBERi0xLjQK",
534
+ mediaType: "application/pdf",
535
+ },
536
+ ]);
537
+ });
538
+
539
+ it("leaves a plain output untouched when no envelope is present", () => {
540
+ const converted = AISDKMessageConverter.toThreadMessages([
541
+ {
542
+ id: "a1",
543
+ role: "assistant",
544
+ parts: [
545
+ {
546
+ type: "tool-weather",
547
+ toolCallId: "tc-1",
548
+ state: "output-available",
549
+ input: { city: "NYC" },
550
+ output: { temp: 72 },
551
+ },
552
+ ],
553
+ } as any,
554
+ ]);
555
+
556
+ const call = converted[0]?.content.find(
557
+ (part): part is any => part.type === "tool-call",
558
+ );
559
+ expect(call?.result).toEqual({ temp: 72 });
560
+ expect(call?.modelContent).toBeUndefined();
561
+ });
562
+
563
+ it("forwards callProviderMetadata.mcp.app onto ToolCallMessagePart.mcp.app", () => {
564
+ const converted = AISDKMessageConverter.toThreadMessages([
565
+ {
566
+ id: "a1",
567
+ role: "assistant",
568
+ parts: [
569
+ {
570
+ type: "tool-search",
571
+ toolCallId: "tc-1",
572
+ state: "output-available",
573
+ input: { query: "hi" },
574
+ output: { results: [] },
575
+ callProviderMetadata: {
576
+ mcp: {
577
+ app: {
578
+ resourceUri: "ui://example/search",
579
+ mimeType: "text/html;profile=mcp-app",
580
+ visibility: ["app", "model", "bogus"],
581
+ },
582
+ },
583
+ },
584
+ },
585
+ ],
586
+ } as any,
587
+ ]);
588
+
589
+ const call = converted[0]?.content.find(
590
+ (part): part is any => part.type === "tool-call",
591
+ );
592
+ expect(call?.mcp?.app).toEqual({
593
+ resourceUri: "ui://example/search",
594
+ mimeType: "text/html;profile=mcp-app",
595
+ visibility: ["app", "model"],
596
+ });
597
+ });
598
+
599
+ it("memoizes MCP app metadata across conversions by resourceUri", () => {
600
+ const metadata = {
601
+ mcpAppMetadataCache: new Map(),
602
+ };
603
+
604
+ const buildMessage = (id: string) => ({
605
+ id,
606
+ role: "assistant" as const,
607
+ parts: [
608
+ {
609
+ type: "tool-search",
610
+ toolCallId: `${id}-call`,
611
+ state: "output-available",
612
+ input: { q: "hi" },
613
+ output: {},
614
+ callProviderMetadata: {
615
+ mcp: { app: { resourceUri: "ui://example/search" } },
616
+ },
617
+ } as any,
618
+ ],
619
+ });
620
+
621
+ const first = AISDKMessageConverter.toThreadMessages(
622
+ [buildMessage("a1")],
623
+ false,
624
+ metadata,
625
+ );
626
+ const second = AISDKMessageConverter.toThreadMessages(
627
+ [buildMessage("a2")],
628
+ false,
629
+ metadata,
630
+ );
631
+
632
+ const firstApp = first[0]?.content.find(
633
+ (p): p is any => p.type === "tool-call",
634
+ )?.mcp?.app;
635
+ const secondApp = second[0]?.content.find(
636
+ (p): p is any => p.type === "tool-call",
637
+ )?.mcp?.app;
638
+ expect(firstApp).toBeDefined();
639
+ expect(firstApp).toBe(secondApp);
640
+ });
310
641
  });
@@ -3,27 +3,75 @@ import {
3
3
  createMessageConverter as unstable_createMessageConverter,
4
4
  type useExternalMessageConverter,
5
5
  } from "@assistant-ui/core/react";
6
- import type {
7
- ReasoningMessagePart,
8
- ToolCallMessagePart,
9
- TextMessagePart,
10
- DataMessagePart,
11
- SourceMessagePart,
12
- FileMessagePart,
13
- ThreadMessageLike,
6
+ import {
7
+ isMcpAppUri,
8
+ type ReasoningMessagePart,
9
+ type ToolCallMessagePart,
10
+ type TextMessagePart,
11
+ type DataMessagePart,
12
+ type SourceMessagePart,
13
+ type SourceProviderMetadata,
14
+ type FileMessagePart,
15
+ type ThreadMessageLike,
16
+ type McpAppMetadata,
14
17
  } from "@assistant-ui/core";
15
18
  import type { ReadonlyJSONObject } from "assistant-stream/utils";
19
+ import { unwrapModelContentEnvelope } from "../../modelContentEnvelope";
16
20
 
17
21
  type MessageMetadata = ThreadMessageLike["metadata"];
18
22
  export type AISDKMessageConverterMetadata =
19
23
  useExternalMessageConverter.Metadata & {
20
24
  toolArgsKeyOrderCache?: Map<string, Map<string, string[]>>;
25
+ toolLastInputCache?: Map<string, ReadonlyJSONObject>;
26
+ mcpAppMetadataCache?: Map<string, McpAppMetadata>;
21
27
  };
22
28
 
23
29
  function stripClosingDelimiters(json: string): string {
24
30
  return json.replace(/[}\]"]+$/, "");
25
31
  }
26
32
 
33
+ const MCP_APP_METADATA_CACHE_MAX = 100;
34
+
35
+ function extractMcpAppMetadata(
36
+ part: unknown,
37
+ cache: Map<string, McpAppMetadata> | undefined,
38
+ ): McpAppMetadata | undefined {
39
+ if (!part || typeof part !== "object") return undefined;
40
+ const meta = (part as { callProviderMetadata?: unknown })
41
+ .callProviderMetadata;
42
+ if (!meta || typeof meta !== "object") return undefined;
43
+ const mcp = (meta as { mcp?: unknown }).mcp;
44
+ if (!mcp || typeof mcp !== "object") return undefined;
45
+ const app = (mcp as { app?: unknown }).app;
46
+ if (!app || typeof app !== "object") return undefined;
47
+ const a = app as Record<string, unknown>;
48
+ if (typeof a["resourceUri"] !== "string") return undefined;
49
+ if (!isMcpAppUri(a["resourceUri"])) return undefined;
50
+ const cached = cache?.get(a["resourceUri"]);
51
+ if (cached) {
52
+ cache!.delete(a["resourceUri"]);
53
+ cache!.set(a["resourceUri"], cached);
54
+ return cached;
55
+ }
56
+ const out: { -readonly [K in keyof McpAppMetadata]: McpAppMetadata[K] } = {
57
+ resourceUri: a["resourceUri"],
58
+ };
59
+ if (typeof a["mimeType"] === "string") out.mimeType = a["mimeType"];
60
+ if (Array.isArray(a["visibility"])) {
61
+ out.visibility = a["visibility"].filter(
62
+ (v): v is "model" | "app" => v === "model" || v === "app",
63
+ );
64
+ }
65
+ if (cache) {
66
+ if (cache.size >= MCP_APP_METADATA_CACHE_MAX) {
67
+ const oldest = cache.keys().next().value;
68
+ if (oldest !== undefined) cache.delete(oldest);
69
+ }
70
+ cache.set(a["resourceUri"], out);
71
+ }
72
+ return out;
73
+ }
74
+
27
75
  const hasOwn = (value: object, key: string) => Object.hasOwn(value, key);
28
76
 
29
77
  const stabilizeToolArgsValue = (
@@ -147,14 +195,28 @@ function convertParts(
147
195
  const toolName = getToolName(part);
148
196
  const toolCallId = part.toolCallId;
149
197
  const argsKeyOrderCacheKey = `${message.id}:${toolCallId}`;
150
- const args: ReadonlyJSONObject =
151
- (part.input as ReadonlyJSONObject) || {};
198
+
199
+ const rawInput = part.input as ReadonlyJSONObject | null | undefined;
200
+ let args: ReadonlyJSONObject;
201
+ if (
202
+ rawInput != null &&
203
+ typeof rawInput === "object" &&
204
+ !Array.isArray(rawInput)
205
+ ) {
206
+ args = rawInput;
207
+ metadata.toolLastInputCache?.set(argsKeyOrderCacheKey, args);
208
+ } else {
209
+ args = metadata.toolLastInputCache?.get(argsKeyOrderCacheKey) ?? {};
210
+ }
152
211
 
153
212
  let result: unknown;
213
+ let modelContent: ToolCallMessagePart["modelContent"];
154
214
  let isError = false;
155
215
 
156
216
  if (part.state === "output-available") {
157
- result = part.output;
217
+ const unwrapped = unwrapModelContentEnvelope(part.output);
218
+ result = unwrapped.result;
219
+ modelContent = unwrapped.modelContent;
158
220
  } else if (part.state === "output-error") {
159
221
  isError = true;
160
222
  result = { error: part.errorText };
@@ -177,9 +239,20 @@ function convertParts(
177
239
  argsText = stripClosingDelimiters(argsText);
178
240
  } else {
179
241
  metadata.toolArgsKeyOrderCache?.delete(argsKeyOrderCacheKey);
242
+ if (
243
+ part.state === "output-available" ||
244
+ part.state === "output-error" ||
245
+ part.state === "output-denied"
246
+ ) {
247
+ metadata.toolLastInputCache?.delete(argsKeyOrderCacheKey);
248
+ }
180
249
  }
181
250
 
182
251
  const toolStatus = metadata.toolStatuses?.[toolCallId];
252
+ const mcpApp = extractMcpAppMetadata(
253
+ part,
254
+ metadata.mcpAppMetadataCache,
255
+ );
183
256
  return {
184
257
  type: "tool-call",
185
258
  toolName,
@@ -188,6 +261,8 @@ function convertParts(
188
261
  args,
189
262
  result,
190
263
  isError,
264
+ ...(modelContent !== undefined && { modelContent }),
265
+ ...(mcpApp && { mcp: { app: mcpApp } }),
191
266
  ...getToolInterrupt(part, toolStatus),
192
267
  } satisfies ToolCallMessagePart;
193
268
  }
@@ -198,7 +273,13 @@ function convertParts(
198
273
  sourceType: "url",
199
274
  id: part.sourceId,
200
275
  url: part.url,
201
- title: part.title || "",
276
+ ...(part.title != null ? { title: part.title } : undefined),
277
+ ...(part.providerMetadata != null
278
+ ? {
279
+ providerMetadata:
280
+ part.providerMetadata as SourceProviderMetadata,
281
+ }
282
+ : undefined),
202
283
  } satisfies SourceMessagePart;
203
284
  }
204
285
 
@@ -212,10 +293,20 @@ function convertParts(
212
293
  }
213
294
 
214
295
  if (part.type === "source-document") {
215
- console.warn(
216
- "Source document parts are not yet supported in conversion",
217
- );
218
- return null;
296
+ return {
297
+ type: "source",
298
+ sourceType: "document",
299
+ id: part.sourceId,
300
+ title: part.title,
301
+ mediaType: part.mediaType,
302
+ ...(part.filename != null ? { filename: part.filename } : undefined),
303
+ ...(part.providerMetadata != null
304
+ ? {
305
+ providerMetadata:
306
+ part.providerMetadata as SourceProviderMetadata,
307
+ }
308
+ : undefined),
309
+ } satisfies SourceMessagePart;
219
310
  }
220
311
 
221
312
  if (part.type.startsWith("data-")) {