basalt-ui 1.11.0 → 1.13.0

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 (301) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +63 -51
  4. package/agent/rules/basalt-agent.md +14 -0
  5. package/agent/rules/basalt-content.md +16 -3
  6. package/configs/oxlint-plugin.js +7 -3
  7. package/configs/oxlint-plugin.test.ts +34 -5
  8. package/configs/oxlint.json +4 -4
  9. package/dist/agent/adapter.d.ts +150 -0
  10. package/dist/agent/adapter.d.ts.map +1 -0
  11. package/dist/agent/adapter.js +482 -0
  12. package/dist/agent/adapter.test.d.ts +2 -0
  13. package/dist/agent/adapter.test.d.ts.map +1 -0
  14. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  15. package/dist/agent/ai-sdk-transport.js +6 -2
  16. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  17. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  18. package/dist/agent/coalesce.d.ts +20 -4
  19. package/dist/agent/coalesce.d.ts.map +1 -1
  20. package/dist/agent/coalesce.js +76 -8
  21. package/dist/agent/coalesce.test.d.ts +2 -0
  22. package/dist/agent/coalesce.test.d.ts.map +1 -0
  23. package/dist/agent/foreign.test.d.ts +2 -0
  24. package/dist/agent/foreign.test.d.ts.map +1 -0
  25. package/dist/agent/id.d.ts +78 -0
  26. package/dist/agent/id.d.ts.map +1 -1
  27. package/dist/agent/id.js +30 -0
  28. package/dist/agent/id.test.d.ts +2 -0
  29. package/dist/agent/id.test.d.ts.map +1 -0
  30. package/dist/agent/index.d.ts +2 -0
  31. package/dist/agent/index.d.ts.map +1 -1
  32. package/dist/agent/index.js +3 -0
  33. package/dist/agent/merge.d.ts.map +1 -1
  34. package/dist/agent/merge.js +19 -7
  35. package/dist/agent/merge.test.d.ts +2 -0
  36. package/dist/agent/merge.test.d.ts.map +1 -0
  37. package/dist/agent/part-list.d.ts +49 -34
  38. package/dist/agent/part-list.d.ts.map +1 -1
  39. package/dist/agent/part-list.js +42 -6
  40. package/dist/agent/part-list.test.d.ts +2 -0
  41. package/dist/agent/part-list.test.d.ts.map +1 -0
  42. package/dist/agent/parts.d.ts +46 -10
  43. package/dist/agent/parts.d.ts.map +1 -1
  44. package/dist/agent/parts.js +16 -6
  45. package/dist/agent/parts.test.d.ts +2 -0
  46. package/dist/agent/parts.test.d.ts.map +1 -0
  47. package/dist/agent/thread.d.ts +18 -0
  48. package/dist/agent/thread.d.ts.map +1 -1
  49. package/dist/agent/thread.js +8 -2
  50. package/dist/agent/thread.test.d.ts +2 -0
  51. package/dist/agent/thread.test.d.ts.map +1 -0
  52. package/dist/agent/transport.test.d.ts +2 -0
  53. package/dist/agent/transport.test.d.ts.map +1 -0
  54. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  55. package/dist/agent/use-agent-stream.js +2 -2
  56. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  57. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  58. package/dist/agent/use-agent-thread-runs.d.ts +24 -3
  59. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  60. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  61. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  62. package/dist/agent/use-agent-thread-runs.js +55 -15
  63. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  64. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  65. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  66. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  67. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  68. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  69. package/dist/agent-chat/composer.d.ts +130 -8
  70. package/dist/agent-chat/composer.d.ts.map +1 -1
  71. package/dist/agent-chat/composer.js +189 -23
  72. package/dist/agent-chat/composer.test.d.ts +2 -0
  73. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  74. package/dist/agent-chat/index.d.ts +5 -1
  75. package/dist/agent-chat/index.d.ts.map +1 -1
  76. package/dist/agent-chat/index.js +2 -0
  77. package/dist/agent-chat/message-affordances.d.ts +59 -0
  78. package/dist/agent-chat/message-affordances.d.ts.map +1 -0
  79. package/dist/agent-chat/message-affordances.js +7 -0
  80. package/dist/agent-chat/relative-time.d.ts +27 -0
  81. package/dist/agent-chat/relative-time.d.ts.map +1 -0
  82. package/dist/agent-chat/relative-time.js +27 -0
  83. package/dist/agent-chat/relative-time.test.d.ts +2 -0
  84. package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
  85. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  86. package/dist/agent-chat/thread-detail-panel.js +1 -1
  87. package/dist/agent-chat/thread-feed-row.d.ts +65 -0
  88. package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
  89. package/dist/agent-chat/thread-feed-row.js +156 -0
  90. package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
  91. package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
  92. package/dist/agent-chat/thread-feed.d.ts +34 -5
  93. package/dist/agent-chat/thread-feed.d.ts.map +1 -1
  94. package/dist/agent-chat/thread-feed.js +52 -19
  95. package/dist/agent-chat/thread-feed.test.d.ts +2 -0
  96. package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
  97. package/dist/agent-chat/thread-message.d.ts +183 -2
  98. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  99. package/dist/agent-chat/thread-message.js +329 -37
  100. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  101. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  102. package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
  103. package/dist/agent-chat/thread-outcome-card.js +1 -16
  104. package/dist/agent-chat/thread-workspace.d.ts +14 -2
  105. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  106. package/dist/agent-chat/thread-workspace.js +30 -4
  107. package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
  108. package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
  109. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  110. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  111. package/dist/agent-chat/virtualize.d.ts +123 -0
  112. package/dist/agent-chat/virtualize.d.ts.map +1 -0
  113. package/dist/agent-chat/virtualize.js +9 -0
  114. package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
  115. package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
  116. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  117. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  118. package/dist/charts/utils/domain.test.d.ts +2 -0
  119. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  120. package/dist/cli/check-theme.test.d.ts +2 -0
  121. package/dist/cli/check-theme.test.d.ts.map +1 -0
  122. package/dist/cli/help.test.d.ts +2 -0
  123. package/dist/cli/help.test.d.ts.map +1 -0
  124. package/dist/cli/index.d.ts.map +1 -1
  125. package/dist/cli/init-sync.test.d.ts +2 -0
  126. package/dist/cli/init-sync.test.d.ts.map +1 -0
  127. package/dist/cli/placement-engine.test.d.ts +2 -0
  128. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  129. package/dist/cli/tarball-parity.test.d.ts +2 -0
  130. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  131. package/dist/cli/template-format.test.d.ts +2 -0
  132. package/dist/cli/template-format.test.d.ts.map +1 -0
  133. package/dist/cli/template-ledger.test.d.ts +2 -0
  134. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  135. package/dist/cli/tokens-css.test.d.ts +2 -0
  136. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  137. package/dist/content/article-actions.test.d.ts +2 -0
  138. package/dist/content/article-actions.test.d.ts.map +1 -0
  139. package/dist/content/article-model.test.d.ts +2 -0
  140. package/dist/content/article-model.test.d.ts.map +1 -0
  141. package/dist/content/block-split.test.d.ts +2 -0
  142. package/dist/content/block-split.test.d.ts.map +1 -0
  143. package/dist/content/fence-block.d.ts +37 -10
  144. package/dist/content/fence-block.d.ts.map +1 -1
  145. package/dist/content/fence-block.js +80 -14
  146. package/dist/content/heading-components.d.ts.map +1 -1
  147. package/dist/content/heading-components.js +7 -4
  148. package/dist/content/heading-components.test.d.ts +2 -0
  149. package/dist/content/heading-components.test.d.ts.map +1 -0
  150. package/dist/content/index.d.ts +6 -3
  151. package/dist/content/index.d.ts.map +1 -1
  152. package/dist/content/index.js +5 -1
  153. package/dist/content/markdown.d.ts +101 -14
  154. package/dist/content/markdown.d.ts.map +1 -1
  155. package/dist/content/markdown.js +141 -28
  156. package/dist/content/markdown.test.d.ts +2 -0
  157. package/dist/content/markdown.test.d.ts.map +1 -0
  158. package/dist/content/prose.module.css.test.d.ts +2 -0
  159. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  160. package/dist/content/sanitize.d.ts +170 -0
  161. package/dist/content/sanitize.d.ts.map +1 -0
  162. package/dist/content/sanitize.js +178 -0
  163. package/dist/content/sanitize.test.d.ts +2 -0
  164. package/dist/content/sanitize.test.d.ts.map +1 -0
  165. package/dist/content/slug.test.d.ts +2 -0
  166. package/dist/content/slug.test.d.ts.map +1 -0
  167. package/dist/dashboard/stat-card.test.d.ts +2 -0
  168. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  169. package/dist/data/index.d.ts +5 -1
  170. package/dist/data/index.d.ts.map +1 -1
  171. package/dist/data/virtual-list.d.ts.map +1 -1
  172. package/dist/data/virtual.d.ts +2 -1
  173. package/dist/data/virtual.d.ts.map +1 -1
  174. package/dist/guard/check-source.test.d.ts +2 -0
  175. package/dist/guard/check-source.test.d.ts.map +1 -0
  176. package/dist/guard/guard-hook.test.d.ts +2 -0
  177. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  178. package/dist/index.d.ts +4 -4
  179. package/dist/index.d.ts.map +1 -1
  180. package/dist/index.js +11 -1
  181. package/dist/provider/index.test.d.ts +2 -0
  182. package/dist/provider/index.test.d.ts.map +1 -0
  183. package/dist/provider/lab-theme.test.d.ts +2 -0
  184. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  186. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  187. package/dist/styles.floor.test.d.ts +2 -0
  188. package/dist/styles.floor.test.d.ts.map +1 -0
  189. package/dist/surfaces.d.ts +5 -5
  190. package/dist/surfaces.d.ts.map +1 -1
  191. package/dist/surfaces.js +14 -4
  192. package/dist/theme/border-coverage.test.d.ts +18 -0
  193. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  194. package/dist/theme/contrast.test.d.ts +2 -0
  195. package/dist/theme/contrast.test.d.ts.map +1 -0
  196. package/dist/theme/density-option.test.d.ts +2 -0
  197. package/dist/theme/density-option.test.d.ts.map +1 -0
  198. package/dist/theme/density-relations.test.d.ts +2 -0
  199. package/dist/theme/density-relations.test.d.ts.map +1 -0
  200. package/dist/theme/derive-theme.test.d.ts +2 -0
  201. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  202. package/dist/theme/fonts-option.test.d.ts +2 -0
  203. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  204. package/dist/theme/radius-option.test.d.ts +2 -0
  205. package/dist/theme/radius-option.test.d.ts.map +1 -0
  206. package/dist/theme/radius.test.d.ts +2 -0
  207. package/dist/theme/radius.test.d.ts.map +1 -0
  208. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  209. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  210. package/dist/theme/spacing.test.d.ts +2 -0
  211. package/dist/theme/spacing.test.d.ts.map +1 -0
  212. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  213. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  214. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  215. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  216. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  217. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  218. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  219. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  220. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  221. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  222. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  223. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  224. package/dist/tokens/density.test.d.ts +2 -0
  225. package/dist/tokens/density.test.d.ts.map +1 -0
  226. package/dist/tokens/derive-radius.test.d.ts +2 -0
  227. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  228. package/dist/tokens/derive.test.d.ts +2 -0
  229. package/dist/tokens/derive.test.d.ts.map +1 -0
  230. package/dist/tokens/palette.test.d.ts +2 -0
  231. package/dist/tokens/palette.test.d.ts.map +1 -0
  232. package/dist/vite-color-mix.test.d.ts +2 -0
  233. package/dist/vite-color-mix.test.d.ts.map +1 -0
  234. package/dist/vite.test.d.ts +2 -0
  235. package/dist/vite.test.d.ts.map +1 -0
  236. package/llms.txt +9 -9
  237. package/package.json +7 -2
  238. package/src/agent/adapter.test.ts +1640 -0
  239. package/src/agent/adapter.ts +945 -0
  240. package/src/agent/ai-sdk-transport.test.ts +117 -26
  241. package/src/agent/ai-sdk-transport.ts +8 -2
  242. package/src/agent/coalesce.test.ts +202 -9
  243. package/src/agent/coalesce.ts +126 -15
  244. package/src/agent/foreign.test.ts +5 -1
  245. package/src/agent/id.test.ts +101 -3
  246. package/src/agent/id.ts +118 -0
  247. package/src/agent/index.ts +8 -0
  248. package/src/agent/merge.test.ts +87 -0
  249. package/src/agent/merge.ts +57 -10
  250. package/src/agent/part-list.test.tsx +31 -1
  251. package/src/agent/part-list.tsx +77 -29
  252. package/src/agent/parts.test.ts +121 -4
  253. package/src/agent/parts.ts +78 -17
  254. package/src/agent/thread.test.ts +75 -2
  255. package/src/agent/thread.ts +25 -1
  256. package/src/agent/use-agent-stream.test.tsx +31 -5
  257. package/src/agent/use-agent-stream.ts +6 -3
  258. package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
  259. package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
  260. package/src/agent/use-agent-thread-runs.test.tsx +356 -17
  261. package/src/agent/use-agent-thread-runs.ts +159 -23
  262. package/src/agent/use-agent-thread-runs.wedge.test.tsx +93 -7
  263. package/src/agent-chat/composer.test.tsx +924 -0
  264. package/src/agent-chat/composer.tsx +521 -52
  265. package/src/agent-chat/index.ts +13 -1
  266. package/src/agent-chat/message-affordances.ts +61 -0
  267. package/src/agent-chat/relative-time.test.ts +95 -0
  268. package/src/agent-chat/relative-time.ts +55 -0
  269. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  270. package/src/agent-chat/thread-feed-row.test.tsx +510 -0
  271. package/src/agent-chat/thread-feed-row.tsx +274 -0
  272. package/src/agent-chat/thread-feed.test.tsx +241 -0
  273. package/src/agent-chat/thread-feed.tsx +119 -18
  274. package/src/agent-chat/thread-message.test.tsx +1078 -4
  275. package/src/agent-chat/thread-message.tsx +795 -42
  276. package/src/agent-chat/thread-outcome-card.tsx +1 -25
  277. package/src/agent-chat/thread-workspace.test.tsx +169 -0
  278. package/src/agent-chat/thread-workspace.tsx +70 -6
  279. package/src/agent-chat/virtualize.ts +132 -0
  280. package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
  281. package/src/cli/index.ts +0 -1
  282. package/src/cli/template-format.test.ts +2 -2
  283. package/src/content/fence-block.tsx +242 -15
  284. package/src/content/heading-components.test.tsx +65 -0
  285. package/src/content/heading-components.tsx +26 -5
  286. package/src/content/index.ts +19 -3
  287. package/src/content/markdown.test.tsx +603 -0
  288. package/src/content/markdown.tsx +355 -35
  289. package/src/content/sanitize.test.ts +659 -0
  290. package/src/content/sanitize.ts +510 -0
  291. package/src/dashboard/stat-card.test.tsx +6 -1
  292. package/src/data/index.ts +5 -1
  293. package/src/data/virtual-list.tsx +2 -1
  294. package/src/data/virtual.ts +2 -1
  295. package/src/index.ts +18 -1
  296. package/src/surfaces.ts +13 -3
  297. package/src/theme/contrast.test.ts +1 -4
  298. package/src/theme/density-relations.test.ts +4 -2
  299. package/src/theme/fonts-option.test.ts +7 -1
  300. package/src/theme/shadow-surfaces.test.ts +21 -6
  301. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -23,7 +23,7 @@ import { aiSdkTransport } from './ai-sdk-transport'
