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
@@ -17,7 +17,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'
17
17
  import { useAgentThreadRuns } from './use-agent-thread-runs'
18
18
  import type { AgentThread, ThreadsStore } from './thread'
19
19
  import type { AgentTransport } from './transport'
20
- import type { AgentPart } from './parts'
20
+ import type { AgentPart, AgentPartDraft } from './parts'
21
21
  import type { AgentOutcome, OutcomeResolver } from './outcome'
22
22
 
23
23
  // ── test-only ThreadsStore double — see use-agent-thread-runs.wedge.test.tsx for the rationale ──
@@ -100,6 +100,11 @@ function createTestThreadsStore(
100
100
  threads = []
101
101
  activeId = null
102
102
  },
103
+ // Always-hydrated, never-erroring — mirrors the localStorage-backed store's real values
104
+ // (see ThreadsStore.hydrated/.error doc comments); this double is a synchronous in-memory
105
+ // stand-in with no async load path to fail.
106
+ hydrated: true,
107
+ error: undefined,
103
108
  }
104
109
  }
105
110
 
@@ -130,7 +135,7 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
130
135
  const transport: AgentTransport<AgentPart, string> = {
131
136
  async *stream(input) {
132
137
  await gates.get(input)?.promise
133
- yield { type: 'text', text: `done:${input}` }
138
+ yield { id: 'p1', type: 'text', text: `done:${input}` }
134
139
  },
135
140
  }
136
141
 
@@ -173,7 +178,7 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
173
178
  async *stream(input) {
174
179
  calls.push(input)
175
180
  await gate.promise
176
- yield { type: 'text', text: 'ok' }
181
+ yield { id: 'p1', type: 'text', text: 'ok' }
177
182
  },
178
183
  }
179
184
 
@@ -218,7 +223,7 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
218
223
  const transport: AgentTransport<AgentPart, string> = {
219
224
  async *stream() {
220
225
  await gate.promise
221
- yield { type: 'text', text: 'hi' }
226
+ yield { id: 'p1', type: 'text', text: 'hi' }
222
227
  },
223
228
  }
224
229
 
@@ -265,7 +270,7 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
265
270
  async *stream() {
266
271
  throw new Error('boom')
267
272
  // eslint-disable-next-line no-unreachable
268
- yield { type: 'text', text: 'unreachable' }
273
+ yield { id: 'p1', type: 'text', text: 'unreachable' }
269
274
  },
270
275
  }
271
276
 
@@ -294,7 +299,7 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
294
299
  const transport: AgentTransport<AgentPart, string> = {
295
300
  async *stream(input) {
296
301
  calls.push(input)
297
- yield { type: 'text', text: 'x' }
302
+ yield { id: 'p1', type: 'text', text: 'x' }
298
303
  },
299
304
  }
300
305
 
@@ -316,7 +321,7 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
316
321
  const transport: AgentTransport<AgentPart, string> = {
317
322
  async *stream(input) {
318
323
  calls.push(input)
319
- yield { type: 'text', text: `done:${input}` }
324
+ yield { id: 'p1', type: 'text', text: `done:${input}` }
320
325
  },
321
326
  }
322
327
 
@@ -364,7 +369,7 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
364
369
 
365
370
  const transport: AgentTransport<AgentPart, string> = {
366
371
  async *stream() {
367
- yield { type: 'text', text: 'hi' }
372
+ yield { id: 'p1', type: 'text', text: 'hi' }
368
373
  },
369
374
  }
370
375
 
