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
@@ -12,10 +12,26 @@
12
12
  * wire, the SDK re-supplies `input` by reading it back off the stored invocation, and a merge that
13
13
  * replaces wholesale rather than accumulating would lose it outright.
14
14
  *
15
- * `approval`-bearing responses can arrive without a usable `toolCallId` (the SDK resolves them by
16
- * reverse lookup on `approval.id` instead) — this maintains an `approvalId -> toolCallId` side
17
- * index while folding and routes such a part through it. A part resolvable by neither key is kept
18
- * as-is rather than dropped; never silently discard a part.
15
+ * A settled tool part can arrive without a `toolCallId` at all — `approval-responded` plus the
16
+ * three terminal states all carry it as optional (see `MaybeIdentified` in `parts.ts`).
17
+ * `approval-responded` is where the WIRE itself omits it: the SDK's `tool-approval-response` chunk
18
+ * carries no `toolCallId`, only `approval.id`, so it is resolved by reverse lookup on `approval.id`
19
+ * instead — this maintains an `approvalId -> toolCallId` side index while folding and routes such a
20
+ * part through it. A part resolvable by neither key is kept as-is rather than dropped; never
21
+ * silently discard a part.
22
+ *
23
+ * The fold is strictly LEFT-TO-RIGHT with no backtracking: the side index only holds ids seen so
24
+ * far, so an unidentified `approval-responded` arriving BEFORE its `approval-requested` stays its
25
+ * own block and the later request opens a second one. That is deliberate, not an oversight —
26
+ * reconciling backwards would let the request (the later arrival) win under the "later state wins"
27
+ * rule and render an already-answered call as still pending, which is worse than two blocks in an
28
+ * ordering the wire never produces.
29
+ *
30
+ * **This function never throws.** It is public, it runs on the render path, and it folds data that
31
+ * came off a wire — so every anomaly (an unrecognized tool state, an unresolvable id) degrades to
32
+ * "keep the part as it arrived", announced in dev, rather than aborting the render. A throw here
33
+ * would turn one malformed chunk into a permanently dead transcript. Exhaustiveness over the tool
34
+ * states is still enforced, but at COMPILE time only (see `mergeToolPart`'s default branch).
19
35
  *
20
36
  * @example
21
37
  * import { coalesceParts } from 'basalt-ui/agent'
@@ -35,7 +51,7 @@ function resolveToolCallId(
35
51
  part: ToolCallPart,
36
52
  toolCallIdByApprovalId: Map<string, string>,
37
53
  ): string | undefined {
38
- if (typeof part.toolCallId === 'string' && part.toolCallId.length > 0) return part.toolCallId
54
+ if (part.toolCallId !== undefined) return part.toolCallId
39
55
  const approval = 'approval' in part ? part.approval : undefined
40
56
  return approval !== undefined ? toolCallIdByApprovalId.get(approval.id) : undefined
41
57
  }
@@ -48,24 +64,119 @@ function registerApprovalIndex(
48
64
  ): void {
49
65
  const approval = 'approval' in part ? part.approval : undefined
50
66
  if (approval === undefined) return
51
- if (typeof part.toolCallId !== 'string' || part.toolCallId.length === 0) return
67
+ if (part.toolCallId === undefined) return
52
68
  toolCallIdByApprovalId.set(approval.id, part.toolCallId)
53
69
  }
54
70
 
55
- /** The later state always wins; `input`/`toolName` from the earlier state survive a later state
56
- * that omits them. */
71
+ /**
72
+ * The later state always wins; `toolCallId`/`toolName`/`durationMs`/`providerExecuted` and `input`
73
+ * from the earlier state survive a later state that omits them.
74
+ *
75
+ * The result is RECONSTRUCTED per `next.state`, building only the fields that state legally has —
76
+ * never a blind `{ ...existing, ...next }` spread. A spread lets any field the next state does not
77
+ * redeclare survive onto it: an `output-available` (with `output`, possibly `preliminary`) that
78
+ * fails to `output-error` (which has no `output` key) would keep a stale `output` alongside
79
+ * `errorText`, and a consumer branching on `output` before `errorText` renders a failed call as
80
+ * successful. Only the fields listed above are carried; `output`, `errorText`, `rawInput`,
81
+ * `preliminary` and `approval` come from `next` alone, so nothing from a prior state can leak into
82
+ * a member that has no room for it. Reconstruction is exact enough that tsc proves each returned
83
+ * member valid — there is deliberately no cast here.
84
+ */
57
85
  function mergeToolPart(existing: ToolCallPart, next: ToolCallPart): ToolCallPart {
58
86
  const existingInput = 'input' in existing ? existing.input : undefined
59
87
  const nextInput = 'input' in next ? next.input : undefined
60
88
  const input = nextInput !== undefined ? nextInput : existingInput
61
- const toolName = next.toolName.length > 0 ? next.toolName : existing.toolName
89
+ const durationMs = next.durationMs ?? existing.durationMs
90
+ const providerExecuted = next.providerExecuted ?? existing.providerExecuted
91
+ // `toolCallId` is optional on the four settled states (see `MaybeIdentified` in parts.ts), so a
92
+ // later part that arrived unidentified — resolved here through the approvalId side index — keeps
93
+ // the identity an earlier state supplied instead of losing it on the way through the merge.
94
+ const settledToolCallId = next.toolCallId ?? existing.toolCallId
95
+
96
+ const common = {
97
+ id: next.id,
98
+ type: 'tool' as const,
99
+ toolName: next.toolName.length > 0 ? next.toolName : existing.toolName,
100
+ ...(durationMs !== undefined ? { durationMs } : {}),
101
+ ...(providerExecuted !== undefined ? { providerExecuted } : {}),
102
+ }
62
103
 
63
- return {
64
- ...existing,
65
- ...next,
66
- toolName,
67
- ...(input !== undefined ? { input } : {}),
68
- } as ToolCallPart
104
+ switch (next.state) {
105
+ case 'input-streaming':
106
+ return {
107
+ ...common,
108
+ toolCallId: next.toolCallId,
109
+ state: 'input-streaming',
110
+ ...(input !== undefined ? { input } : {}),
111
+ }
112
+ case 'input-available':
113
+ return { ...common, toolCallId: next.toolCallId, state: 'input-available', input }
114
+ case 'approval-requested':
115
+ return {
116
+ ...common,
117
+ toolCallId: next.toolCallId,
118
+ state: 'approval-requested',
119
+ input,
120
+ approval: next.approval,
121
+ }
122
+ case 'approval-responded':
123
+ return {
124
+ ...common,
125
+ ...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
126
+ state: 'approval-responded',
127
+ input,
128
+ approval: next.approval,
129
+ }
130
+ case 'output-available':
131
+ return {
132
+ ...common,
133
+ ...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
134
+ state: 'output-available',
135
+ input,
136
+ output: next.output,
137
+ ...(next.preliminary !== undefined ? { preliminary: next.preliminary } : {}),
138
+ ...(next.approval !== undefined ? { approval: next.approval } : {}),
139
+ }
140
+ case 'output-error':
141
+ return {
142
+ ...common,
143
+ ...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
144
+ state: 'output-error',
145
+ errorText: next.errorText,
146
+ ...(input !== undefined ? { input } : {}),
147
+ ...(next.rawInput !== undefined ? { rawInput: next.rawInput } : {}),
148
+ ...(next.approval !== undefined ? { approval: next.approval } : {}),
149
+ }
150
+ case 'output-denied':
151
+ return {
152
+ ...common,
153
+ ...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
154
+ state: 'output-denied',
155
+ input,
156
+ approval: next.approval,
157
+ }
158
+ default: {
159
+ // Exhaustive at COMPILE time, defensive at RUNTIME. Binding `next` at `never` keeps tsc's
160
+ // guarantee — add a state to `ToolCallPart` without a case above and this line stops
161
+ // compiling — but this branch must NOT throw. `coalesceParts` is public, runs on the render
162
+ // path, and folds wire data: a throw would turn one transient wire anomaly into a
163
+ // permanently dead transcript (the same trade `spliceText` makes in merge.ts, where an
164
+ // out-of-range offset is clamped rather than thrown on). An unrecognized state therefore
165
+ // falls back to the incoming part with only the whitelisted carry-over fields folded in —
166
+ // nothing state-specific from `existing` can leak, because `common` is all that is merged.
167
+ const unhandled: never = next
168
+ // Widened back deliberately: control-flow analysis would keep an annotated const at `never`
169
+ // and make every field read below a compile error, so the assertion is what makes the
170
+ // runtime value usable. It asserts nothing tsc has not already proved on the line above.
171
+ const fallback = unhandled as ToolCallPart
172
+ if (process.env['NODE_ENV'] !== 'production') {
173
+ console.warn(
174
+ `[basalt] coalesceParts: unrecognized tool state ${JSON.stringify(fallback.state)} — kept, merged best-effort`,
175
+ )
176
+ }
177
+ return { ...fallback, ...common }
178
+ }
179
+ }
69
180
  }
70
181
 
71
182
  export function coalesceParts<TPart extends AgentPart>(parts: readonly TPart[]): TPart[] {
@@ -8,9 +8,13 @@
8
8
  import { describe, expect, test } from 'bun:test'
9
9
  import { definePartRenderers, narrowAgentPart } from './foreign'
10
10
  import type { TranscriptPart } from './foreign'
11
+ import type { AgentPart } from './parts'
11
12
 
12
13
  describe('narrowAgentPart', () => {
13
- const BUILT_IN_SAMPLES: TranscriptPart[] = [
14
+ // Typed AgentPart[], not TranscriptPart[]: every literal here IS a built-in variant (the whole
15
+ // point of this fixture), and narrowAgentPart's return type is AgentPart | null — comparing it
16
+ // against a TranscriptPart-typed element (which also admits ForeignPart) doesn't type-check.
17
+ const BUILT_IN_SAMPLES: AgentPart[] = [
14
18
  { id: 'p1', type: 'start', runId: 'r1' },
15
19
  { id: 'p2', type: 'text', text: 'hi' },
16
20
  { id: 'p3', type: 'reasoning', text: 'thinking' },
@@ -1,9 +1,16 @@
1
1
  /**
2
2
  * withPartIds — stamps `${runId}#${n}` onto any draft part arriving without an id; idempotent for
3
3
  * drafts that already have one (untouched, and doesn't advance the counter).
4
+ *
5
+ * mintThreadId/mintMessageId — the two id-minting helpers behind every unguarded
6
+ * `crypto.randomUUID()` call site this file's siblings used to have. Both share rungs 1
7
+ * (`crypto.randomUUID`) and 2 (`crypto.getRandomValues`, hand-assembled into a UUIDv4); they
8
+ * deliberately diverge on rung 3 (no crypto at all) — `mintThreadId` degrades, `mintMessageId`
9
+ * throws. See each function's own doc for why.
4
10
  */
5
11
  import { describe, expect, test } from 'bun:test'
6
- import { withPartIds } from './id'
12
+ import { mintMessageId, mintThreadId, withPartIds } from './id'
13
+ import type { AgentPartDraft } from './parts'
7
14
 
8
15
  async function* gen<T>(values: T[]): AsyncGenerator<T> {
9
16
  for (const value of values) yield value
@@ -15,9 +22,39 @@ async function collect<T>(source: AsyncGenerator<T>): Promise<T[]> {
15
22
  return out
16
23
  }
17
24
 
25
+ const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
26
+
27
+ /** Rung 2: randomUUID absent, getRandomValues present (a real, non-secure-context host). */
28
+ function withRung2Crypto<T>(run: () => T): T {
29
+ const originalCrypto = globalThis.crypto
30
+ Object.defineProperty(globalThis, 'crypto', {
31
+ value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
32
+ configurable: true,
33
+ })
34
+ try {
35
+ return run()
36
+ } finally {
37
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
38
+ }
39
+ }
40
+
41
+ /** Rung 3: no usable crypto at all. */
42
+ function withRung3Crypto<T>(run: () => T): T {
43
+ const originalCrypto = globalThis.crypto
44
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
45
+ try {
46
+ return run()
47
+ } finally {
48
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
49
+ }
50
+ }
51
+
18
52
  describe('withPartIds', () => {
19
53
  test('stamps a sequential id on every draft missing one', async () => {
20
- const drafts = [
54
+ // Annotated: all three literals uniformly omit `id`, so without this TS infers an `id`-less
55
+ // element type that has no properties in common with withPartIds' `{ id?: string }`
56
+ // constraint — a weak-type check failure, not a real shape mismatch.
57
+ const drafts: AgentPartDraft[] = [
21
58
  { type: 'text', text: 'a' },
22
59
  { type: 'text', text: 'b' },
23
60
  { type: 'text', text: 'c' },
@@ -37,7 +74,7 @@ describe('withPartIds', () => {
37
74
  })
38
75
 
39
76
  test('is idempotent: re-running withPartIds over already-identified parts changes nothing', async () => {
40
- const drafts = [{ type: 'text', text: 'a' }]
77
+ const drafts: AgentPartDraft[] = [{ type: 'text', text: 'a' }]
41
78
  const once = await collect(withPartIds('run-1', gen(drafts)))
42
79
  const twice = await collect(withPartIds('run-1', gen(once)))
43
80
  expect(twice).toEqual(once)
@@ -69,3 +106,64 @@ describe('withPartIds', () => {
69
106
  ])
70
107
  })
71
108
  })
109
+
110
+ describe('mintThreadId', () => {
111
+ test('rung 1 (crypto.randomUUID): returns a valid UUIDv4 string', () => {
112
+ const id = mintThreadId()
113
+ expect(id).toMatch(UUID_V4_RE)
114
+ })
115
+
116
+ test('rung 1: many mints are distinct', () => {
117
+ const ids = Array.from({ length: 1000 }, () => mintThreadId())
118
+ expect(new Set(ids).size).toBe(1000)
119
+ })
120
+
121
+ test('rung 2 (getRandomValues only): assembles a valid UUIDv4 — version/variant bits set', () => {
122
+ const id = withRung2Crypto(() => mintThreadId())
123
+ expect(id).toMatch(UUID_V4_RE)
124
+ })
125
+
126
+ test('rung 2: many mints are distinct', () => {
127
+ const ids = withRung2Crypto(() => Array.from({ length: 1000 }, () => mintThreadId()))
128
+ expect(new Set(ids).size).toBe(1000)
129
+ })
130
+
131
+ test('rung 3 (no usable crypto at all): does NOT throw, still returns distinct non-empty strings', () => {
132
+ const ids = withRung3Crypto(() => Array.from({ length: 50 }, () => mintThreadId()))
133
+ for (const id of ids) {
134
+ expect(typeof id).toBe('string')
135
+ expect(id.length).toBeGreaterThan(0)
136
+ }
137
+ // Weak (Date.now() + Math.random()), not cryptographically random — but still distinct across
138
+ // this many mints in one tick, since Math.random() varies even when Date.now() doesn't.
139
+ expect(new Set(ids).size).toBe(50)
140
+ })
141
+ })
142
+
143
+ describe('mintMessageId', () => {
144
+ test('rung 1 (crypto.randomUUID): returns a valid UUIDv4 string', () => {
145
+ const id = mintMessageId()
146
+ expect(id).toMatch(UUID_V4_RE)
147
+ })
148
+
149
+ test('rung 1: many mints are distinct', () => {
150
+ const ids = Array.from({ length: 1000 }, () => mintMessageId())
151
+ expect(new Set(ids).size).toBe(1000)
152
+ })
153
+
154
+ test('rung 2 (getRandomValues only): assembles a valid UUIDv4 — version/variant bits set', () => {
155
+ const id = withRung2Crypto(() => mintMessageId())
156
+ expect(id).toMatch(UUID_V4_RE)
157
+ })
158
+
159
+ test('rung 2: many mints are distinct', () => {
160
+ const ids = withRung2Crypto(() => Array.from({ length: 1000 }, () => mintMessageId()))
161
+ expect(new Set(ids).size).toBe(1000)
162
+ })
163
+
164
+ test('rung 3 (no usable crypto at all): THROWS rather than minting a non-random id', () => {
165
+ // Diverges from mintThreadId's rung 3 on purpose — appendMessage's idempotency key must never
166
+ // silently degrade to a non-collision-resistant id. See mintMessageId's own doc.
167
+ expect(() => withRung3Crypto(() => mintMessageId())).toThrow(/idempotency key/)
168
+ })
169
+ })
package/src/agent/id.ts CHANGED
@@ -1,3 +1,121 @@
1
+ /**
2
+ * Id-minting helpers shared across the agent layer: `mintThreadId` (a low-stakes client-side id —
3
+ * threads, run/chat namespaces — used by `./thread`'s `createThreadsStore`, `./adapter`'s
4
+ * `createAdapterThreadsStore`/`threadsStoreAdapterContract`, `./use-agent-stream`'s per-send run
5
+ * id, and `aiSdkTransport`'s fixed chat id), `mintMessageId` (the higher-stakes id `appendMessage`
6
+ * treats as its only idempotency key), and `withPartIds` (stamps sequence ids onto streamed
7
+ * parts). Grouped here because they're the id-minting leaves every store/hook/transport in this
8
+ * layer sits above — not because they share a mechanism (`mintMessageId` deliberately diverges
9
+ * from `mintThreadId` on rung 3; see its own doc).
10
+ */
11
+
12
+ /**
13
+ * Hand-assembles a UUIDv4 string from 16 random bytes — the shared rung-2 mechanism both
14
+ * `mintThreadId` and `mintMessageId` use once `crypto.randomUUID` is unavailable but
15
+ * `crypto.getRandomValues` still is. The version/variant bits are set per RFC 4122, since this
16
+ * rung hands back raw bytes, not a formatted UUID.
17
+ */
18
+ function assembleUuidV4(bytes: Uint8Array): string {
19
+ const hex = Array.from({ length: 16 }, (_, i) => {
20
+ const raw = bytes.at(i) ?? 0
21
+ // Version 4 (bits 12-15 of time_hi_and_version) and variant 10 (bits 6-7 of
22
+ // clock_seq_hi_and_reserved) — the only structure a UUIDv4 promises over raw random bytes.
23
+ const byte = i === 6 ? (raw & 0x0f) | 0x40 : i === 8 ? (raw & 0x3f) | 0x80 : raw
24
+ return byte.toString(16).padStart(2, '0')
25
+ }).join('')
26
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
27
+ }
28
+
29
+ /**
30
+ * Mints a client-side id for a LOW-COLLISION-COST use: a thread id, a per-send run-id namespace, a
31
+ * fixed chat id — anything minted client-side that is NOT the idempotency key some write depends
32
+ * on. `crypto.randomUUID()` exists only in SECURE CONTEXTS (HTTPS or localhost) — a consumer served
33
+ * over plain HTTP on a LAN hostname (a homelab dashboard on a bare IP, a staging box) would
34
+ * otherwise have `create()`, about the most basic user action a thread store has, throw a plain
35
+ * TypeError. A store used from a browser must not throw on a user gesture — the same principle
36
+ * already applied twice this release (`spliceText` clamps rather than throws, `coalesceParts`
37
+ * degrades rather than throws).
38
+ *
39
+ * Fallback chain, each rung reached only when the one above it is unavailable:
40
+ * 1. `crypto.randomUUID()` — cryptographically random, RFC 4122 UUID. The normal path.
41
+ * 2. `crypto.getRandomValues()` — still cryptographically random; hand-assembled into a UUIDv4
42
+ * via `assembleUuidV4`.
43
+ * 3. No usable `crypto` at all (missing entirely, or missing both methods above — an old WebView,
44
+ * an SSR shim). NOT cryptographically random and NOT collision-resistant in general. This rung
45
+ * exists solely so a caller never throws on such a host. It is acceptable ONLY because an id
46
+ * minted here is minted a handful of times per client session and is not the idempotency key
47
+ * any write depends on — `ThreadsStoreAdapter.appendMessage`'s idempotency key is the MESSAGE
48
+ * id (see `mintMessageId`), not this one, so a collision on this rung costs a locally
49
+ * merged/duplicated thread (or two runs sharing a part-id namespace), not silent data loss on
50
+ * the append path. Do not reuse this rung for anything with a higher collision cost than that
51
+ * — see `mintMessageId` for the id that needs one.
52
+ */
53
+ export function mintThreadId(): string {
54
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
55
+ return crypto.randomUUID()
56
+ }
57
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
58
+ return assembleUuidV4(crypto.getRandomValues(new Uint8Array(16)))
59
+ }
60
+ return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
61
+ }
62
+
63
+ /**
64
+ * Mints a message id — the value `ThreadsStoreAdapter.appendMessage` treats as its ONLY
65
+ * idempotency key (see that method's contract doc in `./adapter`). Shares `mintThreadId`'s rungs
66
+ * 1-2 (`crypto.randomUUID()`, then `crypto.getRandomValues()` assembled into a UUIDv4) — both are
67
+ * cryptographically random and collision-resistant, and rung 2 is the fix that actually matters in
68
+ * practice (a plain-HTTP/LAN host still has `getRandomValues`; only `randomUUID` is secure-context-
69
+ * gated).
70
+ *
71
+ * Rung 3 deliberately DIVERGES from `mintThreadId`: it THROWS instead of degrading to a
72
+ * non-random fallback. A thread id's rung-3 collision costs a locally merged/duplicated thread —
73
+ * annoying, recoverable, visible. A message id's collision cost is different IN KIND, not degree:
74
+ * `appendMessage` is idempotent on this id, so two messages that collide are not two rows, they
75
+ * are ONE — the second write silently no-ops and its content is gone, with nothing downstream able
76
+ * to tell. Degrading here would trade a loud, debuggable failure for silent, permanent data loss.
77
+ *
78
+ * This deliberately diverges from this layer's other standing rule — render-path code degrades,
79
+ * never throws (`spliceText` clamps, `coalesceParts` degrades, a consumer fence renderer must not
80
+ * take the message down) — because minting a message id is NOT render-path code. It runs once, in
81
+ * the write path that constructs a message before handing it to a store (`start()`,
82
+ * `consumeAndFinalize`'s finalize step, `finalizeStop`, `aiSdkTransport`'s per-turn user message),
83
+ * never inside the per-chunk accumulation/render loop those functions guard. A host with no usable
84
+ * `crypto` at all (missing both `randomUUID` and `getRandomValues`) is vanishingly rare — no
85
+ * runtime with a DOM/fetch surface worth targeting ships without a Crypto object — and an uncaught
86
+ * throw here is a loud, debuggable failure rather than a message silently vanishing with no trace,
87
+ * but WHERE it surfaces differs by call site (audited, not assumed — this paragraph previously
88
+ * claimed a single shared catch handled all of them, which was false):
89
+ * - `consumeAndFinalize`'s own assistantMessage mint runs inside that function's try/catch, so a
90
+ * throw there is correctly turned into `onFailureStatus` ('error' from `start()`, 'interrupted'
91
+ * from the mount-time resume) — a visibly stuck-then-settled run, as originally claimed.
92
+ * - `useAgentThreadRuns.start()`'s userMessage mint runs BEFORE any state is touched
93
+ * (`appendMessage`/`setStatus`/the controller registration all come after it), so a throw there
94
+ * is a true no-op that propagates synchronously out of `start()` itself — not a stuck run at
95
+ * all, since nothing ever started. See `UseAgentThreadRunsReturn.start`'s `@throws` doc.
96
+ * - `finalizeStop`'s stoppedMessage mint is wrapped in its own try/catch precisely so a throw
97
+ * here cannot wedge the thread at 'streaming' forever (stop() has already torn down this run's
98
+ * bookkeeping by the time finalizeStop runs, so a second stop() is always a no-op) — see
99
+ * `finalizeStop`'s doc for the guarded behavior and the resulting 'error' status.
100
+ * A stuck spinner is debuggable; silently discarded chat history is not — that half of the
101
+ * original claim stands regardless of which of the three paths above catches it.
102
+ *
103
+ * @throws {Error} when neither `crypto.randomUUID` nor `crypto.getRandomValues` is available.
104
+ */
105
+ export function mintMessageId(): string {
106
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
107
+ return crypto.randomUUID()
108
+ }
109
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
110
+ return assembleUuidV4(crypto.getRandomValues(new Uint8Array(16)))
111
+ }
112
+ throw new Error(
113
+ 'mintMessageId: no usable crypto (both randomUUID and getRandomValues are unavailable) — ' +
114
+ 'refusing to mint a non-random message id, since ThreadsStoreAdapter.appendMessage treats ' +
115
+ 'this id as its only idempotency key and a collision would silently drop a message.',
116
+ )
117
+ }
118
+
1
119
  /**
2
120
  * withPartIds — stamps a stable id onto every draft part arriving without one.
3
121
  *
@@ -113,3 +113,11 @@ export type { AgentThread, ThreadStatus, ThreadsStore, ThreadsStoreOptions } fro
113
113
  // ── AgentOutcome + heuristicOutcome ────────────────────────────────────────────
114
114
  export { heuristicOutcome } from './outcome'
115
115
  export type { AgentOutcome, OutcomeResolver } from './outcome'
116
+
117
+ // ── createAdapterThreadsStore + threadsStoreAdapterContract ──────────────────
118
+ export { createAdapterThreadsStore, threadsStoreAdapterContract } from './adapter'
119
+ export type {
120
+ AdapterThreadsStoreOptions,
121
+ ThreadsStoreAdapter,
122
+ ThreadsStoreAdapterContractCase,
123
+ } from './adapter'
@@ -57,6 +57,93 @@ describe('mergePart', () => {
57
57
  expect(replayed).toEqual(firstPassResult.map((p) => ({ ...p, offset: 0 })))
58
58
  })
59
59
 
60
+ // ── out-of-range offsets ───────────────────────────────────────────────────
61
+ // spliceText supports exactly two shapes (append at the end, in-range replace). Everything else
62
+ // is CLAMPED into [0, existing.text.length] with a dev warning — never thrown on, because a
63
+ // throw on the render path turns a transient wire anomaly into a permanently dead transcript.
64
+
65
+ test('an offset past the end of the existing text clamps to the tail (appends, leaves no hole)', () => {
66
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hi' }]
67
+ const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: 10 })
68
+ expect(merged?.text).toBe('Hi!')
69
+ })
70
+
71
+ test('a negative offset clamps to 0 rather than slicing from the end', () => {
72
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hello world' }]
73
+ const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: 'Hey', offset: -3 })
74
+ expect(merged?.text).toBe('Heylo world')
75
+ })
76
+
77
+ test('an out-of-order (already-covered) offset replaces in place — it never re-appends', () => {
78
+ // 'Hello world' is fully accumulated; a straggler delta for offset 6 arrives after the tail
79
+ // has moved past it. In-range, so it is honoured as a replace, not clamped.
80
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hello world' }]
81
+ const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: 'WORLD', offset: 6 })
82
+ expect(merged?.text).toBe('Hello WORLD')
83
+ })
84
+
85
+ test('a non-finite offset clamps to the tail rather than blanking the part', () => {
86
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hi' }]
87
+ const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: Number.NaN })
88
+ expect(merged?.text).toBe('Hi!')
89
+ })
90
+
91
+ test('a FIRST insertion with a nonzero offset clamps and warns like any other out-of-range one', () => {
92
+ // A first insertion splices against an EMPTY existing text, so the offset is out of range
93
+ // [0, 0]: mergePart never invents the 2 missing characters, it stores what arrived — but the
94
+ // anomaly is announced, exactly as it is for an existing part. One contract, one code path.
95
+ const warnings: string[] = []
96
+ const original = console.warn
97
+ console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
98
+ let first: TextPart[]
99
+ try {
100
+ first = mergePart<TextPart>([], { id: 'p1', type: 'text', text: 'llo', offset: 2 })
101
+ } finally {
102
+ console.warn = original
103
+ }
104
+ expect(warnings).toHaveLength(1)
105
+ expect(warnings[0]).toContain('out of range [0, 0]')
106
+ expect(first).toEqual([{ id: 'p1', type: 'text', text: 'llo', offset: 2 }])
107
+
108
+ // The offset rides along inert; the next delta for this id splices relative to the STORED text.
109
+ const [merged] = mergePart(first, { id: 'p1', type: 'text', text: ' there' })
110
+ expect(merged?.text).toBe('llo there')
111
+ })
112
+
113
+ test('a FIRST insertion with an in-range (0 or undefined) offset stays silent and stores the text as-is', () => {
114
+ const warnings: string[] = []
115
+ const original = console.warn
116
+ console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
117
+ try {
118
+ expect(mergePart<TextPart>([], { id: 'p1', type: 'text', text: 'Hello', offset: 0 })).toEqual(
119
+ [{ id: 'p1', type: 'text', text: 'Hello', offset: 0 }],
120
+ )
121
+ expect(mergePart<TextPart>([], { id: 'p2', type: 'text', text: 'Hello' })).toEqual([
122
+ { id: 'p2', type: 'text', text: 'Hello' },
123
+ ])
124
+ } finally {
125
+ console.warn = original
126
+ }
127
+ expect(warnings).toHaveLength(0)
128
+ })
129
+
130
+ test('warns in dev on an out-of-range offset, and stays silent on an in-range one', () => {
131
+ const warnings: string[] = []
132
+ const original = console.warn
133
+ console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
134
+ try {
135
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hi' }]
136
+ mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: 1 })
137
+ expect(warnings).toHaveLength(0)
138
+
139
+ mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: 10 })
140
+ expect(warnings).toHaveLength(1)
141
+ expect(warnings[0]).toContain('out of range')
142
+ } finally {
143
+ console.warn = original
144
+ }
145
+ })
146
+
60
147
  test('non-text/reasoning variants replace wholesale rather than merging fields', () => {
61
148
  const parts: ToolCallPart[] = [
62
149
  {
@@ -4,8 +4,11 @@
4
4
  * Appending a part whose `id` is already present in `parts` REWRITES that entry in place rather
5
5
  * than pushing a duplicate — ordering stays stable (a rewritten part keeps its original position).
6
6
  * `text`/`reasoning` splice the incoming delta into the existing text at `next.offset` (an
7
- * undefined offset means "append at the tail"); every other variant — including any `ForeignPart`,
8
- * which is never text-like — replaces wholesale.
7
+ * undefined offset means "append at the tail", and an out-of-range one is clamped rather than
8
+ * honoured — see `spliceText` for the exact supported shapes); every other variant — including any
9
+ * `ForeignPart`, which is never text-like — replaces wholesale. A FIRST insertion of a text-like
10
+ * part goes through the same splice against an empty text, so an offset that claims a prefix this
11
+ * accumulator never saw is clamped and warned about there too — never honoured, never padded.
9
12
  *
10
13
  * The invariant this holds, and the reason `offset` exists at all: replaying a whole run from
11
14
  * character 0 rebuilds the identical parts array it produced the first time — it cannot double
@@ -41,26 +44,70 @@ function isTextLikeType(type: string): type is TextLike['type'] {
41
44
  return type === 'text' || type === 'reasoning'
42
45
  }
43
46
 
44
- /** Splices `next`'s text into `existing`'s text at `next.offset` (tail-append when undefined). */
45
- function spliceText(existing: TextLike, next: TextLike): string {
46
- const offset = next.offset ?? existing.text.length
47
- return existing.text.slice(0, offset) + next.text + existing.text.slice(offset + next.text.length)
47
+ /**
48
+ * Splices `next`'s text into `existingText` at `next.offset`. Exactly two shapes are supported,
49
+ * and they are the only two any basalt transport emits (`diffPart` always sets
50
+ * `offset = prevText.length`; `diffChunkStream` restarts its accumulator per call):
51
+ *
52
+ * 1. **append at the end** — `offset` undefined, or equal to `existingText.length`;
53
+ * 2. **in-range replace** — `0 <= offset <= existingText.length`, overwriting the span the
54
+ * incoming text covers (this is what makes an offset-0 full replay converge instead of double).
55
+ *
56
+ * An offset outside that range — negative, past the current length, or non-finite — is **clamped**
57
+ * into `[0, existingText.length]`, not honoured and not thrown on. A throw here would turn a
58
+ * transient wire anomaly into a permanently dead transcript; this is stream-reduction code on the
59
+ * render path. Each clamp is announced in dev.
60
+ *
61
+ * A FIRST insertion (no entry with this id yet) runs through here too, against an empty
62
+ * `existingText`: the stored text is the incoming text either way, but a nonzero offset — the wire
63
+ * claiming text this accumulator never saw — is clamped to 0 and announced like any other
64
+ * out-of-range value rather than passing silently. `mergePart` never invents the missing prefix.
65
+ *
66
+ * Note what is NOT special-cased: an out-of-order delta whose offset is still in range is an
67
+ * ordinary in-range replace, so it overwrites whatever already occupies that span. `mergePart`
68
+ * has no way to tell a stale delta from a deliberate rewrite, and inventing one would break the
69
+ * offset-0 replay convergence this whole mechanism exists for.
70
+ */
71
+ function spliceText(existingText: string, next: TextLike): string {
72
+ const offset = clampOffset(next.offset ?? existingText.length, existingText.length)
73
+ return existingText.slice(0, offset) + next.text + existingText.slice(offset + next.text.length)
74
+ }
75
+
76
+ /** Clamps a wire offset into `[0, length]`, warning in dev when the raw value was out of range. */
77
+ function clampOffset(offset: number, length: number): number {
78
+ // A non-finite offset would make every slice() return '' and blank the part outright — it is
79
+ // out of range like any other, and appending is the safe reading of "position unknown".
80
+ const clamped = Number.isFinite(offset) ? Math.min(Math.max(offset, 0), length) : length
81
+ if (clamped !== offset && process.env['NODE_ENV'] !== 'production') {
82
+ console.warn(
83
+ `[basalt] mergePart: text offset ${offset} out of range [0, ${length}] — clamped to ${clamped}`,
84
+ )
85
+ }
86
+ return clamped
48
87
  }
49
88
 
50
89
  export function mergePart<TPart extends PartLike>(parts: readonly TPart[], next: TPart): TPart[] {
51
90
  const index = parts.findIndex((part) => part.id === next.id)
52
- if (index === -1) return [...parts, next]
91
+ const existing = index === -1 ? undefined : (parts[index] as TPart)
53
92
 
54
- const existing = parts[index] as TPart
55
- const spliceable = existing.type === next.type && isTextLikeType(next.type)
93
+ // A first insertion of a text-like part is spliced too — against an empty existing text — so the
94
+ // offset contract is one path with one set of rules: same clamp, same dev warning, and the same
95
+ // stored result (`'' + next.text`) an unconditional append would have produced.
96
+ const spliceable =
97
+ isTextLikeType(next.type) && (existing === undefined || existing.type === next.type)
56
98
 
57
99
  const merged: TPart = spliceable
58
100
  ? ({
59
101
  ...next,
60
- text: spliceText(existing as unknown as TextLike, next as unknown as TextLike),
102
+ text: spliceText(
103
+ existing === undefined ? '' : (existing as unknown as TextLike).text,
104
+ next as unknown as TextLike,
105
+ ),
61
106
  } as TPart)
62
107
  : next
63
108
 
109
+ if (existing === undefined) return [...parts, merged]
110
+
64
111
  const out = parts.slice()
65
112
  out[index] = merged
66
113
  return out