23
23
  import { withPartIds } from './id'
24
24
  import { mergePart } from './merge'
25
25
  import type { UIMessageChunk } from 'ai'
26
- import type { AgentPart } from './parts'
26
+ import type { AgentPart, ToolCallPart } from './parts'
27
27
 
28
28
  /**
29
29
  * Builds a fetch-compatible Response whose body is the given low-level UIMessageChunks, SSE-
@@ -43,6 +43,16 @@ function scriptedResponse(chunks: UIMessageChunk[]): Response {
43
43
  return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } })
44
44
  }
45
45
 
46
+ /**
47
+ * Casts a scripted fetch stub into `aiSdkTransport`'s `fetch: typeof globalThis.fetch` option.
48
+ * The DOM lib's `typeof fetch` type also demands a static `preconnect` method (a real
49
+ * browser/undici API these scripted mocks never call and don't need to implement) — the cast
50
+ * documents that the only structural gap is that one, unused static member.
51
+ */
52
+ function mockFetch(impl: () => Promise<Response>): typeof fetch {
53
+ return impl as unknown as typeof fetch
54
+ }
55
+
46
56
  /** Drains an aiSdkTransport `.stream()`/`.resume()` async generator into a plain array. */
47
57
  async function collect(gen: AsyncGenerator<AgentPart>): Promise<AgentPart[]> {
48
58
  const parts: AgentPart[] = []
@@ -50,11 +60,24 @@ async function collect(gen: AsyncGenerator<AgentPart>): Promise<AgentPart[]> {
50
60
  return parts
51
61
  }
52
62
 
63
+ /**
64
+ * `Omit<T, K>` does not distribute over a union — `keyof T` on a union collapses to only the
65
+ * KEYS COMMON to every member, so `Omit<ToolCallPart, 'durationMs'>` would silently drop each
66
+ * state's own fields (`input`, `output`, …) rather than just `durationMs`. This variant forces
67
+ * distribution via a naked conditional type parameter.
68
+ */
69
+ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
70
+
53
71
  /** Strips a non-deterministic `durationMs` (wall-clock, so never equality-comparable) before an
54
- * exact `toEqual`, and separately asserts its type. */
55
- function withoutDurationMs<T extends { durationMs?: number }>(part: T): Omit<T, 'durationMs'> {
72
+ * exact `toEqual`, and separately asserts its type. Constrained to `Record<string, unknown>`
73
+ * rather than `{ durationMs?: number }` because ErrorPart (uniquely among AgentPart variants)
74
+ * carries no `durationMs` field at all — that stricter constraint trips TS's weak-type check
75
+ * ("no properties in common") the moment this is called with the full AgentPart union. */
76
+ function withoutDurationMs<T extends Record<string, unknown>>(
77
+ part: T,
78
+ ): DistributiveOmit<T, 'durationMs'> {
56
79
  const { durationMs: _durationMs, ...rest } = part
57
- return rest
80
+ return rest as DistributiveOmit<T, 'durationMs'>
58
81
  }
59
82
 
60
83
  describe('aiSdkTransport — snapshot→delta diffing', () => {
@@ -67,7 +90,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
67
90
  ]
68
91
  const transport = aiSdkTransport({
69
92
  api: '/api/chat',
70
- fetch: async () => scriptedResponse(chunks),
93
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
71
94
  })
72
95
  const parts = await collect(transport.stream('hi'))
73
96
 
@@ -107,7 +130,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
107
130
  // trivially fail to match on that alone, masking whether withPartIds itself changed anything.
108
131
  const transport = aiSdkTransport({
109
132
  api: '/api/chat',
110
- fetch: async () => scriptedResponse(chunks),
133
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
111
134
  })
112
135
  const direct = await collect(transport.stream('hi'))
113
136
  const wrapped = await collect(withPartIds('unrelated-run-id', transport.stream('hi')))
@@ -126,7 +149,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
126
149
  ]
127
150
  const transport = aiSdkTransport({
128
151
  api: '/api/chat',
129
- fetch: async () => scriptedResponse(chunks),
152
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
130
153
  })
131
154
  const parts = await collect(transport.stream('hi'))
132
155
 
@@ -146,7 +169,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
146
169
  ]
147
170
  const transport = aiSdkTransport({
148
171
  api: '/api/chat',
149
- fetch: async () => scriptedResponse(chunks),
172
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
150
173
  })
151
174
  const parts = await collect(transport.stream('hi'))
152
175
 
@@ -168,13 +191,16 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
168
191
  ]
