@assistant-ui/ai-sdk 0.0.9 → 0.0.11

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 (35) hide show
  1. package/dist/converters/convertMessage.d.ts.map +1 -1
  2. package/dist/converters/convertMessage.js +59 -6
  3. package/dist/converters/convertMessage.js.map +1 -1
  4. package/dist/converters/toCreateMessage.d.ts.map +1 -1
  5. package/dist/converters/toCreateMessage.js +2 -1
  6. package/dist/converters/toCreateMessage.js.map +1 -1
  7. package/dist/converters/toolApprovalAnswers.d.ts +3 -0
  8. package/dist/converters/toolApprovalAnswers.d.ts.map +1 -0
  9. package/dist/converters/toolApprovalAnswers.js +17 -0
  10. package/dist/converters/toolApprovalAnswers.js.map +1 -0
  11. package/dist/runtime/AISDKChat.js.map +1 -1
  12. package/dist/runtime/AISDKThreads.js.map +1 -1
  13. package/dist/runtime/sdkIdentity.js +1 -1
  14. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  15. package/dist/runtime/useChatRuntime.js.map +1 -1
  16. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  17. package/dist/runtime/useExternalHistory.js +4 -0
  18. package/dist/runtime/useExternalHistory.js.map +1 -1
  19. package/dist/runtime/useResourceCleanup.js.map +1 -1
  20. package/dist/runtime/useStreamingTiming.js.map +1 -1
  21. package/dist/tools/generativeTools.d.ts.map +1 -1
  22. package/dist/tools/generativeTools.js +12 -1
  23. package/dist/tools/generativeTools.js.map +1 -1
  24. package/dist/transport/resumable.js.map +1 -1
  25. package/dist/usage.js.map +1 -1
  26. package/package.json +12 -11
  27. package/src/converters/convertMessage.test.ts +266 -0
  28. package/src/converters/convertMessage.ts +97 -4
  29. package/src/converters/toCreateMessage.test.ts +13 -0
  30. package/src/converters/toCreateMessage.ts +1 -0
  31. package/src/converters/toolApprovalAnswers.ts +27 -0
  32. package/src/runtime/useExternalHistory.test.ts +94 -0
  33. package/src/runtime/useExternalHistory.ts +4 -0
  34. package/src/tools/generativeTools.test.ts +189 -1
  35. package/src/tools/generativeTools.ts +23 -2
@@ -666,6 +666,119 @@ describe("AISDKMessageConverter", () => {
666
666
  ]);
667
667
  });
668
668
 
