@assistant-ui/ai-sdk 0.0.3 → 0.0.5

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 (49) hide show
  1. package/README.md +1 -1
  2. package/dist/converters/convertMessage.d.ts.map +1 -1
  3. package/dist/converters/convertMessage.js +31 -6
  4. package/dist/converters/convertMessage.js.map +1 -1
  5. package/dist/runtime/AISDKChat.js +1 -1
  6. package/dist/runtime/AISDKThreads.d.ts +10 -8
  7. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  8. package/dist/runtime/AISDKThreads.js +34 -27
  9. package/dist/runtime/AISDKThreads.js.map +1 -1
  10. package/dist/runtime/useAISDKRuntime.d.ts +2 -0
  11. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  12. package/dist/runtime/useAISDKRuntime.js +171 -68
  13. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  14. package/dist/runtime/useChatRuntime.d.ts.map +1 -1
  15. package/dist/runtime/useChatRuntime.js +3 -2
  16. package/dist/runtime/useChatRuntime.js.map +1 -1
  17. package/dist/runtime/useChatThread.d.ts +7 -0
  18. package/dist/runtime/useChatThread.d.ts.map +1 -1
  19. package/dist/runtime/useChatThread.js +2 -1
  20. package/dist/runtime/useChatThread.js.map +1 -1
  21. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  22. package/dist/runtime/useExternalHistory.js +42 -47
  23. package/dist/runtime/useExternalHistory.js.map +1 -1
  24. package/dist/runtime/useResourceCleanup.js +1 -1
  25. package/dist/tools/generativeTools.js +7 -4
  26. package/dist/tools/generativeTools.js.map +1 -1
  27. package/dist/usage.d.ts +9 -2
  28. package/dist/usage.d.ts.map +1 -1
  29. package/dist/usage.js +13 -7
  30. package/dist/usage.js.map +1 -1
  31. package/package.json +11 -11
  32. package/src/converters/convertMessage.test.ts +124 -0
  33. package/src/converters/convertMessage.ts +64 -20
  34. package/src/runtime/AISDKChat.integration.test.tsx +48 -27
  35. package/src/runtime/AISDKThreads.cloud.test.ts +11 -1
  36. package/src/runtime/AISDKThreads.test.ts +102 -0
  37. package/src/runtime/AISDKThreads.ts +16 -9
  38. package/src/runtime/__tests__/controlled-transport.ts +21 -0
  39. package/src/runtime/useAISDKRuntime.test.ts +288 -3
  40. package/src/runtime/useAISDKRuntime.ts +90 -5
  41. package/src/runtime/useChatRuntime.integration.test.tsx +183 -4
  42. package/src/runtime/useChatRuntime.ts +1 -0
  43. package/src/runtime/useChatThread.ts +11 -0
  44. package/src/runtime/useExternalHistory.test.ts +64 -0
  45. package/src/runtime/useExternalHistory.ts +43 -56
  46. package/src/tools/generativeTools.test.ts +79 -0
  47. package/src/tools/generativeTools.ts +7 -8
  48. package/src/usage.test.ts +26 -8
  49. package/src/usage.ts +12 -9
package/dist/usage.js CHANGED
@@ -1,5 +1,6 @@
1
- import { useAuiState } from "@assistant-ui/store";
2
1
  import { c } from "@assistant-ui/tap/react-shim/compiler-runtime";
2
+ import "@assistant-ui/tap/react-shim";
3
+ import { useAuiState } from "@assistant-ui/store";
3
4
  //#region src/usage.ts