169
192
  const transport = aiSdkTransport({
170
193
  api: '/api/chat',
171
- fetch: async () => scriptedResponse(chunks),
194
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
172
195
  })
173
196
  const parts = await collect(transport.stream('hi'))
174
197
 
175
198
  const toolParts = parts.filter((p) => p.type === 'tool')
176
199
  expect(toolParts.every((p) => (p as { id: string }).id === 'tool#call-1')).toBe(true)
177
- expect(toolParts.map(withoutDurationMs)).toEqual([
200
+ // Annotated so each element is checked individually against the ToolCallPart union — passed
201
+ // inline, TS collapses the array's contextual element type and rejects the settled states'
202
+ // extra `input`/`output` fields against the first (narrowest) member it picks.
203
+ const expectedToolParts: DistributiveOmit<ToolCallPart, 'durationMs'>[] = [
178
204
  {
179
205
  id: 'tool#call-1',
180
206
  type: 'tool',
@@ -199,7 +225,8 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
199
225
  input: { q: 'x' },
200
226
  output: { hits: 3 },
201
227
  },
202
- ])
228
+ ]
229
+ expect(toolParts.map(withoutDurationMs)).toEqual(expectedToolParts)
203
230
  // durationMs is only set once the call reaches a terminal state (output-available here).