@@ -403,11 +408,15 @@ describe('useAgentThreadRuns — stop()/stopAll() lifecycle', () => {
403
408
  const threadId = store.create()
404
409
 
405
410
  const gate = deferred<void>()
411
+ // Yields id-less drafts on purpose — the assertion below exists specifically to prove
412
+ // withPartIds stamps the missing id as `${threadId}#0`, so the cast documents that the type
413
+ // says AgentPart (identified) while this transport deliberately produces what the real
414
+ // contract is AgentPartDraft (a transport is always permitted to omit id).
406
415
  const transport: AgentTransport<AgentPart, string> = {
407
416
  async *stream() {
408
- yield { type: 'text', text: 'partial' }
417
+ yield { type: 'text', text: 'partial' } as AgentPartDraft as AgentPart
409
418
  await gate.promise
410
- yield { type: 'text', text: 'never arrives' }
419
+ yield { type: 'text', text: 'never arrives' } as AgentPartDraft as AgentPart
411
420
  },
412
421
  }
413
422
 
@@ -456,7 +465,7 @@ describe('useAgentThreadRuns — stop()/stopAll() lifecycle', () => {
456
465
 
457
466
  const transport: AgentTransport<AgentPart, string> = {
458
467
  async *stream() {
459
- yield { type: 'text', text: 'hi' }
468
+ yield { id: 'p1', type: 'text', text: 'hi' }
460
469
  },
461
470
  }
462
471
 
@@ -498,7 +507,7 @@ describe('useAgentThreadRuns — stop()/stopAll() lifecycle', () => {
498
507
 
499
508
  const transport: AgentTransport<AgentPart, string> = {
500
509
  async *stream() {
501
- yield { type: 'text', text: 'x' }
510
+ yield { id: 'p1', type: 'text', text: 'x' }
502
511
  },
503
512
  }
504
513
 
@@ -672,10 +681,10 @@ describe('useAgentThreadRuns — stop()/stopAll() lifecycle', () => {
672
681
  const transport: AgentTransport<AgentPart, string> = {
673
682
  async *stream(input) {
674
683
  if (input === 'a') {
675
- yield { type: 'text', text: 'partial-a' }
684
+ yield { id: 'p1', type: 'text', text: 'partial-a' }
676
685
  await gateA.promise
677
686
  } else {
678
- yield { type: 'text', text: 'partial-b' }
687
+ yield { id: 'p1', type: 'text', text: 'partial-b' }
679
688
  await gateB.promise
680
689
  }
681
690
  },
@@ -748,17 +757,21 @@ describe('useAgentThreadRuns — F1 regression: id-less drafts within one run',
748
757
  const store = createTestThreadsStore()
749
758
  const threadId = store.create()
750
759
 
760
+ // Yields id-less drafts on purpose — the whole point of this regression is that TWO drafts
761
+ // (of different types, so a real bug wouldn't be "same id" but "no id at all") must not
762
+ // collide via `undefined === undefined`. The cast documents that the type says AgentPart
763
+ // (identified) while a transport is always permitted to omit id (AgentPartDraft).
751
764
  const transport: AgentTransport<AgentPart, string> = {
752
765
  async *stream() {
753
- yield { type: 'text', text: 'Hi' }
766
+ yield { type: 'text', text: 'Hi' } as AgentPartDraft as AgentPart
754
767
  yield {
755
768
  type: 'tool',
756
769
  toolCallId: 'c1',
757
770
  toolName: 'search',
758
771
  state: 'input-available',
759
772
  input: { q: 'x' },
760
- }
761
- yield { type: 'text', text: 'Bye' }
773
+ } as AgentPartDraft as AgentPart
774
+ yield { type: 'text', text: 'Bye' } as AgentPartDraft as AgentPart
762
775
  },
763
776
  }
764
777
 
@@ -779,3 +792,329 @@ describe('useAgentThreadRuns — F1 regression: id-less drafts within one run',
779
792
  expect(assistantMessage?.parts.map((part) => part.type)).toEqual(['text', 'tool', 'text'])
780
793
  })
781
794
  })
795
+
796
+ describe('useAgentThreadRuns — message-id minting under degraded crypto', () => {
797
+ test('start() mints distinct message ids via crypto.getRandomValues when randomUUID is unavailable', async () => {
798
+ const store = createTestThreadsStore()
799
+ const threadA = store.create()
800
+ const threadB = store.create()
801
+
802
+ const transport: AgentTransport<AgentPart, string> = {
803
+ async *stream(input) {
804
+ yield { id: 'p1', type: 'text', text: `done:${input}` }
805
+ },
806
+ }
807
+
808
+ const originalCrypto = globalThis.crypto
809
+ Object.defineProperty(globalThis, 'crypto', {
810
+ value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
811
+ configurable: true,
812
+ })
813
+ try {
814
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
815
+
816
+ act(() => {
817
+ result.current.start(threadA, 'input-a')
818
+ result.current.start(threadB, 'input-b')
819
+ })
820
+
821
+ await waitFor(() => {
822
+ expect(store.threads.find((t) => t.id === threadA)?.status).toBe('done')
823
+ expect(store.threads.find((t) => t.id === threadB)?.status).toBe('done')
824
+ })
825
+
826
+ const allMessageIds = store.threads.flatMap((t) => t.messages.map((m) => m.id))
827
+ expect(allMessageIds.length).toBeGreaterThan(0)
828
+ expect(new Set(allMessageIds).size).toBe(allMessageIds.length)
829
+ } finally {
830
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
831
+ }
832
+ })
833
+
834
+ test('start() THROWS on a host with no usable crypto at all, rather than silently minting a colliding message id', () => {
835
+ const store = createTestThreadsStore()
836
+ const threadId = store.create()
837
+
838
+ const transport: AgentTransport<AgentPart, string> = {
839
+ async *stream() {
840
+ yield { id: 'p1', type: 'text', text: 'unreachable' }
841
+ },
842
+ }
843
+
844
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
845
+
846
+ const originalCrypto = globalThis.crypto
847
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
848
+ try {
849
+ expect(() => {
850
+ act(() => {
851
+ result.current.start(threadId, 'hi')
852
+ })
853
+ }).toThrow(/idempotency key/)
854
+ } finally {
855
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
856
+ }
857
+
858
+ // The deliberate divergence documented on mintMessageId: throwing means the write never
859
+ // happened at all (no half-appended message, no orphaned run entry) rather than silently
860
+ // dropping content behind a colliding id.
861
+ expect(store.threads.find((t) => t.id === threadId)?.messages).toHaveLength(0)
862
+ expect(result.current.runs.has(threadId)).toBe(false)
863
+ })
864
+ })
865
+
866
+ // A ThreadsStore double that delegates everything to a real test store EXCEPT appendMessage,
867
+ // which throws for messages matching `shouldThrow` — lets a test simulate consumer-adapter code
868
+ // (appendMessage) throwing without touching the shared createTestThreadsStore factory used by
869
+ // every other test in this file.
870
+ function wrapStoreWithThrowingAppend(
871
+ store: ThreadsStore<AgentPart>,
872
+ shouldThrow: (message: AgentThread<AgentPart>['messages'][number]) => boolean,
873
+ ): ThreadsStore<AgentPart> {
874
+ return {
875
+ get threads() {
876
+ return store.threads
877
+ },
878
+ get activeId() {
879
+ return store.activeId
880
+ },
881
+ select: store.select,
882
+ create: store.create,
883
+ appendMessage(id, message) {
884
+ if (shouldThrow(message)) {
885
+ throw new Error('consumer appendMessage boom')
886
+ }
887
+ store.appendMessage(id, message)
888
+ },
889
+ setOutcome: store.setOutcome,
890
+ setStatus: store.setStatus,
891
+ setResumeToken: store.setResumeToken,
892
+ markRead: store.markRead,
893
+ remove: store.remove,
894
+ clear: store.clear,
895
+ hydrated: store.hydrated,
896
+ error: store.error,
897
+ }
898
+ }
899
+
900
+ /** As above, but for the settle half of `finalizeStop` — a consumer store whose `setStatus` throws
901
+ * (a remote-backed adapter rejecting a thread removed mid-stream is the realistic shape). Narrowed
902
+ * by `shouldThrow` because the hook ALSO calls `setStatus` from its mount-time orphan sweep, which
903
+ * is a different call site with a different (non-wedge) failure mode. */
904
+ function wrapStoreWithThrowingSetStatus(
905
+ store: ThreadsStore<AgentPart>,
906
+ shouldThrow: (status: AgentThread<AgentPart>['status']) => boolean,
907
+ ): ThreadsStore<AgentPart> {
908
+ return {
909
+ get threads() {
910
+ return store.threads
911
+ },
912
+ get activeId() {
913
+ return store.activeId
914
+ },
915
+ select: store.select,
916
+ create: store.create,
917
+ appendMessage: store.appendMessage,
918
+ setOutcome: store.setOutcome,
919
+ setStatus(id, status) {
920
+ if (shouldThrow(status)) throw new Error('consumer setStatus boom')
921
+ store.setStatus(id, status)
922
+ },
923
+ setResumeToken: store.setResumeToken,
924
+ markRead: store.markRead,
925
+ remove: store.remove,
926
+ clear: store.clear,
927
+ hydrated: store.hydrated,
928
+ error: store.error,
929
+ }
930
+ }
931
+
932
+ describe('useAgentThreadRuns — finalizeStop must reach a terminal state no matter what', () => {
933
+ test('stop() on a host with no usable crypto still reaches a terminal status and clears the run entry (was: wedged at "streaming" forever)', async () => {
934
+ const store = createTestThreadsStore()
935
+ const threadId = store.create()
936
+
937
+ const gate = deferred<void>()
938
+ const transport: AgentTransport<AgentPart, string> = {
939
+ async *stream() {
940
+ yield { id: 'p1', type: 'text', text: 'partial' }
941
+ await gate.promise
942
+ yield { id: 'p2', type: 'text', text: 'never arrives' }
943
+ },
944
+ }
945
+
946
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
947
+
948
+ act(() => {
949
+ result.current.start(threadId, 'hi')
950
+ })
951
+
952
+ await waitFor(() => {
953
+ expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
954
+ })
955
+
956
+ const originalCrypto = globalThis.crypto
957
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
958
+ try {
959
+ act(() => {
960
+ result.current.stop(threadId)
961
+ })
962
+
963
+ const thread = store.threads.find((t) => t.id === threadId)
964
+ // Before the fix: mintMessageId's throw escaped finalizeStop entirely, so setStatus/setRuns
965
+ // never ran and this stayed 'streaming' forever.
966
+ expect(thread?.status).toBe('error')
967
+ // The stopped message never minted an id, so appendMessage was never even attempted —
968
+ // only the original user message is present.
969
+ expect(thread?.messages).toHaveLength(1)
970
+ expect(result.current.runs.has(threadId)).toBe(false)
971
+
972
+ // A second stop() must not be the user's only recourse — and it isn't: controllersRef
973
+ // already had this threadId deleted before finalizeStop ever ran, so this is an ordinary
974
+ // no-op, not a retry of the failed append.
975
+ act(() => {
976
+ result.current.stop(threadId)
977
+ })
978
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('error')
979
+ } finally {
980
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
981
+ }
982
+
983
+ gate.resolve(undefined)
984
+ })
985
+
986
+ test("stop() reaches a terminal status and clears the run entry even when the consumer's appendMessage throws (was: wedged at 'streaming' forever)", async () => {
987
+ const baseStore = createTestThreadsStore()
988
+ const threadId = baseStore.create()
989
+ const store = wrapStoreWithThrowingAppend(
990
+ baseStore,
991
+ (message) => message.role === 'assistant' && message.finish === 'stopped',
992
+ )
993
+
994
+ const gate = deferred<void>()
995
+ const transport: AgentTransport<AgentPart, string> = {
996
+ async *stream() {
997
+ yield { id: 'p1', type: 'text', text: 'partial' }
998
+ await gate.promise
999
+ yield { id: 'p2', type: 'text', text: 'never arrives' }
1000
+ },
1001
+ }
1002
+
1003
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
1004
+
1005
+ act(() => {
1006
+ result.current.start(threadId, 'hi')
1007
+ })
1008
+
1009
+ await waitFor(() => {
1010
+ expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
1011
+ })
1012
+
1013
+ act(() => {
1014
+ result.current.stop(threadId)
1015
+ })
1016
+
1017
+ const thread = store.threads.find((t) => t.id === threadId)
1018
+ expect(thread?.status).toBe('error')
1019
+ // Only the user message — the consumer's appendMessage rejected the stopped message.
1020
+ expect(thread?.messages).toHaveLength(1)
1021
+ expect(result.current.runs.has(threadId)).toBe(false)
1022
+
1023
+ // A second stop() remains a true no-op — not the user's only recourse.
1024
+ act(() => {
1025
+ result.current.stop(threadId)
1026
+ })
1027
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('error')
1028
+
1029
+ gate.resolve(undefined)
1030
+ })
1031
+
1032
+ test("stop() clears the hook's own run entry even when the consumer's setStatus throws (the second half of the same wedge)", async () => {
1033
+ const baseStore = createTestThreadsStore()
1034
+ const threadId = baseStore.create()
1035
+ const store = wrapStoreWithThrowingSetStatus(baseStore, (status) => status === 'done')
1036
+
1037
+ const gate = deferred<void>()
1038
+ const transport: AgentTransport<AgentPart, string> = {
1039
+ async *stream() {
1040
+ yield { id: 'p1', type: 'text', text: 'partial' }
1041
+ await gate.promise
1042
+ yield { id: 'p2', type: 'text', text: 'never arrives' }
1043
+ },
1044
+ }
1045
+
1046
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
1047
+
1048
+ act(() => {
1049
+ result.current.start(threadId, 'hi')
1050
+ })
1051
+
1052
+ await waitFor(() => {
1053
+ expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
1054
+ })
1055
+
1056
+ act(() => {
1057
+ result.current.stop(threadId)
1058
+ })
1059
+
1060
+ // The store's own status is whatever the throwing adapter left it as — unrecoverable from
1061
+ // here. What IS this hook's to guarantee is that `runs` no longer reports an in-flight turn:
1062
+ // that entry is the hook's own state and nothing later can ever clear it (stop() already
1063
+ // deleted the thread from controllersRef), so a throw above the teardown stranded it forever.
1064
+ expect(result.current.runs.has(threadId)).toBe(false)
1065
+ // The partial content still landed — the append runs before the settle.
1066
+ expect(store.threads.find((t) => t.id === threadId)?.messages).toHaveLength(2)
1067
+
1068
+ act(() => {
1069
+ result.current.stop(threadId)
1070
+ })
1071
+ expect(result.current.runs.has(threadId)).toBe(false)
1072
+
1073
+ gate.resolve(undefined)
1074
+ })
1075
+
1076
+ test('stop() still succeeds via crypto.getRandomValues when randomUUID is unavailable (rung 2 — the rung that matters in practice)', async () => {
1077
+ const store = createTestThreadsStore()
1078
+ const threadId = store.create()
1079
+
1080
+ const gate = deferred<void>()
1081
+ const transport: AgentTransport<AgentPart, string> = {
1082
+ async *stream() {
1083
+ yield { id: 'p1', type: 'text', text: 'partial' }
1084
+ await gate.promise
1085
+ yield { id: 'p2', type: 'text', text: 'never arrives' }
1086
+ },
1087
+ }
1088
+
1089
+ const originalCrypto = globalThis.crypto
1090
+ Object.defineProperty(globalThis, 'crypto', {
1091
+ value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
1092
+ configurable: true,
1093
+ })
1094
+ try {
1095
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
1096
+
1097
+ act(() => {
1098
+ result.current.start(threadId, 'hi')
1099
+ })
1100
+
1101
+ await waitFor(() => {
1102
+ expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
1103
+ })
1104
+
1105
+ act(() => {
1106
+ result.current.stop(threadId)
1107
+ })
1108
+
1109
+ const thread = store.threads.find((t) => t.id === threadId)
1110
+ expect(thread?.status).toBe('done')
1111
+ expect(thread?.messages).toHaveLength(2)
1112
+ expect(thread?.messages[1]?.finish).toBe('stopped')
1113
+ expect(result.current.runs.has(threadId)).toBe(false)
1114
+ } finally {
1115
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
1116
+ }
1117
+
1118
+ gate.resolve(undefined)
1119
+ })
1120
+ })