4
5
  const USAGE_KEYS = [
5
6
  "inputTokens",
@@ -93,13 +94,10 @@ function getThreadMessageTokenUsage(message) {
93
94
  if (!metadata) return void 0;
94
95
  const topLevelUsage = normalizeUsage(metadata.usage);
95
96
  if (topLevelUsage) return withComputedTotal(topLevelUsage);
96
- const legacyUsage = normalizeUsage(asRecord(metadata.custom)?.usage);
97
- if (legacyUsage) return withComputedTotal(legacyUsage);
97
+ const customUsage = normalizeUsage(asRecord(metadata.custom)?.usage);
98
+ if (customUsage) return withComputedTotal(customUsage);
98
99
  return usageFromSteps(metadata.steps);
99
100
  }
100
- function getLatestThreadTokenUsage(messages) {
101
- return getThreadMessageTokenUsage(findLatestMessageWithUsage(messages));
102
- }
103
101
  function findLatestMessageWithUsage(messages) {
104
102
  if (!messages) return void 0;
105
103
  for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
@@ -107,6 +105,14 @@ function findLatestMessageWithUsage(messages) {
107
105
  if (getThreadMessageTokenUsage(message)) return message;
108
106
  }
109
107
  }
108
+ /**
109
+ * Reads token usage from the newest assistant message that reports any.
110
+ *
111
+ * A route attaches usage through the AI SDK's `messageMetadata` option. Because
112
+ * a thread message carries a fixed metadata shape, the converter moves every
113
+ * other key the route returns into `metadata.custom`, which is where this hook
114
+ * looks.
115
+ */
110
116
  function useThreadTokenUsage() {
111
117
  const $ = c(2);
112
118
  const msg = useAuiState(_temp);
@@ -122,6 +128,6 @@ function _temp(s) {
122
128
  return findLatestMessageWithUsage(s.thread.messages);
123
129
  }
124
130
  //#endregion
125
- export { getLatestThreadTokenUsage, getThreadMessageTokenUsage, useThreadTokenUsage };
131
+ export { getThreadMessageTokenUsage, useThreadTokenUsage };
126
132
 
127
133
  //# sourceMappingURL=usage.js.map
package/dist/usage.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"usage.js","names":["useAuiState","ThreadTokenUsage","totalTokens","inputTokens","outputTokens","reasoningTokens","cachedInputTokens","TokenUsageExtractableMessage","role","metadata","UsageRecord","Record","USAGE_KEYS","const","asRecord","value","Array","isArray","undefined","asPositiveTokenCount","Number","isFinite","computeTotalTokens","usage","normalizeUsage","record","result","hasFields","key","count","outputTokenDetails","inputTokenDetails","cacheReadTokens","withComputedTotal","usageFromSteps","steps","sums","present","stepsWithUsage","stepsWithComputableTotal","step","stepTotal","getThreadMessageTokenUsage","message","topLevelUsage","legacyUsage","custom","getLatestThreadTokenUsage","messages","findLatestMessageWithUsage","idx","length","useThreadTokenUsage","$","_c","msg","_temp","t0","s","thread"],"sources":["../src/usage.ts"],"sourcesContent":["/// <reference types=\"@assistant-ui/core/react\" />\nimport { useAuiState } from \"@assistant-ui/store\";\n\nexport type ThreadTokenUsage = {\n totalTokens?: number;\n inputTokens?: number;\n outputTokens?: number;\n reasoningTokens?: number;\n cachedInputTokens?: number;\n};\n\nexport interface TokenUsageExtractableMessage {\n role?: string;\n metadata?: unknown;\n}\n\ntype UsageRecord = Record<string, unknown>;\n\nconst USAGE_KEYS = [\n \"inputTokens\",\n \"outputTokens\",\n \"reasoningTokens\",\n \"cachedInputTokens\",\n \"totalTokens\",\n] as const satisfies (keyof ThreadTokenUsage)[];\n\nfunction asRecord(value: unknown): UsageRecord | undefined {\n if (!value || typeof value !== \"object\" || Array.isArray(value))\n return undefined;\n return value as UsageRecord;\n}\n\nfunction asPositiveTokenCount(value: unknown): number | undefined {\n if (typeof value !== \"number\" || !Number.isFinite(value) || value < 0) {\n return undefined;\n }\n return value;\n}\n\nfunction computeTotalTokens(usage: ThreadTokenUsage): number | undefined {\n if (usage.totalTokens !== undefined) return usage.totalTokens;\n if (usage.inputTokens !== undefined && usage.outputTokens !== undefined) {\n return usage.inputTokens + usage.outputTokens;\n }\n return undefined;\n}\n\nfunction normalizeUsage(value: unknown): ThreadTokenUsage | undefined {\n const record = asRecord(value);\n if (!record) return undefined;\n\n const result: ThreadTokenUsage = {};\n let hasFields = false;\n for (const key of USAGE_KEYS) {\n const count = asPositiveTokenCount(record[key]);\n if (count !== undefined) {\n result[key] = count;\n hasFields = true;\n }\n }\n // AI SDK v7 moved these under token detail objects; v6 kept them top-level.\n if (result.reasoningTokens === undefined) {\n const count = asPositiveTokenCount(\n asRecord(record.outputTokenDetails)?.reasoningTokens,\n );\n if (count !== undefined) {\n result.reasoningTokens = count;\n hasFields = true;\n }\n }\n if (result.cachedInputTokens === undefined) {\n const count = asPositiveTokenCount(\n asRecord(record.inputTokenDetails)?.cacheReadTokens,\n );\n if (count !== undefined) {\n result.cachedInputTokens = count;\n hasFields = true;\n }\n }\n return hasFields ? result : undefined;\n}\n\nfunction withComputedTotal(\n usage: ThreadTokenUsage,\n): ThreadTokenUsage | undefined {\n const totalTokens = computeTotalTokens(usage);\n return { ...usage, ...(totalTokens !== undefined && { totalTokens }) };\n}\n\nfunction usageFromSteps(value: unknown): ThreadTokenUsage | undefined {\n const steps = Array.isArray(value) ? value : [];\n\n const sums: Record<string, number> = {};\n const present: Record<string, boolean> = {};\n let stepsWithUsage = 0;\n let stepsWithComputableTotal = 0;\n\n for (const step of steps) {\n const usage = normalizeUsage(asRecord(step)?.usage);\n if (!usage) continue;\n stepsWithUsage++;\n\n const stepTotal = computeTotalTokens(usage);\n if (stepTotal !== undefined) {\n sums.totalTokens = (sums.totalTokens ?? 0) + stepTotal;\n stepsWithComputableTotal++;\n }\n\n for (const key of USAGE_KEYS) {\n if (key === \"totalTokens\") continue;\n if (usage[key] !== undefined) {\n sums[key] = (sums[key] ?? 0) + usage[key];\n present[key] = true;\n }\n }\n }\n\n if (stepsWithUsage === 0) return undefined;\n\n const result: ThreadTokenUsage = {};\n if (stepsWithComputableTotal === stepsWithUsage) {\n result.totalTokens = sums.totalTokens!;\n }\n for (const key of USAGE_KEYS) {\n if (key === \"totalTokens\") continue;\n if (present[key]) {\n result[key] = sums[key]!;\n }\n }\n return result;\n}\n\nexport function getThreadMessageTokenUsage(\n message: TokenUsageExtractableMessage | undefined,\n): ThreadTokenUsage | undefined {\n if (!message || message.role !== \"assistant\") return undefined;\n\n const metadata = asRecord(message.metadata);\n if (!metadata) return undefined;\n\n const topLevelUsage = normalizeUsage(metadata.usage);\n if (topLevelUsage) return withComputedTotal(topLevelUsage);\n\n const legacyUsage = normalizeUsage(asRecord(metadata.custom)?.usage);\n if (legacyUsage) return withComputedTotal(legacyUsage);\n\n return usageFromSteps(metadata.steps);\n}\n\nexport function getLatestThreadTokenUsage(\n messages: readonly TokenUsageExtractableMessage[] | undefined,\n): ThreadTokenUsage | undefined {\n return getThreadMessageTokenUsage(findLatestMessageWithUsage(messages));\n}\n\nfunction findLatestMessageWithUsage(\n messages: readonly TokenUsageExtractableMessage[] | undefined,\n): TokenUsageExtractableMessage | undefined {\n if (!messages) return undefined;\n\n for (let idx = messages.length - 1; idx >= 0; idx -= 1) {\n const message = messages[idx];\n if (getThreadMessageTokenUsage(message)) {\n return message;\n }\n }\n\n return undefined;\n}\n\nexport function useThreadTokenUsage(): ThreadTokenUsage | undefined {\n const msg = useAuiState((s) => findLatestMessageWithUsage(s.thread.messages));\n return getThreadMessageTokenUsage(msg);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"usage.js","names":["useMemo","useAuiState","ThreadTokenUsage","totalTokens","inputTokens","outputTokens","reasoningTokens","cachedInputTokens","TokenUsageExtractableMessage","role","metadata","UsageRecord","Record","USAGE_KEYS","const","asRecord","value","Array","isArray","undefined","asPositiveTokenCount","Number","isFinite","computeTotalTokens","usage","normalizeUsage","record","result","hasFields","key","count","outputTokenDetails","inputTokenDetails","cacheReadTokens","withComputedTotal","usageFromSteps","steps","sums","present","stepsWithUsage","stepsWithComputableTotal","step","stepTotal","getThreadMessageTokenUsage","message","topLevelUsage","customUsage","custom","findLatestMessageWithUsage","messages","idx","length","useThreadTokenUsage","$","_c","msg","_temp","t0","s","thread"],"sources":["../src/usage.ts"],"sourcesContent":["/// <reference types=\"@assistant-ui/core/react\" />\nimport { useMemo } from \"react\";\nimport { useAuiState } from \"@assistant-ui/store\";\n\nexport type ThreadTokenUsage = {\n totalTokens?: number;\n inputTokens?: number;\n outputTokens?: number;\n reasoningTokens?: number;\n cachedInputTokens?: number;\n};\n\nexport interface TokenUsageExtractableMessage {\n role?: string;\n metadata?: unknown;\n}\n\ntype UsageRecord = Record<string, unknown>;\n\nconst USAGE_KEYS = [\n \"inputTokens\",\n \"outputTokens\",\n \"reasoningTokens\",\n \"cachedInputTokens\",\n \"totalTokens\",\n] as const satisfies (keyof ThreadTokenUsage)[];\n\nfunction asRecord(value: unknown): UsageRecord | undefined {\n if (!value || typeof value !== \"object\" || Array.isArray(value))\n return undefined;\n return value as UsageRecord;\n}\n\nfunction asPositiveTokenCount(value: unknown): number | undefined {\n if (typeof value !== \"number\" || !Number.isFinite(value) || value < 0) {\n return undefined;\n }\n return value;\n}\n\nfunction computeTotalTokens(usage: ThreadTokenUsage): number | undefined {\n if (usage.totalTokens !== undefined) return usage.totalTokens;\n if (usage.inputTokens !== undefined && usage.outputTokens !== undefined) {\n return usage.inputTokens + usage.outputTokens;\n }\n return undefined;\n}\n\nfunction normalizeUsage(value: unknown): ThreadTokenUsage | undefined {\n const record = asRecord(value);\n if (!record) return undefined;\n\n const result: ThreadTokenUsage = {};\n let hasFields = false;\n for (const key of USAGE_KEYS) {\n const count = asPositiveTokenCount(record[key]);\n if (count !== undefined) {\n result[key] = count;\n hasFields = true;\n }\n }\n // AI SDK v7 moved these under token detail objects; v6 kept them top-level.\n if (result.reasoningTokens === undefined) {\n const count = asPositiveTokenCount(\n asRecord(record.outputTokenDetails)?.reasoningTokens,\n );\n if (count !== undefined) {\n result.reasoningTokens = count;\n hasFields = true;\n }\n }\n if (result.cachedInputTokens === undefined) {\n const count = asPositiveTokenCount(\n asRecord(record.inputTokenDetails)?.cacheReadTokens,\n );\n if (count !== undefined) {\n result.cachedInputTokens = count;\n hasFields = true;\n }\n }\n return hasFields ? result : undefined;\n}\n\nfunction withComputedTotal(\n usage: ThreadTokenUsage,\n): ThreadTokenUsage | undefined {\n const totalTokens = computeTotalTokens(usage);\n return { ...usage, ...(totalTokens !== undefined && { totalTokens }) };\n}\n\nfunction usageFromSteps(value: unknown): ThreadTokenUsage | undefined {\n const steps = Array.isArray(value) ? value : [];\n\n const sums: Record<string, number> = {};\n const present: Record<string, boolean> = {};\n let stepsWithUsage = 0;\n let stepsWithComputableTotal = 0;\n\n for (const step of steps) {\n const usage = normalizeUsage(asRecord(step)?.usage);\n if (!usage) continue;\n stepsWithUsage++;\n\n const stepTotal = computeTotalTokens(usage);\n if (stepTotal !== undefined) {\n sums.totalTokens = (sums.totalTokens ?? 0) + stepTotal;\n stepsWithComputableTotal++;\n }\n\n for (const key of USAGE_KEYS) {\n if (key === \"totalTokens\") continue;\n if (usage[key] !== undefined) {\n sums[key] = (sums[key] ?? 0) + usage[key];\n present[key] = true;\n }\n }\n }\n\n if (stepsWithUsage === 0) return undefined;\n\n const result: ThreadTokenUsage = {};\n if (stepsWithComputableTotal === stepsWithUsage) {\n result.totalTokens = sums.totalTokens!;\n }\n for (const key of USAGE_KEYS) {\n if (key === \"totalTokens\") continue;\n if (present[key]) {\n result[key] = sums[key]!;\n }\n }\n return result;\n}\n\nexport function getThreadMessageTokenUsage(\n message: TokenUsageExtractableMessage | undefined,\n): ThreadTokenUsage | undefined {\n if (!message || message.role !== \"assistant\") return undefined;\n\n const metadata = asRecord(message.metadata);\n if (!metadata) return undefined;\n\n const topLevelUsage = normalizeUsage(metadata.usage);\n if (topLevelUsage) return withComputedTotal(topLevelUsage);\n\n const customUsage = normalizeUsage(asRecord(metadata.custom)?.usage);\n if (customUsage) return withComputedTotal(customUsage);\n\n return usageFromSteps(metadata.steps);\n}\n\nfunction findLatestMessageWithUsage(\n messages: readonly TokenUsageExtractableMessage[] | undefined,\n): TokenUsageExtractableMessage | undefined {\n if (!messages) return undefined;\n\n for (let idx = messages.length - 1; idx >= 0; idx -= 1) {\n const message = messages[idx];\n if (getThreadMessageTokenUsage(message)) {\n return message;\n }\n }\n\n return undefined;\n}\n\n/**\n * Reads token usage from the newest assistant message that reports any.\n *\n * A route attaches usage through the AI SDK's `messageMetadata` option. Because\n * a thread message carries a fixed metadata shape, the converter moves every\n * other key the route returns into `metadata.custom`, which is where this hook\n * looks.\n */\nexport function useThreadTokenUsage(): ThreadTokenUsage | undefined {\n const msg = useAuiState((s) => findLatestMessageWithUsage(s.thread.messages));\n return useMemo(() => getThreadMessageTokenUsage(msg), [msg]);\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/ai-sdk",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Vercel AI SDK adapter for assistant-ui",
5
5
  "keywords": [
6
6
  "ai-sdk",
@@ -46,14 +46,14 @@
46
46
  ],
47
47
  "sideEffects": false,
48
48
  "dependencies": {
49
- "@ai-sdk/mcp": "^2.0.36",
50
- "@ai-sdk/react": "^4.0.80",
51
- "@assistant-ui/core": "^0.3.16",
52
- "@assistant-ui/store": "^0.3.11",
53
- "@assistant-ui/tap": "^0.9.15",
54
- "ai": "^7.0.77",
49
+ "@ai-sdk/mcp": "^2.0.45",
50
+ "@ai-sdk/react": "^4.0.96",
51
+ "@assistant-ui/core": "^0.3.18",
52
+ "@assistant-ui/store": "^0.3.13",
53
+ "@assistant-ui/tap": "^0.9.17",
54
+ "ai": "^7.0.93",
55
55
  "assistant-cloud": "*",
56
- "assistant-stream": "^0.3.40"
56
+ "assistant-stream": "^0.3.42"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@types/react": "*",
@@ -68,11 +68,11 @@
68
68
  }
69
69
  },
70
70
  "devDependencies": {
71
- "@assistant-ui/x-buildutils": "0.0.24",
72
- "@testing-library/react": "^16.3.2",
71
+ "@assistant-ui/x-buildutils": "0.0.26",
72
+ "@testing-library/react": "^16.3.3",
73
73
  "@types/json-schema": "^7.0.15",
74
74
  "@types/react": "^19.2.18",
75
- "@types/react-dom": "^19.2.5",
75
+ "@types/react-dom": "^19.2.7",
76
76
  "jsdom": "^30.0.1",
77
77
  "react": "^19.2.8",
78
78
  "vitest": "^4.1.11"
@@ -26,6 +26,28 @@ describe("AISDKMessageConverter", () => {
26
26
  expect(converted[1]?.metadata.isOptimistic).toBe(true);
27
27
  });
28
28
 
29
+ it("keeps metadata outside the thread shape reachable under custom", () => {
30
+ const converted = AISDKMessageConverter.toThreadMessages([
31
+ {
32
+ id: "a1",
33
+ role: "assistant",
34
+ parts: [{ type: "text", text: "yo" }],
35
+ metadata: {
36
+ usage: { inputTokens: 40, outputTokens: 2 },
37
+ modelId: "gpt-5.6-luna",
38
+ custom: { source: "route" },
39
+ },
40
+ },
41
+ ] as any);
42
+
43
+ expect(converted[0]?.metadata.custom).toEqual({
44
+ usage: { inputTokens: 40, outputTokens: 2 },
45
+ modelId: "gpt-5.6-luna",
46
+ source: "route",
47
+ });
48
+ expect(converted[0]?.metadata).not.toHaveProperty("usage");
49
+ });
50
+
29
51
  it("does not flag messages when no optimistic id is provided", () => {
30
52
  const converted = AISDKMessageConverter.toThreadMessages([
31
53
  { id: "a1", role: "assistant", parts: [{ type: "text", text: "yo" }] },
@@ -372,6 +394,108 @@ describe("AISDKMessageConverter", () => {
372
394
  });
373
395
  });
374
396
 
397
+ it("preserves producer-defined approval fields and gives prompt precedence", () => {
398
+ const descriptor = { scope: "account:deploy" };
399
+ const converted = AISDKMessageConverter.toThreadMessages([
400
+ {
401
+ id: "a1",
402
+ role: "assistant",
403
+ parts: [
404
+ {
405
+ type: "tool-deploy",
406
+ toolCallId: "tc-1",
407
+ state: "approval-responded",
408
+ input: { environment: "production" },
409
+ approval: {
410
+ id: "approval-1",
411
+ approved: true,
412
+ reason: "approved by operator",
413
+ prompt: "Deploy to production?",
414
+ descriptor,
415
+ requestReason: "Production access requires approval",
416
+ signature: "signed-approval",
417
+ futureField: "preserved",
418
+ },
419
+ },
420
+ ],
421
+ } as any,
422
+ ]);
423
+
424
+ const toolCall = converted[0]?.content.find(
425
+ (part): part is any => part.type === "tool-call",
426
+ );
427
+ expect(toolCall?.approval).toEqual({
428
+ id: "approval-1",
429
+ approved: true,
430
+ reason: "approved by operator",
431
+ prompt: "Deploy to production?",
432
+ descriptor,
433
+ requestReason: "Production access requires approval",
434
+ signature: "signed-approval",
435
+ futureField: "preserved",
436
+ });
437
+ });
438
+
439
+ it("drops fields the AI SDK cannot answer and uses requestReason as the prompt", () => {
440
+ const converted = AISDKMessageConverter.toThreadMessages([
441
+ {
442
+ id: "a1",
443
+ role: "assistant",
444
+ parts: [
445
+ {
446
+ type: "tool-deploy",
447
+ toolCallId: "tc-1",
448
+ state: "approval-requested",
449
+ input: {},
450
+ approval: {
451
+ id: "approval-1",
452
+ display: "select",
453
+ allowFreeform: true,
454
+ options: [{ id: "once", kind: "allow-once" }],
455
+ optionId: "once",
456
+ text: "an answer",
457
+ resolution: "cancelled",
458
+ requestReason: "kept",
459
+ },
460
+ },
461
+ ],
462
+ } as any,
463
+ ]);
464
+
465
+ const toolCall = converted[0]?.content.find(
466
+ (part): part is any => part.type === "tool-call",
467
+ );
468
+ expect(toolCall?.approval).toEqual({
469
+ id: "approval-1",
470
+ prompt: "kept",
471
+ resolution: "cancelled",
472
+ requestReason: "kept",
473
+ });
474
+ });
475
+
476
+ it("drops a resolution the core contract does not declare", () => {
477
+ const converted = AISDKMessageConverter.toThreadMessages([
478
+ {
479
+ id: "a1",
480
+ role: "assistant",
481
+ parts: [
482
+ {
483
+ type: "tool-deploy",
484
+ toolCallId: "tc-1",
485
+ state: "approval-requested",
486
+ input: {},
487
+ approval: { id: "approval-1", resolution: "whatever" },
488
+ },
489
+ ],
490
+ } as any,
491
+ ]);
492
+
493
+ const toolCall = converted[0]?.content.find(
494
+ (part): part is any => part.type === "tool-call",
495
+ );
496
+ expect(toolCall?.approval).toEqual({ id: "approval-1" });
497
+ });
498
+
375
499
  it("strips closing delimiters from streaming tool argsText", () => {
376
500
  const converted = AISDKMessageConverter.toThreadMessages([
377
501
  {
@@ -31,6 +31,30 @@ import {
31
31
  import { unwrapModelContentEnvelope } from "./modelContentEnvelope";
32
32
 
33
33
  type MessageMetadata = ThreadMessageLike["metadata"];
34
+
35
+ const THREAD_METADATA_KEYS = new Set([
36
+ "unstable_state",
37
+ "unstable_annotations",
38
+ "unstable_data",
39
+ "steps",
40
+ "timing",
41
+ "submittedFeedback",
42
+ "isOptimistic",
43
+ "custom",
44
+ ]);
45
+
46
+ const toThreadMetadata = (metadata: unknown): MessageMetadata => {
47
+ if (!metadata || typeof metadata !== "object") return undefined;
48
+ const result: Record<string, unknown> = {};
49
+ const extra: Record<string, unknown> = {};
50
+ for (const [key, value] of Object.entries(metadata)) {
51
+ (THREAD_METADATA_KEYS.has(key) ? result : extra)[key] = value;
52
+ }
53
+ if (Object.keys(extra).length > 0) {
54
+ result.custom = { ...extra, ...(result.custom as object | undefined) };
55
+ }
56
+ return result as MessageMetadata;
57
+ };
34
58
  export type AISDKMessageConverterMetadata =
35
59
  useExternalMessageConverter.Metadata & {
36
60
  toolArgsKeyOrderCache?: Map<string, Map<string, string[]>>;
@@ -130,30 +154,50 @@ function extractMcpAppMetadata(
130
154
 
131
155
  function getToolApprovalAndInterrupt(
132
156
  part: {
133
- approval?:
134
- | {
135
- id: string;
136
- approved?: boolean;
137
- reason?: string;
138
- isAutomatic?: boolean;
139
- }
140
- | undefined;
157
+ approval?: Record<string, unknown> | undefined;
141
158
  },
142
159
  toolStatus: { type: string; payload?: unknown } | undefined,
143
160
  ): {
144
161
  approval?: NonNullable<ToolCallMessagePart["approval"]>;
145
162
  interrupt?: NonNullable<ToolCallMessagePart["interrupt"]>;
146
163
  } {
147
- if (part.approval && typeof part.approval.id === "string") {
148
- const { id, approved, reason, isAutomatic } = part.approval;
149
- return {
150
- approval: {
151
- id,
152
- ...(typeof approved === "boolean" && { approved }),
153
- ...(typeof reason === "string" && { reason }),
154
- ...(isAutomatic === true && { isAutomatic: true }),
155
- },
156
- };
164
+ if (part.approval) {
165
+ // The AI SDK sends only id, approved and reason back to the server, so a
166
+ // request shape promising any other answer would render controls whose
167
+ // response cannot travel.
168
+ const {
169
+ id,
170
+ prompt,
171
+ approved,
172
+ reason,
173
+ isAutomatic,
174
+ resolution,
175
+ display,
176
+ allowFreeform,
177
+ options,
178
+ optionId,
179
+ text,
180
+ ...additionalApprovalFields
181
+ } = part.approval;
182
+ const requestReason = additionalApprovalFields.requestReason;
183
+ if (typeof id === "string")
184
+ return {
185
+ approval: {
186
+ ...additionalApprovalFields,
187
+ id,
188
+ ...(typeof prompt === "string"
189
+ ? { prompt }
190
+ : typeof requestReason === "string"
191
+ ? { prompt: requestReason }
192
+ : {}),
193
+ ...(typeof approved === "boolean" && { approved }),
194
+ ...(typeof reason === "string" && { reason }),
195
+ ...(isAutomatic === true && { isAutomatic: true }),
196
+ ...((resolution === "cancelled" || resolution === "expired") && {
197
+ resolution,
198
+ }),
199
+ } as NonNullable<ToolCallMessagePart["approval"]>,
200
+ };
157
201
  }
158
202
 
159
203
  if (toolStatus?.type === "interrupt") {
@@ -427,7 +471,7 @@ export const AISDKMessageConverter = unstable_createMessageConverter(
427
471
  status: { type: "complete" as const },
428
472
  };
429
473
  }),
430
- metadata: message.metadata as MessageMetadata,
474
+ metadata: toThreadMetadata(message.metadata),
431
475
  };
432
476
 
433
477
  case "system":
@@ -442,7 +486,7 @@ export const AISDKMessageConverter = unstable_createMessageConverter(
442
486
  createdAt,
443
487
  content,
444
488
  metadata: {
445
- ...(message.metadata as MessageMetadata),
489
+ ...toThreadMetadata(message.metadata),
446
490
  ...(timing && { timing }),
447
491
  ...(isOptimistic && { isOptimistic: true }),
448
492
  },
@@ -1,47 +1,68 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
- import { useEffect } from "react";
4
- import { render, waitFor } from "@testing-library/react";
5
- import { StrictMode } from "react";
3
+ import { StrictMode, type ReactNode } from "react";
4
+ import { act, render, waitFor } from "@testing-library/react";
6
5
  import { AuiConfig, AuiProvider, useAui } from "@assistant-ui/store";
7
- import { flushTapSync } from "@assistant-ui/tap";
6
+ import type { ChatTransport, UIMessage } from "ai";
8
7
  import { describe, expect, it } from "vitest";
9
8
  import { AISDKChat } from "./AISDKChat";
10
- import { createCancellableTransport } from "./__tests__/controlled-transport";
9
+ import {
10
+ createCancellableTransport,
11
+ createStreamHarness,
12
+ } from "./__tests__/controlled-transport";
11
13
 
12
14
  describe("AISDKChat React integration", () => {
13
- it("does not treat React provider unmount as client destruction", async () => {
14
- const { transport, getCancelCount, close } = createCancellableTransport();
15
- let started = false;
16
- let isRunning = () => false;
17
-
18
- const SendOnMount = () => {
19
- const aui = useAui();
20
- isRunning = () => aui.thread.getState().isRunning;
21
- useEffect(() => {
22
- if (started) return;
23
- started = true;
24
- flushTapSync(() => aui.composer.setText("keep streaming"));
25
- flushTapSync(() => aui.composer.send());
26
- }, [aui]);
27
- return null;
28
- };
15
+ it("aborts the in-flight transport after a real unmount", async () => {
16
+ const { transport, getCancelCount } = createCancellableTransport();
17
+ const { Probe, send, isRunning } = createStreamHarness();
29
18
 
30
19
  const view = render(
31
20
  <StrictMode>
32
21
  <AuiProvider config={AuiConfig({ threads: AISDKChat({ transport }) })}>
33
- <SendOnMount />
22
+ <Probe />
34
23
  </AuiProvider>
35
24
  </StrictMode>,
36
25
  );
37
26
 
38
- await waitFor(() => {
39
- expect(isRunning()).toBe(true);
40
- });
27
+ await act(async () => send());
28
+ await waitFor(() => expect(isRunning()).toBe(true));
29
+ // the Strict Mode double mount already ran a host cleanup by now
30
+ expect(getCancelCount()).toBe(0);
41
31
 
42
32
  view.unmount();
43
- await new Promise((resolve) => setTimeout(resolve, 0));
33
+ await waitFor(() => expect(getCancelCount()).toBe(1));
34
+ });
35
+ });
36
+
37
+ describe("AISDKChat legacy useAui host integration", () => {
38
+ const LegacyProvider = ({
39
+ transport,
40
+ children,
41
+ }: {
42
+ transport: ChatTransport<UIMessage>;
43
+ children: ReactNode;
44
+ }) => {
45
+ const aui = useAui(AuiConfig({ threads: AISDKChat({ transport }) }));
46
+ return <AuiProvider value={aui}>{children}</AuiProvider>;
47
+ };
48
+
49
+ it("aborts the in-flight transport after a real unmount", async () => {
50
+ const { transport, getCancelCount } = createCancellableTransport();
51
+ const { Probe, send, isRunning } = createStreamHarness();
52
+
53
+ const view = render(
54
+ <StrictMode>
55
+ <LegacyProvider transport={transport}>
56
+ <Probe />
57
+ </LegacyProvider>
58
+ </StrictMode>,
59
+ );
60
+
61
+ await act(async () => send());
62
+ await waitFor(() => expect(isRunning()).toBe(true));
44
63
  expect(getCancelCount()).toBe(0);
45
- close();
64
+
65
+ view.unmount();
66
+ await waitFor(() => expect(getCancelCount()).toBe(1));
46
67
  });
47
68
  });
@@ -95,7 +95,7 @@ describe("AISDKThreads cloud", () => {
95
95
  handle.destroy();
96
96
  });
97
97
 
98
- it("stops an in-flight cloud chat when switching away", async () => {
98
+ it("keeps an in-flight cloud chat running across a switch and stops it on delete", async () => {
99
99
  const chat = createCancellableTransport();
100
100
  const handle = createAssistantClient(
101
101
  AuiConfig({
@@ -126,6 +126,16 @@ describe("AISDKThreads cloud", () => {
126
126
  await vi.waitFor(() => {
127
127
  expect(handle.getClient().threads.getState().mainThreadId).toBe("t2");
128
128
  });
129
+ expect(chat.getCancelCount()).toBe(0);
130
+ await vi.waitFor(() => {
131
+ expect(
132
+ handle.getClient().threads.item({ id: "t1" }).getState().isRunning,
133
+ ).toBe(true);
134
+ });
135
+
136
+ flushTapSync(() =>
137
+ handle.getClient().threads.item({ id: "t1" }).delete(),
138
+ );
129
139
  await vi.waitFor(() => {
130
140
  expect(chat.getCancelCount()).toBe(1);
131
141
  });
@@ -546,3 +546,105 @@ describe("AISDKThreads", () => {
546
546
  }
547
547
  });
548
548
  });
549
+
550
+ describe("AISDKThreads branch isolation", () => {
551
+ const completeRound = async (
552
+ handle: ReturnType<typeof createAssistantClient>,
553
+ emit: (...chunks: never[]) => void,
554
+ close: () => void,
555
+ question: string,
556
+ answer: string,
557
+ ) => {
558
+ const aui = handle.getClient();
559
+ flushTapSync(() => aui.composer.setText(question));
560
+ flushTapSync(() => aui.composer.send());
561
+ await vi.waitFor(() => {
562
+ expect(aui.thread.getState().messages.length).toBeGreaterThan(0);
563
+ });
564
+ emit(...(textReply(answer) as never[]));
565
+ close();
566
+ await vi.waitFor(() => {
567
+ expect(handle.getClient().thread.getState().isRunning).toBe(false);
568
+ });
569
+ };
570
+
571
+ it("keeps switching between populated threads free of cross-thread branches", async () => {
572
+ const { transport, emit, close } = createControlledTransport();
573
+ const handle = createAssistantClient(
574
+ AuiConfig({ threads: AISDKThreads({ transport }) }),
575
+ );
576
+ handle.subscribe(() => {});
577
+ const aui = handle.getClient();
578
+
579
+ await completeRound(
580
+ handle,
581
+ emit as never,
582
+ close,
583
+ "thread a question",
584
+ "thread a answer",
585
+ );
586
+
587
+ flushTapSync(() => aui.threads.switchToNewThread());
588
+ await completeRound(
589
+ handle,
590
+ emit as never,
591
+ close,
592
+ "thread b question",
593
+ "thread b answer",
594
+ );
595
+
596
+ flushTapSync(() => aui.threads.switchToThread("main"));
597
+ await vi.waitFor(() => {
598
+ expect(threadText(handle as never)).toEqual([
599
+ "thread a question",
600
+ "thread a answer",
601
+ ]);
602
+ });
603
+ expect(aui.thread.message({ index: 0 }).getState().branchCount).toBe(1);
604
+ expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(1);
605
+
606
+ handle.destroy();
607
+ });
608
+
609
+ it("preserves intra-thread branches across a switch through an empty thread", async () => {
610
+ const { transport, emit, close } = createControlledTransport();
611
+ const handle = createAssistantClient(
612
+ AuiConfig({ threads: AISDKThreads({ transport }) }),
613
+ );
614
+ handle.subscribe(() => {});
615
+ const aui = handle.getClient();
616
+
617
+ await completeRound(
618
+ handle,
619
+ emit as never,
620
+ close,
621
+ "branchy question",
622
+ "first answer",
623
+ );
624
+
625
+ flushTapSync(() => aui.thread.message({ index: 1 }).reload());
626
+ await vi.waitFor(() => {
627
+ expect(handle.getClient().thread.getState().isRunning).toBe(true);
628
+ });
629
+ emit(...(textReply("second answer") as never[]));
630
+ close();
631
+ await vi.waitFor(() => {
632
+ expect(handle.getClient().thread.getState().isRunning).toBe(false);
633
+ expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(2);
634
+ });
635
+
636
+ flushTapSync(() => aui.threads.switchToNewThread());
637
+ expect(handle.getClient().thread.getState().messages).toHaveLength(0);
638
+
639
+ flushTapSync(() => handle.getClient().threads.switchToThread("main"));
640
+ await vi.waitFor(() => {
641
+ expect(threadText(handle as never)).toEqual([
642
+ "branchy question",
643
+ "second answer",
644
+ ]);
645
+ });
646
+ expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(2);
647
+
648
+ handle.destroy();
649
+ });
650
+ });