204
231
  expect((toolParts[0] as { durationMs?: number }).durationMs).toBeUndefined()
205
232
  expect(typeof (toolParts.at(-1) as { durationMs?: number }).durationMs).toBe('number')
@@ -215,7 +242,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
215
242
  ]
216
243
  const transport = aiSdkTransport({
217
244
  api: '/api/chat',
218
- fetch: async () => scriptedResponse(chunks),
245
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
219
246
  })
220
247
  const parts = await collect(transport.stream('hi'))
221
248
 
@@ -245,7 +272,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
245
272
  ]
246
273
  const transport = aiSdkTransport({
247
274
  api: '/api/chat',
248
- fetch: async () => scriptedResponse(chunks),
275
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
249
276
  })
250
277
  const parts = await collect(transport.stream('hi'))
251
278
 
@@ -282,7 +309,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
282
309
  ]
283
310
  const transport = aiSdkTransport({
284
311
  api: '/api/chat',
285
- fetch: async () => scriptedResponse(chunks),
312
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
286
313
  })
287
314
  const parts = await collect(transport.stream('hi'))
288
315
 
@@ -330,7 +357,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
330
357
  ]
331
358
  const transport = aiSdkTransport({
332
359
  api: '/api/chat',
333
- fetch: async () => scriptedResponse(chunks),
360
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
334
361
  })
