@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
@@ -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,274 @@ 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("extracts MCP app metadata from output._meta['ui/resourceUri']", () => {
600
+ const converted = AISDKMessageConverter.toThreadMessages([
601
+ {
602
+ id: "a1",
603
+ role: "assistant",
604
+ parts: [
605
+ {
606
+ type: "tool-hello_ui",
607
+ toolCallId: "tc-1",
608
+ state: "output-available",
609
+ input: {},
610
+ output: {
611
+ _meta: { "ui/resourceUri": "ui://app/hello_ui.html" },
612
+ content: [{ type: "text", text: "" }],
613
+ },
614
+ },
615
+ ],
616
+ } as any,
617
+ ]);
618
+
619
+ const call = converted[0]?.content.find(
620
+ (part): part is any => part.type === "tool-call",
621
+ );
622
+ expect(call?.mcp?.app).toEqual({
623
+ resourceUri: "ui://app/hello_ui.html",
624
+ });
625
+ });
626
+
627
+ it("memoizes MCP app metadata across conversions by resourceUri", () => {
628
+ const metadata = {
629
+ mcpAppMetadataCache: new Map(),
630
+ };
631
+
632
+ const buildMessage = (id: string) => ({
633
+ id,
634
+ role: "assistant" as const,
635
+ parts: [
636
+ {
637
+ type: "tool-search",
638
+ toolCallId: `${id}-call`,
639
+ state: "output-available",
640
+ input: { q: "hi" },
641
+ output: {},
642
+ callProviderMetadata: {
643
+ mcp: { app: { resourceUri: "ui://example/search" } },
644
+ },
645
+ } as any,
646
+ ],
647
+ });
648
+
649
+ const first = AISDKMessageConverter.toThreadMessages(
650
+ [buildMessage("a1")],
651
+ false,
652
+ metadata,
653
+ );
654
+ const second = AISDKMessageConverter.toThreadMessages(
655
+ [buildMessage("a2")],
656
+ false,
657
+ metadata,
658
+ );
659
+
660
+ const firstApp = first[0]?.content.find(
661
+ (p): p is any => p.type === "tool-call",
662
+ )?.mcp?.app;
663
+ const secondApp = second[0]?.content.find(
664
+ (p): p is any => p.type === "tool-call",
665
+ )?.mcp?.app;
666
+ expect(firstApp).toBeDefined();
667
+ expect(firstApp).toBe(secondApp);
668
+ });
310
669
  });
@@ -3,27 +3,93 @@ 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
+ const mcp =
43
+ meta && typeof meta === "object"
44
+ ? (meta as { mcp?: unknown }).mcp
45
+ : undefined;
46
+ const app =
47
+ mcp && typeof mcp === "object" ? (mcp as { app?: unknown }).app : undefined;
48
+ let a: Record<string, unknown>;
49
+ if (app && typeof app === "object") {
50
+ a = app as Record<string, unknown>;
51
+ } else {
52
+ // MCP-UI tools (e.g. xmcp) surface the UI pointer as
53
+ // result._meta["ui/resourceUri"] rather than in callProviderMetadata.
54
+ const output = (part as { output?: unknown }).output;
55
+ const outMeta =
56
+ output && typeof output === "object"
57
+ ? (output as { _meta?: unknown })._meta
58
+ : undefined;
59
+ const uiResourceUri =
60
+ outMeta && typeof outMeta === "object"
61
+ ? (outMeta as Record<string, unknown>)["ui/resourceUri"]
62
+ : undefined;
63
+ if (typeof uiResourceUri !== "string") return undefined;
64
+ a = { resourceUri: uiResourceUri };
65
+ }
66
+ if (typeof a["resourceUri"] !== "string") return undefined;
67
+ if (!isMcpAppUri(a["resourceUri"])) return undefined;
68
+ const cached = cache?.get(a["resourceUri"]);
69
+ if (cached) {
70
+ cache!.delete(a["resourceUri"]);
71
+ cache!.set(a["resourceUri"], cached);
72
+ return cached;
73
+ }
74
+ const out: { -readonly [K in keyof McpAppMetadata]: McpAppMetadata[K] } = {
75
+ resourceUri: a["resourceUri"],
76
+ };
77
+ if (typeof a["mimeType"] === "string") out.mimeType = a["mimeType"];
78
+ if (Array.isArray(a["visibility"])) {
79
+ out.visibility = a["visibility"].filter(
80
+ (v): v is "model" | "app" => v === "model" || v === "app",
81
+ );
82
+ }
83
+ if (cache) {
84
+ if (cache.size >= MCP_APP_METADATA_CACHE_MAX) {
85
+ const oldest = cache.keys().next().value;
86
+ if (oldest !== undefined) cache.delete(oldest);
87
+ }
88
+ cache.set(a["resourceUri"], out);
89
+ }
90
+ return out;
91
+ }
92
+
27
93
  const hasOwn = (value: object, key: string) => Object.hasOwn(value, key);