669
+ it("reads questions and recorded answers from the approval descriptor for a custom response channel", () => {
670
+ const questions = [
671
+ {
672
+ id: "scope",
673
+ prompt: "Which files?",
674
+ header: "Scope",
675
+ options: [
676
+ { id: "src", label: "src", description: "Sources" },
677
+ { id: 1, label: "invalid" },
678
+ ],
679
+ multiple: true,
680
+ },
681
+ { id: "note", prompt: "Anything else?", allowFreeform: "yes" },
682
+ { prompt: "No id" },
683
+ ];
684
+ const parts = [
685
+ {
686
+ type: "tool-ask",
687
+ toolCallId: "tc-1",
688
+ state: "approval-requested",
689
+ input: {},
690
+ approval: {
691
+ id: "approval-1",
692
+ descriptor: { display: "questions", questions },
693
+ },
694
+ },
695
+ {
696
+ type: "tool-ask",
697
+ toolCallId: "tc-2",
698
+ state: "approval-responded",
699
+ input: {},
700
+ approval: {
701
+ id: "approval-2",
702
+ approved: true,
703
+ descriptor: {
704
+ display: "questions",
705
+ questions: [{ id: "note", prompt: "Anything else?" }],
706
+ answers: {
707
+ note: { text: "no", optionIds: [1, "x"] },
708
+ bad: "x",
709
+ empty: { optionIds: [1] },
710
+ },
711
+ },
712
+ },
713
+ },
714
+ ];
715
+
716
+ const convert = (supportsRichToolApprovalResponses: boolean) => {
717
+ const metadata: AISDKMessageConverterMetadata = {
718
+ supportsRichToolApprovalResponses,
719
+ };
720
+ return AISDKMessageConverter.toThreadMessages(
721
+ [{ id: "a1", role: "assistant", parts } as any],
722
+ false,
723
+ metadata,
724
+ )[0]?.content.map((part) => (part as { approval?: any }).approval);
725
+ };
726
+
727
+ const [requested, responded] = convert(true)!;
728
+ expect(requested.display).toBe("questions");
729
+ expect(requested.questions).toEqual([
730
+ {
731
+ id: "scope",
732
+ prompt: "Which files?",
733
+ header: "Scope",
734
+ options: [{ id: "src", label: "src", description: "Sources" }],
735
+ multiple: true,
736
+ },
737
+ { id: "note", prompt: "Anything else?" },
738
+ ]);
739
+ expect(responded.approved).toBe(true);
740
+ expect(responded.answers).toEqual({
741
+ note: { text: "no", optionIds: ["x"] },
742
+ });
743
+ expect(
744
+ convertWith(JSON.parse('{"__proto__":{"text":"kept"}}')).answers
745
+ .__proto__,
746
+ ).toEqual({ text: "kept" });
747
+
748
+ function convertWith(answers: unknown) {
749
+ const metadata: AISDKMessageConverterMetadata = {
750
+ supportsRichToolApprovalResponses: true,
751
+ };
752
+ return AISDKMessageConverter.toThreadMessages(
753
+ [
754
+ {
755
+ id: "a2",
756
+ role: "assistant",
757
+ parts: [
758
+ {
759
+ type: "tool-ask",
760
+ toolCallId: "tc-3",
761
+ state: "approval-responded",
762
+ input: {},
763
+ approval: {
764
+ id: "approval-3",
765
+ approved: true,
766
+ descriptor: { display: "questions", answers },
767
+ },
768
+ },
769
+ ],
770
+ } as any,
771
+ ],
772
+ false,
773
+ metadata,
774
+ )[0]?.content.map((part) => (part as { approval?: any }).approval)[0];
775
+ }
776
+
777
+ const [builtIn] = convert(false)!;
778
+ expect(builtIn).not.toHaveProperty("display");
779
+ expect(builtIn).not.toHaveProperty("questions");
780
+ });
781
+
669
782
  it("ignores a non-boolean dismissible field from the approval descriptor", () => {
670
783
  const metadata: AISDKMessageConverterMetadata = {
671
784
  supportsRichToolApprovalResponses: true,
@@ -1416,6 +1529,123 @@ describe("AISDKMessageConverter", () => {
1416
1529
  });
1417
1530
  });
1418
1531
 
1532
+ it("forwards toolMetadata.app without a tool-supplied serverId", () => {
1533
+ const converted = AISDKMessageConverter.toThreadMessages([
1534
+ {
1535
+ id: "a1",
1536
+ role: "assistant",
1537
+ parts: [
1538
+ {
1539
+ type: "tool-search",
1540
+ toolCallId: "tc-1",
1541
+ state: "output-available",
1542
+ input: { query: "hi" },
1543
+ output: { results: [] },
1544
+ toolMetadata: {
1545
+ app: {
1546
+ resourceUri: "ui://example/search",
1547
+ mimeType: "text/html;profile=mcp-app",
1548
+ visibility: ["app", "model", "bogus"],
1549
+ serverId: "search-server",
1550
+ },
1551
+ },
1552
+ },
1553
+ ],
1554
+ } as any,
1555
+ ]);
1556
+
1557
+ const call = converted[0]?.content.find(
1558
+ (part): part is any => part.type === "tool-call",
1559
+ );
1560
+ expect(call?.mcp?.app).toEqual({
1561
+ resourceUri: "ui://example/search",
1562
+ mimeType: "text/html;profile=mcp-app",
1563
+ visibility: ["app", "model"],
1564
+ });
1565
+ });
1566
+
1567
+ it.each([undefined, "", "other-server"])(
1568
+ "keeps provider serverId when the tool supplies %s",
1569
+ (serverId) => {
1570
+ const converted = AISDKMessageConverter.toThreadMessages([
1571
+ {
1572
+ id: "a1",
1573
+ role: "assistant",
1574
+ parts: [
1575
+ {
1576
+ type: "tool-search",
1577
+ toolCallId: "tc-1",
1578
+ state: "output-available",
1579
+ input: { query: "hi" },
1580
+ output: { results: [] },
1581
+ toolMetadata: {
1582
+ app: {
1583
+ resourceUri: "ui://example/search",
1584
+ mimeType: "text/html;profile=mcp-app",
1585
+ serverId,
1586
+ },
1587
+ },
1588
+ callProviderMetadata: {
1589
+ mcp: {
1590
+ app: {
1591
+ resourceUri: "ui://example/search",
1592
+ serverId: "search-server",
1593
+ },
1594
+ },
1595
+ },
1596
+ },
1597
+ ],
1598
+ } as any,
1599
+ ]);
1600
+
1601
+ const call = converted[0]?.content.find(
1602
+ (part): part is any => part.type === "tool-call",
1603
+ );
1604
+ expect(call?.mcp?.app).toEqual({
1605
+ resourceUri: "ui://example/search",
1606
+ mimeType: "text/html;profile=mcp-app",
1607
+ serverId: "search-server",
1608
+ });
1609
+ },
1610
+ );
1611
+
1612
+ it("falls back to provider app metadata when toolMetadata.app has an invalid URI", () => {
1613
+ const converted = AISDKMessageConverter.toThreadMessages([
1614
+ {
1615
+ id: "a1",
1616
+ role: "assistant",
1617
+ parts: [
1618
+ {
1619
+ type: "tool-search",
1620
+ toolCallId: "tc-1",
1621
+ state: "output-available",
1622
+ input: { query: "hi" },
1623
+ output: { results: [] },
1624
+ toolMetadata: {
1625
+ app: { resourceUri: "https://example.com/search" },
1626
+ },
1627
+ callProviderMetadata: {
1628
+ mcp: {
1629
+ app: {
1630
+ resourceUri: "ui://example/search",
1631
+ mimeType: "text/html;profile=mcp-app",
1632
+ },
1633
+ },
1634
+ },
1635
+ },
1636
+ ],
1637
+ } as any,
1638
+ ]);
1639
+
1640
+ const call = converted[0]?.content.find(
1641
+ (part): part is any => part.type === "tool-call",
1642
+ );
1643
+ expect(call?.mcp?.app).toEqual({
1644
+ resourceUri: "ui://example/search",
1645
+ mimeType: "text/html;profile=mcp-app",
1646
+ });
1647
+ });
1648
+
1419
1649
  it("omits an empty callProviderMetadata.mcp.app.serverId", () => {
1420
1650
  const converted = AISDKMessageConverter.toThreadMessages([
1421
1651
  {
@@ -1483,6 +1713,42 @@ describe("AISDKMessageConverter", () => {
1483
1713
  expect(converted[0]?.content[2]).not.toHaveProperty("providerMetadata");
1484
1714
  });
1485
1715
 
1716
+ it("keeps a step scoped reasoning block id off the part", () => {
1717
+ const converted = AISDKMessageConverter.toThreadMessages([
1718
+ {
1719
+ id: "a1",
1720
+ role: "assistant",
1721
+ parts: [
1722
+ { type: "reasoning", id: "reasoning-0", text: "first step" },
1723
+ { type: "text", text: "answer" },
1724
+ { type: "reasoning", id: "reasoning-0", text: "second step" },
1725
+ ],
1726
+ } as any,
1727
+ ]);
1728
+
1729
+ expect(converted[0]?.content[0]).toMatchObject({ type: "reasoning" });
1730
+ expect(converted[0]?.content[0]).not.toHaveProperty("id");
1731
+ expect(converted[0]?.content[1]).not.toHaveProperty("id");
1732
+ expect(converted[0]?.content[2]).not.toHaveProperty("id");
1733
+ });
1734
+
1735
+ it("forwards data part ids", () => {
1736
+ const converted = AISDKMessageConverter.toThreadMessages([
1737
+ {
1738
+ id: "a1",
1739
+ role: "assistant",
1740
+ parts: [{ type: "data-chart", id: "data-1", data: { x: 1 } }],
1741
+ } as any,
1742
+ ]);
1743
+
1744
+ expect(converted[0]?.content[0]).toMatchObject({
1745
+ type: "data",
1746
+ id: "data-1",
1747
+ name: "chart",
1748
+ data: { x: 1 },
1749
+ });
1750
+ });
1751
+
1486
1752
  it("maps TextUIPart.state onto the per-part status", () => {
1487
1753
  const converted = AISDKMessageConverter.toThreadMessages([
1488
1754
  {
@@ -13,6 +13,8 @@ import {
13
13
  isMcpAppUri,
14
14
  type ReasoningMessagePart,
15
15
  type ToolApprovalOption,
16
+ type ToolApprovalQuestion,
17
+ type ToolApprovalQuestionOption,
16
18
  type ToolCallMessagePart,
17
19
  type TextMessagePart,
18
20
  type DataMessagePart,
@@ -26,6 +28,7 @@ import {
26
28
  type RespondToToolApprovalOptions,
27
29
  type Unstable_ToolInteractionLog,
28
30
  } from "@assistant-ui/core";
31
+ import { normalizeToolApprovalAnswers } from "./toolApprovalAnswers";
29
32
  import { stableStringifyToolArgs } from "@assistant-ui/core/internal";
30
33
  import {
31
34
  parsePartialJsonObject,
@@ -95,17 +98,45 @@ function extractMcpAppMetadata(
95
98
  cache: Map<string, McpAppMetadata> | undefined,
96
99
  ): McpAppMetadata | undefined {
97
100
  if (!part || typeof part !== "object") return undefined;
101
+ const toolMetadata = (part as { toolMetadata?: unknown }).toolMetadata;
102
+ const toolApp =
103
+ toolMetadata && typeof toolMetadata === "object"
104
+ ? (toolMetadata as { app?: unknown }).app
105
+ : undefined;
98
106
  const meta = (part as { callProviderMetadata?: unknown })
99
107
  .callProviderMetadata;
100
108
  const mcp =
101
109
  meta && typeof meta === "object"
102
110
  ? (meta as { mcp?: unknown }).mcp
103
111
  : undefined;
104
- const app =
112
+ const providerApp =
105
113
  mcp && typeof mcp === "object" ? (mcp as { app?: unknown }).app : undefined;
114
+
115
+ const apps: Record<string, unknown>[] = [];
116
+ for (const candidate of [toolApp, providerApp]) {
117
+ if (!candidate || typeof candidate !== "object") continue;
118
+ const candidateApp = candidate as Record<string, unknown>;
119
+ const resourceUri = candidateApp["resourceUri"];
120
+ if (typeof resourceUri !== "string" || !isMcpAppUri(resourceUri)) continue;
121
+ apps.push({
122
+ resourceUri,
123
+ ...(typeof candidateApp["mimeType"] === "string" && {
124
+ mimeType: candidateApp["mimeType"],
125
+ }),
126
+ ...(Array.isArray(candidateApp["visibility"]) && {
127
+ visibility: candidateApp["visibility"],
128
+ }),
129
+ ...(candidate === providerApp &&
130
+ typeof candidateApp["serverId"] === "string" &&
131
+ candidateApp["serverId"].length > 0 && {
132
+ serverId: candidateApp["serverId"],
133
+ }),
134
+ });
135
+ }
136
+
106
137
  let a: Record<string, unknown>;
107
- if (app && typeof app === "object") {
108
- a = app as Record<string, unknown>;
138
+ if (apps.length > 0) {
139
+ a = { ...(apps[1] ?? {}), ...apps[0]! };
109
140
  } else {
110
141
  // MCP-UI tools surface the pointer on result._meta: canonical nested
111
142
  // `ui.resourceUri`, or the deprecated flat `"ui/resourceUri"` key.
@@ -221,6 +252,51 @@ const normalizeToolApprovalOptions = (
221
252
  });
222
253
  };
223
254
 
255
+ const normalizeToolApprovalQuestions = (
256
+ questions: unknown,
257
+ ): readonly ToolApprovalQuestion[] | undefined => {
258
+ if (!Array.isArray(questions)) return undefined;
259
+
260
+ return questions.flatMap<ToolApprovalQuestion>((value) => {
261
+ if (!value || typeof value !== "object" || Array.isArray(value)) return [];
262
+ const question = value as Record<string, unknown>;
263
+ if (typeof question.id !== "string" || typeof question.prompt !== "string")
264
+ return [];
265
+ const options = Array.isArray(question.options)
266
+ ? question.options.flatMap<ToolApprovalQuestionOption>((option) => {
267
+ if (!option || typeof option !== "object" || Array.isArray(option))
268
+ return [];
269
+ const { id, label, description } = option as Record<string, unknown>;
270
+ if (typeof id !== "string" || typeof label !== "string") return [];
271
+ return [
272
+ {
273
+ id,
274
+ label,
275
+ ...(typeof description === "string" && { description }),
276
+ },
277
+ ];
278
+ })
279
+ : undefined;
280
+
281
+ return [
282
+ {
283
+ id: question.id,
284
+ prompt: question.prompt,
285
+ ...(typeof question.header === "string" && {
286
+ header: question.header,
287
+ }),
288
+ ...(options && { options }),
289
+ ...(typeof question.multiple === "boolean" && {
290
+ multiple: question.multiple,
291
+ }),
292
+ ...(typeof question.allowFreeform === "boolean" && {
293
+ allowFreeform: question.allowFreeform,
294
+ }),
295
+ },
296
+ ];
297
+ });
298
+ };
299
+
224
300
  const APPROVAL_DESCRIPTOR_FIELDS = [
225
301
  "prompt",
226
302
  "display",
@@ -229,6 +305,8 @@ const APPROVAL_DESCRIPTOR_FIELDS = [
229
305
  "options",
230
306
  "optionId",
231
307
  "text",
308
+ "questions",
309
+ "answers",
232
310
  "resolution",
233
311
  ] as const;
234
312
 
@@ -302,6 +380,8 @@ function getToolApprovalAndInterrupt(
302
380
  options,
303
381
  optionId,
304
382
  text,
383
+ questions,
384
+ answers,
305
385
  ...additionalApprovalFields
306
386
  } = response
307
387
  ? {
@@ -310,11 +390,18 @@ function getToolApprovalAndInterrupt(
310
390
  ...(response.reason != null && { reason: response.reason }),
311
391
  ...(response.optionId != null && { optionId: response.optionId }),
312
392
  ...(response.text != null && { text: response.text }),
393
+ ...(response.answers != null && { answers: response.answers }),
313
394
  }
314
395
  : approval;
315
396
  const normalizedOptions = supportsRichToolApprovalResponses
316
397
  ? normalizeToolApprovalOptions(options)
317
398
  : undefined;
399
+ const normalizedQuestions = supportsRichToolApprovalResponses
400
+ ? normalizeToolApprovalQuestions(questions)
401
+ : undefined;
402
+ const normalizedAnswers = supportsRichToolApprovalResponses
403
+ ? normalizeToolApprovalAnswers(answers)
404
+ : undefined;
318
405
  const requestReason = additionalApprovalFields.requestReason;
319
406
  if (typeof id === "string")
320
407
  return {
@@ -332,12 +419,15 @@ function getToolApprovalAndInterrupt(
332
419
  ...(supportsRichToolApprovalResponses && {
333
420
  ...((display === "decision" ||
334
421
  display === "select" ||
335
- display === "text") && { display }),
422
+ display === "text" ||
423
+ display === "questions") && { display }),
336
424
  ...(typeof allowFreeform === "boolean" && { allowFreeform }),
337
425
  ...(typeof dismissible === "boolean" && { dismissible }),
338
426
  ...(normalizedOptions && { options: normalizedOptions }),
339
427
  ...(typeof optionId === "string" && { optionId }),
340
428
  ...(typeof text === "string" && { text }),
429
+ ...(normalizedQuestions && { questions: normalizedQuestions }),
430
+ ...(normalizedAnswers && { answers: normalizedAnswers }),
341
431
  }),
342
432
  ...((resolution === "cancelled" || resolution === "expired") && {
343
433
  resolution,
@@ -585,6 +675,9 @@ function convertParts(
585
675
  if (part.type.startsWith("data-")) {
586
676
  return {
587
677
  type: "data",
678
+ ...("id" in part && typeof part.id === "string"
679
+ ? { id: part.id }
680
+ : undefined),
588
681
  name: part.type.substring(5),
589
682
  data: (part as any).data,
590
683
  } satisfies DataMessagePart;
@@ -185,6 +185,19 @@ describe("toCreateMessage", () => {
185
185
  ]);
186
186
  });
187
187
 
188
+ it("forwards a data part id", () => {
189
+ const message = {
190
+ ...baseMessage,
191
+ content: [
192
+ { type: "data", name: "workflow", id: "p1", data: { field: 1 } },
193
+ ],
194
+ } as unknown as AppendMessage;
195
+
196
+ expect(toCreateMessage(message).parts).toEqual([
197
+ { type: "data-workflow", id: "p1", data: { field: 1 } },
198
+ ]);
199
+ });
200
+
188
201
  it("converts a data part inside an attachment without throwing", () => {
189
202
  const message = {
190
203
  ...baseMessage,
@@ -86,6 +86,7 @@ export const toCreateMessage = <UI_MESSAGE extends UIMessage = UIMessage>(
86
86
  return {
87
87
  type: `data-${part.name}`,
88
88
  data: part.data,
89
+ ...(part.id !== undefined && { id: part.id }),
89
90
  };
90
91
  default:
91
92
  throw new Error(`Unsupported part type: ${part.type}`);
@@ -0,0 +1,27 @@
1
+ import type { ToolApprovalAnswer } from "@assistant-ui/core";
2
+
3
+ export const normalizeToolApprovalAnswers = (
4
+ answers: unknown,
5
+ ): Readonly<Record<string, ToolApprovalAnswer>> | undefined => {
6
+ if (!answers || typeof answers !== "object" || Array.isArray(answers))
7
+ return undefined;
8
+
9
+ return Object.fromEntries(
10
+ Object.entries(answers).flatMap(([id, value]) => {
11
+ if (!value || typeof value !== "object" || Array.isArray(value))
12
+ return [];
13
+ const { optionIds, text } = value as Record<string, unknown>;
14
+ const answer: ToolApprovalAnswer = {
15
+ ...(Array.isArray(optionIds) && {
16
+ optionIds: optionIds.filter(
17
+ (optionId): optionId is string => typeof optionId === "string",
18
+ ),
19
+ }),
20
+ ...(typeof text === "string" && { text }),
21
+ };
22
+ return answer.optionIds?.length || answer.text !== undefined
23
+ ? [[id, answer]]
24
+ : [];
25
+ }),
26
+ );
27
+ };
@@ -1383,6 +1383,100 @@ describe("useExternalHistory persistence", () => {
1383
1383
  );
1384
1384
  });
1385
1385
 
1386
+ it("stores and restores questionnaire answers with the approval response", async () => {
1387
+ const answers = {
1388
+ scope: { optionIds: ["src", "tests"] },
1389
+ note: { text: "keep it short" },
1390
+ };
1391
+ const saved = new Map<string, RespondToToolApprovalOptions>();
1392
+ const writer = createPersistenceHarness(false, {
1393
+ toolApprovalResponses: saved,
1394
+ });
1395
+ const innerMessage: InnerMessage = {
1396
+ id: "inner-a",
1397
+ role: "assistant",
1398
+ parts: [
1399
+ {
1400
+ type: "tool-ask",
1401
+ toolCallId: "call-1",
1402
+ state: "approval-requested",
1403
+ approval: { id: "approval-1" },
1404
+ },
1405
+ ],
1406
+ };
1407
+ const message = Object.assign(
1408
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
1409
+ innerMessage,
1410
+ ]),
1411
+ {
1412
+ content: [
1413
+ {
1414
+ type: "tool-call" as const,
1415
+ toolCallId: "call-1",
1416
+ toolName: "ask",
1417
+ args: {},
1418
+ argsText: "{}",
1419
+ result: undefined,
1420
+ isError: false,
1421
+ approval: { id: "approval-1" },
1422
+ },
1423
+ ],
1424
+ },
1425
+ );
1426
+ await waitFor(() => expect(writer.load).toHaveBeenCalledTimes(1));
1427
+ saved.set("approval-1", {
1428
+ approvalId: "approval-1",
1429
+ approved: true,
1430
+ answers,
1431
+ });
1432
+ await writer.runCycle([message]);
1433
+ await waitFor(() =>
1434
+ expect(writer.append).toHaveBeenCalledWith({
1435
+ parentId: null,
1436
+ message: {
1437
+ ...innerMessage,
1438
+ metadata: {
1439
+ __aui_toolApprovalResponses: {
1440
+ "approval-1": { approved: true, answers },
1441
+ },
1442
+ },
1443
+ },
1444
+ }),
1445
+ );
1446
+ writer.unmount();
1447
+
1448
+ const restored = new Map<string, RespondToToolApprovalOptions>();
1449
+ const reader = createPersistenceHarness(true, {
1450
+ loadMessages: {
1451
+ messages: [
1452
+ {
1453
+ parentId: null,
1454
+ message: {
1455
+ ...innerMessage,
1456
+ metadata: {
1457
+ __aui_toolApprovalResponses: {
1458
+ "approval-1": {
1459
+ approved: true,
1460
+ answers: { ...answers, junk: { optionIds: [1] } },
1461
+ },
1462
+ },
1463
+ },
1464
+ },
1465
+ },
1466
+ ],
1467
+ },
1468
+ toolApprovalResponses: restored,
1469
+ });
1470
+ await waitFor(() => expect(reader.load).toHaveBeenCalledTimes(1));
1471
+ await waitFor(() =>
1472
+ expect(restored.get("approval-1")).toEqual({
1473
+ approvalId: "approval-1",
1474
+ approved: true,
1475
+ answers,
1476
+ }),
1477
+ );
1478
+ });
1479
+
1386
1480
  it("restores stored tool artifacts without returning metadata to the chat", async () => {
1387
1481
  const toolArtifacts = new Map<string, unknown>();
1388
1482
  const onSetMessages = vi.fn();
@@ -25,6 +25,7 @@ import {
25
25
  useCallback,
26
26
  useMemo,
27
27
  } from "react";
28
+ import { normalizeToolApprovalAnswers } from "../converters/toolApprovalAnswers";
28
29
 
29
30
  export const toExportedMessageRepository = <TMessage>(
30
31
  toThreadMessages: (messages: TMessage[]) => ThreadMessage[],
@@ -131,6 +132,7 @@ const collectToolApprovalResponses = (
131
132
  approved: response.approved,
132
133
  ...(response.optionId != null && { optionId: response.optionId }),
133
134
  ...(response.text != null && { text: response.text }),
135
+ ...(response.answers != null && { answers: response.answers }),
134
136
  ...(response.reason != null && { reason: response.reason }),
135
137
  },
136
138
  ] as const,
@@ -246,6 +248,7 @@ const restoreToolData = <TMessage>(
246
248
  if (toolApprovalResponses && isRecord(approvalResponses)) {
247
249
  for (const [approvalId, value] of Object.entries(approvalResponses)) {
248
250
  if (!isRecord(value) || typeof value.approved !== "boolean") continue;
251
+ const answers = normalizeToolApprovalAnswers(value.answers);
249
252
  toolApprovalResponses.set(approvalId, {
250
253
  approvalId,
251
254
  approved: value.approved,
@@ -253,6 +256,7 @@ const restoreToolData = <TMessage>(
253
256
  optionId: value.optionId,
254
257
  }),
255
258
  ...(typeof value.text === "string" && { text: value.text }),
259
+ ...(answers && Object.keys(answers).length > 0 && { answers }),
256
260
  ...(typeof value.reason === "string" && { reason: value.reason }),
257
261
  });
258
262
  }