335
362
  const parts = await collect(transport.stream('hi'))
336
363
 
@@ -362,7 +389,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
362
389
  ]
363
390
  const transport = aiSdkTransport({
364
391
  api: '/api/chat',
365
- fetch: async () => scriptedResponse(chunks),
392
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
366
393
  })
367
394
  const parts = await collect(transport.stream('hi'))
368
395
 
@@ -394,7 +421,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
394
421
  ]
395
422
  const transport = aiSdkTransport({
396
423
  api: '/api/chat',
397
- fetch: async () => scriptedResponse(chunks),
424
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
398
425
  })
399
426
  const parts = await collect(transport.stream('hi'))
400
427
 
@@ -415,7 +442,7 @@ describe('aiSdkTransport — signal.aborted stops the yield', () => {
415
442
  ]
416
443
  const transport = aiSdkTransport({
417
444
  api: '/api/chat',
418
- fetch: async () => scriptedResponse(chunks),
445
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
419
446
  })
420
447
  const controller = new AbortController()
421
448
  const gen = transport.stream('hi', controller.signal)
@@ -446,9 +473,9 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
446
473
  // generator that never advances past its first `.next()` never touches the network.
447
474
  const transport = aiSdkTransport({
448
475
  api: '/api/chat',
449
- fetch: () => {
476
+ fetch: mockFetch(() => {
450
477
  throw new Error('must not be called — only the first, pre-network yield is read')
451
- },
478
+ }),
452
479
  })
453
480
 
454
481
  const first = await transport.stream('a').next()
@@ -460,9 +487,9 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
460
487
  test('.forThread(id) binds the StartPart runId/resumeToken/id deterministically to the given id', async () => {
461
488
  const transport = aiSdkTransport({
462
489
  api: '/api/chat',
463
- fetch: () => {
490
+ fetch: mockFetch(() => {
464
491
  throw new Error('must not be called — only the first, pre-network yield is read')
465
- },
492
+ }),
466
493
  })
467
494
  const bound = transport.forThread('caller-supplied-thread-id')
468
495
 
@@ -482,9 +509,9 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
482
509
  test('two different .forThread() ids produce two different StartPart runIds/ids', async () => {
483
510
  const transport = aiSdkTransport({
484
511
  api: '/api/chat',
485
- fetch: () => {
512
+ fetch: mockFetch(() => {
486
513
  throw new Error('must not be called — only the first, pre-network yield is read')
487
- },
514
+ }),
488
515
  })
489
516
 
490
517
  const a = await transport.forThread('thread-a').stream('hi').next()
@@ -516,7 +543,7 @@ describe('aiSdkTransport — deterministic ids and replay idempotency', () => {
516
543
  ]
517
544
  const transport = aiSdkTransport({
518
545
  api: '/api/chat',
519
- fetch: async () => scriptedResponse(chunks),
546
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
520
547
  }).forThread('thread-x')
521
548
 
522
549
  const firstIds = (await collect(transport.stream('hi'))).map((p) => (p as { id: string }).id)
@@ -535,7 +562,7 @@ describe('aiSdkTransport — deterministic ids and replay idempotency', () => {
535
562
  ]
536
563
  const transport = aiSdkTransport({
537
564
  api: '/api/chat',
538
- fetch: async () => scriptedResponse(chunks),
565
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
539
566
  }).forThread('thread-x')
540
567
 
541
568
  const firstPass = await collect(transport.stream('hi'))
@@ -565,3 +592,67 @@ describe('aiSdkTransport — deterministic ids and replay idempotency', () => {
565
592
  )
566
593
  })
567
594
  })
595
+
596
+ describe('aiSdkTransport — id minting under degraded crypto', () => {
597
+ test('the fixed chatId (mintThreadId, low collision cost) mints fine with no usable crypto at all — construction never throws', () => {
598
+ const originalCrypto = globalThis.crypto
599
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
600
+ try {
601
+ expect(() =>
602
+ aiSdkTransport({
603
+ api: '/api/chat',
604
+ fetch: mockFetch(() => {
605
+ throw new Error('must not be called')
606
+ }),
607
+ }),
608
+ ).not.toThrow()
609
+ } finally {
610
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
611
+ }
612
+ })
613
+
614
+ test('the outbound user message id (mintMessageId, an idempotency key on the AI SDK backend) works via getRandomValues when randomUUID is unavailable', async () => {
615
+ const chunks: UIMessageChunk[] = [
616
+ { type: 'text-start', id: 't1' },
617
+ { type: 'text-end', id: 't1' },
618
+ ]
619
+ const transport = aiSdkTransport({
620
+ api: '/api/chat',
621
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
622
+ })
623
+
624
+ const originalCrypto = globalThis.crypto
625
+ Object.defineProperty(globalThis, 'crypto', {
626
+ value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
627
+ configurable: true,
628
+ })
629
+ try {
630
+ const parts = await collect(transport.stream('hi'))
631
+ expect(parts[0]?.type).toBe('start')
632
+ } finally {
633
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
634
+ }
635
+ })
636
+
637
+ test('the outbound user message id THROWS on a host with no usable crypto at all, rather than silently colliding', async () => {
638
+ const transport = aiSdkTransport({
639
+ api: '/api/chat',
640
+ fetch: mockFetch(() => {
641
+ throw new Error('must not be called — must throw before any network call')
642
+ }),
643
+ })
644
+ const gen = transport.stream('hi')
645
+
646
+ // The synthesized StartPart is yielded first, before mintMessageId is ever reached.
647
+ const start = await gen.next()
648
+ expect(start.value).toMatchObject({ type: 'start' })
649
+
650
+ const originalCrypto = globalThis.crypto
651
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
652
+ try {
653
+ await expect(gen.next()).rejects.toThrow(/idempotency key/)
654
+ } finally {
655
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
656
+ }
657
+ })
658
+ })
@@ -48,6 +48,7 @@
48
48
  * })
49
49
  */
50
50
  import { assertNever } from '../register'
51
+ import { mintMessageId, mintThreadId } from './id'
51
52
  import { TERMINAL_TOOL_STATES } from './parts'
52
53
  import type { AgentPart, AgentPartDraft } from './parts'
53
54
  import type { ResumableAgentTransport } from './transport'
@@ -401,7 +402,10 @@ export function aiSdkTransport<TPart = AgentPart>(
401
402
 
402
403
  const { httpTransport, readUIMessageStream: readStream } = await resolveAiSdk()
403
404
  const userMessage: UIMessage = {
404
- id: crypto.randomUUID(),
405
+ // This id is what AI SDK's own backend persistence/dedup keys turns hang off — the same
406
+ // idempotency-key cost as basalt's own ChatMessage.id (see mintMessageId's doc), so
407
+ // mintMessageId, not mintThreadId, is the right helper here.
408
+ id: mintMessageId(),
405
409
  role: 'user',
406
410
  parts: [{ type: 'text', text: input }],
407
411
  }
@@ -425,7 +429,9 @@ export function aiSdkTransport<TPart = AgentPart>(
425
429
  }
426
430
  }
427
431
 
428
- const fixedChatId = crypto.randomUUID()
432
+ // Client-side conversation namespace, not an idempotency key — a collision here would merge two
433
+ // conversations' index-addressed part ids, the same low collision cost `mintThreadId` covers.
434
+ const fixedChatId = mintThreadId()
429
435
  return {
430
436
  ...makeTransport(fixedChatId),
431
437
  forThread: (chatId: string) => makeTransport(chatId),
@@ -136,6 +136,8 @@ describe('coalesceParts', () => {
136
136
  state: 'input-available',
137
137
  input: { q: 'x' },
138
138
  }
139
+ // `toolName` keeps its own empty-string convention for "omitted" (unlike toolCallId, whose
140
+ // absence is now modelled in the type) — the strict type still requires the field, hence the cast.
139
141
  const outputAvailableNoToolName = {
140
142
  id: 'p2',
141
143
  type: 'tool',
@@ -150,6 +152,10 @@ describe('coalesceParts', () => {
150
152
  expect((out[0] as ToolCallPart).toolName).toBe('search')
151
153
  })
152
154
 
155
+ // The SDK's raw approval-responded chunk arrives without its own toolCallId — one of the four
156
+ // SETTLED states whose id is optional in the type (parts.ts, MaybeIdentified), so every fixture
157
+ // below builds a legal wire shape directly, with no cast.
158
+
153
159
  test('an approval-responded part with no toolCallId is resolved via the approvalId side index', () => {
154
160
  const approvalRequested: ToolCallPart = {
155
161
  id: 'p1',
@@ -160,35 +166,222 @@ describe('coalesceParts', () => {
160
166
  input: { path: '/etc/passwd' },
161
167
  approval: { id: 'appr-1' },
162
168
  }
163
- // The SDK's raw approval-responded chunk arrives without its own toolCallId — resolved by
164
- // reverse lookup on approval.id instead.
165
- const approvalResponded = {
169
+ const approvalResponded: ToolCallPart = {
166
170
  id: 'p2',
167
171
  type: 'tool',
168
- toolCallId: '',
169
172
  toolName: 'delete_file',
170
173
  state: 'approval-responded',
171
174
  input: { path: '/etc/passwd' },
172
175
  approval: { id: 'appr-1', approved: true },
173
- } as unknown as ToolCallPart
176
+ }
174
177
 
175
178
  const out = coalesceParts([approvalRequested, approvalResponded])
176
179
  expect(out).toHaveLength(1)
177
- expect((out[0] as ToolCallPart).state).toBe('approval-responded')
180
+ const merged = out[0] as ToolCallPart
181
+ expect(merged.state).toBe('approval-responded')
182
+ // The resolved call keeps the identity the request supplied — the response had none.
183
+ expect(merged.toolCallId).toBe('call-1')
178
184
  })
179
185
 
180
186
  test('a tool part unresolvable by either toolCallId or approvalId is kept as-is, never dropped', () => {
181
- const orphan = {
187
+ const orphan: ToolCallPart = {
182
188
  id: 'p1',
183
189
  type: 'tool',
184
- toolCallId: '',
185
190
  toolName: 'mystery',
186
191
  state: 'approval-responded',
187
192
  input: {},
188
193
  approval: { id: 'unknown-approval', approved: true },
189
- } as unknown as ToolCallPart
194
+ }
190
195
 
191
196
  const out = coalesceParts([orphan])
192
197
  expect(out).toEqual([orphan])
193
198
  })
199
+
200
+ test('an approval-responded seen BEFORE its request stays its own block — the fold never backtracks', () => {
201
+ // The approvalId side index only holds ids seen so far, so a response arriving first cannot
202
+ // resolve. Deliberate: reconciling backwards would let the request (the LATER arrival) win
203
+ // under "later state wins" and re-render an already-answered call as still pending.
204
+ const respondedFirst: ToolCallPart = {
205
+ id: 'p1',
206
+ type: 'tool',
207
+ toolName: 'delete_file',
208
+ state: 'approval-responded',
209
+ input: { path: '/etc/passwd' },
210
+ approval: { id: 'appr-1', approved: true },
211
+ }
212
+ const requestedLater: ToolCallPart = {
213
+ id: 'p2',
214
+ type: 'tool',
215
+ toolCallId: 'call-1',
216
+ toolName: 'delete_file',
217
+ state: 'approval-requested',
218
+ input: { path: '/etc/passwd' },
219
+ approval: { id: 'appr-1' },
220
+ }
221
+ const outputAvailable: ToolCallPart = {
222
+ id: 'p3',
223
+ type: 'tool',
224
+ toolCallId: 'call-1',
225
+ toolName: 'delete_file',
226
+ state: 'output-available',
227
+ input: { path: '/etc/passwd' },
228
+ output: { deleted: true },
229
+ }
230
+
231
+ const out = coalesceParts([respondedFirst, requestedLater, outputAvailable])
232
+ expect(out).toHaveLength(2)
233
+ // The orphan survives untouched at its original position — never dropped, never rewritten.
234
+ expect(out[0]).toEqual(respondedFirst)
235
+ // Everything from the request onward folds into ONE second block, not a third.
236
+ expect((out[1] as ToolCallPart).state).toBe('output-available')
237
+ })
238
+
239
+ test('a failed call keeps NO stale output from the preliminary result it replaces', () => {
240
+ // The defect a blind `{ ...existing, ...next }` spread produced: output-error has no `output`
241
+ // key, so a spread let the preliminary result's `output`/`preliminary` ride along and any
242
+ // consumer branching on `output` before `errorText` rendered a failed call as successful.
243
+ const preliminaryResult: ToolCallPart = {
244
+ id: 'p1',
245
+ type: 'tool',
246
+ toolCallId: 'call-1',
247
+ toolName: 'search',
248
+ state: 'output-available',
249
+ input: { q: 'x' },
250
+ output: { hits: 1, partial: true },
251
+ preliminary: true,
252
+ }
253
+ const failed: ToolCallPart = {
254
+ id: 'p2',
255
+ type: 'tool',
256
+ toolCallId: 'call-1',
257
+ toolName: 'search',
258
+ state: 'output-error',
259
+ errorText: 'upstream timed out',
260
+ durationMs: 900,
261
+ }
262
+
263
+ const out = coalesceParts([preliminaryResult, failed])
264
+ expect(out).toHaveLength(1)
265
+ const merged = out[0] as ToolCallPart
266
+ expect(merged.state).toBe('output-error')
267
+ expect(Object.hasOwn(merged, 'output')).toBe(false)
268
+ expect(Object.hasOwn(merged, 'preliminary')).toBe(false)
269
+ expect(merged).toEqual({
270
+ id: 'p2',
271
+ type: 'tool',
272
+ toolCallId: 'call-1',
273
+ toolName: 'search',
274
+ durationMs: 900,
275
+ state: 'output-error',
276
+ errorText: 'upstream timed out',
277
+ // input is the one thing legitimately carried across the transition.
278
+ input: { q: 'x' },
279
+ })
280
+ })
281
+
282
+ test('a SETTLED part that arrived unidentified keeps the identity an earlier state supplied', () => {
283
+ // `toolCallId` is optional on the four settled states (parts.ts, MaybeIdentified). A terminal
284
+ // part that resolved only through the approvalId side index must come out of the merge WITH the
285
+ // id the request carried — losing it here would unfold the block on the next pass.
286
+ const approvalRequested: ToolCallPart = {
287
+ id: 'p1',
288
+ type: 'tool',
289
+ toolCallId: 'call-1',
290
+ toolName: 'transfer_funds',
291
+ state: 'approval-requested',
292
+ input: { amount: 250 },
293
+ approval: { id: 'appr-1' },
294
+ }
295
+ const unidentifiedResult: ToolCallPart = {
296
+ id: 'p2',
297
+ type: 'tool',
298
+ toolName: 'transfer_funds',
299
+ state: 'output-available',
300
+ input: { amount: 250 },
301
+ output: { ok: true },
302
+ approval: { id: 'appr-1', approved: true },
303
+ }
304
+
305
+ const out = coalesceParts([approvalRequested, unidentifiedResult])
306
+ expect(out).toHaveLength(1)
307
+ const merged = out[0] as ToolCallPart
308
+ expect(merged.state).toBe('output-available')
309
+ expect(merged.toolCallId).toBe('call-1')
310
+ })
311
+
312
+ // ── never throws ───────────────────────────────────────────────────────────
313
+ // The tool-state switch is exhaustive at COMPILE time only. coalesceParts is public, runs on the
314
+ // render path, and folds wire data — an unrecognized state must degrade, not abort the render.
315
+
316
+ test('an unrecognized tool state does not throw and yields a sensible merged part', () => {
317
+ const inputAvailable: ToolCallPart = {
318
+ id: 'p1',
319
+ type: 'tool',
320
+ toolCallId: 'call-1',
321
+ toolName: 'search',
322
+ state: 'input-available',
323
+ input: { q: 'x' },
324
+ }
325
+ // A state no version of the union declares — what a newer server would send to an older client.
326
+ const futureState = {
327
+ id: 'p2',
328
+ type: 'tool',
329
+ toolCallId: 'call-1',
330
+ toolName: '',
331
+ state: 'output-streaming',
332
+ output: { partial: true },
333
+ } as unknown as ToolCallPart
334
+
335
+ const warnings: string[] = []
336
+ const original = console.warn
337
+ console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
338
+ let out: ToolCallPart[]
339
+ try {
340
+ out = coalesceParts([inputAvailable, futureState]) as ToolCallPart[]
341
+ } finally {
342
+ console.warn = original
343
+ }
344
+
345
+ expect(out).toHaveLength(1)
346
+ // `state` widened to `string` — the whole point of this test is a state value the
347
+ // `ToolCallState` union does NOT declare (a future server sending an unknown state to an
348
+ // older client), so the real, narrower type would reject the very literal being asserted.
349
+ const merged = out[0] as Omit<ToolCallPart, 'state'> & { output?: unknown; state: string }
350
+ // The incoming part survives intact…
351
+ expect(merged.state).toBe('output-streaming')
352
+ expect(merged.output).toEqual({ partial: true })
353
+ // …with the whitelisted carry-over fields folded in, exactly as a known state would get.
354
+ expect(merged.toolName).toBe('search')
355
+ expect(merged.toolCallId).toBe('call-1')
356
+ // …and the anomaly is announced in dev rather than thrown.
357
+ expect(warnings).toHaveLength(1)
358
+ expect(warnings[0]).toContain('unrecognized tool state')
359
+ })
360
+
361
+ test('an approval-requested that later succeeds does not carry its pending approval onto the result', () => {
362
+ const approvalRequested: ToolCallPart = {
363
+ id: 'p1',
364
+ type: 'tool',
365
+ toolCallId: 'call-1',
366
+ toolName: 'transfer_funds',
367
+ state: 'approval-requested',
368
+ input: { amount: 250 },
369
+ approval: { id: 'appr-1' },
370
+ }
371
+ const outputAvailable: ToolCallPart = {
372
+ id: 'p2',
373
+ type: 'tool',
374
+ toolCallId: 'call-1',
375
+ toolName: 'transfer_funds',
376
+ state: 'output-available',
377
+ input: { amount: 250 },
378
+ output: { ok: true },
379
+ }
380
+
381
+ const out = coalesceParts([approvalRequested, outputAvailable])
382
+ expect(out).toHaveLength(1)
383
+ // `approval` comes from `next` alone: a pending request envelope (no verdict) must not surface
384
+ // on a settled result, where the type only admits `{ approved?: true }`.
385
+ expect(Object.hasOwn(out[0] as object, 'approval')).toBe(false)
386
+ })
194
387
  })