28
94
 
29
95
  const stabilizeToolArgsValue = (
@@ -147,14 +213,28 @@ function convertParts(
147
213
  const toolName = getToolName(part);
148
214
  const toolCallId = part.toolCallId;
149
215
  const argsKeyOrderCacheKey = `${message.id}:${toolCallId}`;
150
- const args: ReadonlyJSONObject =
151
- (part.input as ReadonlyJSONObject) || {};
216
+
217
+ const rawInput = part.input as ReadonlyJSONObject | null | undefined;
218
+ let args: ReadonlyJSONObject;
219
+ if (
220
+ rawInput != null &&
221
+ typeof rawInput === "object" &&
222
+ !Array.isArray(rawInput)
223
+ ) {
224
+ args = rawInput;
225
+ metadata.toolLastInputCache?.set(argsKeyOrderCacheKey, args);
226
+ } else {
227
+ args = metadata.toolLastInputCache?.get(argsKeyOrderCacheKey) ?? {};
228
+ }
152
229
 
153
230
  let result: unknown;
231
+ let modelContent: ToolCallMessagePart["modelContent"];
154
232
  let isError = false;
155
233
 
156
234
  if (part.state === "output-available") {
157
- result = part.output;
235
+ const unwrapped = unwrapModelContentEnvelope(part.output);
236
+ result = unwrapped.result;
237
+ modelContent = unwrapped.modelContent;
158
238
  } else if (part.state === "output-error") {
159
239
  isError = true;
160
240
  result = { error: part.errorText };
@@ -177,9 +257,20 @@ function convertParts(
177
257
  argsText = stripClosingDelimiters(argsText);
178
258
  } else {
179
259
  metadata.toolArgsKeyOrderCache?.delete(argsKeyOrderCacheKey);
260
+ if (
261
+ part.state === "output-available" ||
262
+ part.state === "output-error" ||
263
+ part.state === "output-denied"
264
+ ) {
265
+ metadata.toolLastInputCache?.delete(argsKeyOrderCacheKey);
266
+ }
180
267
  }
181
268
 
182
269
  const toolStatus = metadata.toolStatuses?.[toolCallId];
270
+ const mcpApp = extractMcpAppMetadata(
271
+ part,
272
+ metadata.mcpAppMetadataCache,
273
+ );
183
274
  return {
184
275
  type: "tool-call",
185
276
  toolName,
@@ -188,6 +279,8 @@ function convertParts(
188
279
  args,
189
280
  result,
190
281
  isError,
282
+ ...(modelContent !== undefined && { modelContent }),
283
+ ...(mcpApp && { mcp: { app: mcpApp } }),
191
284
  ...getToolInterrupt(part, toolStatus),
192
285
  } satisfies ToolCallMessagePart;
193
286
  }
@@ -198,7 +291,13 @@ function convertParts(
198
291
  sourceType: "url",
199
292
  id: part.sourceId,
200
293
  url: part.url,
201
- title: part.title || "",
294
+ ...(part.title != null ? { title: part.title } : undefined),
295
+ ...(part.providerMetadata != null
296
+ ? {
297
+ providerMetadata:
298
+ part.providerMetadata as SourceProviderMetadata,
299
+ }
300
+ : undefined),
202
301
  } satisfies SourceMessagePart;
203
302
  }
204
303
 
@@ -212,10 +311,20 @@ function convertParts(
212
311
  }
213
312
 
214
313
  if (part.type === "source-document") {
215
- console.warn(
216
- "Source document parts are not yet supported in conversion",
217
- );
218
- return null;
314
+ return {
315
+ type: "source",
316
+ sourceType: "document",
317
+ id: part.sourceId,
318
+ title: part.title,
319
+ mediaType: part.mediaType,
320
+ ...(part.filename != null ? { filename: part.filename } : undefined),
321
+ ...(part.providerMetadata != null
322
+ ? {
323
+ providerMetadata:
324
+ part.providerMetadata as SourceProviderMetadata,
325
+ }
326
+ : undefined),
327
+ } satisfies SourceMessagePart;
219
328
  }
220
329
 
221
330
  if (part.type.startsWith("data-")) {