basalt-ui 1.10.0 → 1.12.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 (273) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +36 -27
  4. package/agent/rules/basalt-content.md +16 -3
  5. package/dist/agent/adapter.d.ts +146 -0
  6. package/dist/agent/adapter.d.ts.map +1 -0
  7. package/dist/agent/adapter.js +473 -0
  8. package/dist/agent/adapter.test.d.ts +2 -0
  9. package/dist/agent/adapter.test.d.ts.map +1 -0
  10. package/dist/agent/ai-sdk-transport.d.ts +3 -52
  11. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  12. package/dist/agent/ai-sdk-transport.js +108 -24
  13. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  14. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  15. package/dist/agent/coalesce.d.ts +42 -0
  16. package/dist/agent/coalesce.d.ts.map +1 -0
  17. package/dist/agent/coalesce.js +137 -0
  18. package/dist/agent/coalesce.test.d.ts +2 -0
  19. package/dist/agent/coalesce.test.d.ts.map +1 -0
  20. package/dist/agent/foreign.d.ts +97 -0
  21. package/dist/agent/foreign.d.ts.map +1 -0
  22. package/dist/agent/foreign.js +18 -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/history.d.ts +2 -0
  26. package/dist/agent/history.d.ts.map +1 -1
  27. package/dist/agent/id.d.ts +65 -0
  28. package/dist/agent/id.d.ts.map +1 -0
  29. package/dist/agent/id.js +30 -0
  30. package/dist/agent/id.test.d.ts +2 -0
  31. package/dist/agent/id.test.d.ts.map +1 -0
  32. package/dist/agent/index.d.ts +11 -4
  33. package/dist/agent/index.d.ts.map +1 -1
  34. package/dist/agent/index.js +18 -3
  35. package/dist/agent/merge.d.ts +8 -0
  36. package/dist/agent/merge.d.ts.map +1 -0
  37. package/dist/agent/merge.js +35 -0
  38. package/dist/agent/merge.test.d.ts +2 -0
  39. package/dist/agent/merge.test.d.ts.map +1 -0
  40. package/dist/agent/part-list.d.ts +49 -34
  41. package/dist/agent/part-list.d.ts.map +1 -1
  42. package/dist/agent/part-list.js +53 -7
  43. package/dist/agent/part-list.test.d.ts +2 -0
  44. package/dist/agent/part-list.test.d.ts.map +1 -0
  45. package/dist/agent/parts.d.ts +135 -20
  46. package/dist/agent/parts.d.ts.map +1 -1
  47. package/dist/agent/parts.js +138 -11
  48. package/dist/agent/parts.test.d.ts +2 -0
  49. package/dist/agent/parts.test.d.ts.map +1 -0
  50. package/dist/agent/thread.d.ts +14 -0
  51. package/dist/agent/thread.d.ts.map +1 -1
  52. package/dist/agent/thread.js +8 -2
  53. package/dist/agent/thread.test.d.ts +2 -0
  54. package/dist/agent/thread.test.d.ts.map +1 -0
  55. package/dist/agent/transport.d.ts +39 -3
  56. package/dist/agent/transport.d.ts.map +1 -1
  57. package/dist/agent/transport.js +20 -17
  58. package/dist/agent/transport.test.d.ts +2 -0
  59. package/dist/agent/transport.test.d.ts.map +1 -0
  60. package/dist/agent/use-agent-stream.d.ts +2 -1
  61. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  62. package/dist/agent/use-agent-stream.js +5 -2
  63. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  64. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  65. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  66. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  67. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  68. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  69. package/dist/agent/use-agent-thread-runs.js +75 -23
  70. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  71. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  72. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  73. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  74. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  75. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  76. package/dist/agent-chat/composer.d.ts +130 -8
  77. package/dist/agent-chat/composer.d.ts.map +1 -1
  78. package/dist/agent-chat/composer.js +189 -23
  79. package/dist/agent-chat/composer.test.d.ts +2 -0
  80. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  81. package/dist/agent-chat/index.d.ts +3 -1
  82. package/dist/agent-chat/index.d.ts.map +1 -1
  83. package/dist/agent-chat/index.js +2 -0
  84. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  85. package/dist/agent-chat/thread-detail-panel.js +1 -1
  86. package/dist/agent-chat/thread-message.d.ts +24 -7
  87. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  88. package/dist/agent-chat/thread-message.js +115 -45
  89. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  90. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  91. package/dist/agent-chat/thread-workspace.js +1 -1
  92. package/dist/agent-chat/tool-chip.d.ts +21 -0
  93. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  94. package/dist/agent-chat/tool-chip.js +120 -0
  95. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  96. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  97. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  98. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  99. package/dist/charts/utils/domain.test.d.ts +2 -0
  100. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  101. package/dist/cli/check-theme.test.d.ts +2 -0
  102. package/dist/cli/check-theme.test.d.ts.map +1 -0
  103. package/dist/cli/help.test.d.ts +2 -0
  104. package/dist/cli/help.test.d.ts.map +1 -0
  105. package/dist/cli/index.d.ts.map +1 -1
  106. package/dist/cli/init-sync.test.d.ts +2 -0
  107. package/dist/cli/init-sync.test.d.ts.map +1 -0
  108. package/dist/cli/placement-engine.test.d.ts +2 -0
  109. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  110. package/dist/cli/tarball-parity.test.d.ts +2 -0
  111. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  112. package/dist/cli/template-format.test.d.ts +2 -0
  113. package/dist/cli/template-format.test.d.ts.map +1 -0
  114. package/dist/cli/template-ledger.test.d.ts +2 -0
  115. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  116. package/dist/cli/tokens-css.test.d.ts +2 -0
  117. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  118. package/dist/content/article-actions.test.d.ts +2 -0
  119. package/dist/content/article-actions.test.d.ts.map +1 -0
  120. package/dist/content/article-model.test.d.ts +2 -0
  121. package/dist/content/article-model.test.d.ts.map +1 -0
  122. package/dist/content/block-split.test.d.ts +2 -0
  123. package/dist/content/block-split.test.d.ts.map +1 -0
  124. package/dist/content/fence-block.d.ts +37 -10
  125. package/dist/content/fence-block.d.ts.map +1 -1
  126. package/dist/content/fence-block.js +80 -14
  127. package/dist/content/heading-components.d.ts.map +1 -1
  128. package/dist/content/heading-components.js +7 -4
  129. package/dist/content/heading-components.test.d.ts +2 -0
  130. package/dist/content/heading-components.test.d.ts.map +1 -0
  131. package/dist/content/index.d.ts +6 -3
  132. package/dist/content/index.d.ts.map +1 -1
  133. package/dist/content/index.js +5 -1
  134. package/dist/content/markdown.d.ts +101 -14
  135. package/dist/content/markdown.d.ts.map +1 -1
  136. package/dist/content/markdown.js +141 -28
  137. package/dist/content/markdown.test.d.ts +2 -0
  138. package/dist/content/markdown.test.d.ts.map +1 -0
  139. package/dist/content/prose.module.css.test.d.ts +2 -0
  140. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  141. package/dist/content/sanitize.d.ts +170 -0
  142. package/dist/content/sanitize.d.ts.map +1 -0
  143. package/dist/content/sanitize.js +178 -0
  144. package/dist/content/sanitize.test.d.ts +2 -0
  145. package/dist/content/sanitize.test.d.ts.map +1 -0
  146. package/dist/content/slug.test.d.ts +2 -0
  147. package/dist/content/slug.test.d.ts.map +1 -0
  148. package/dist/dashboard/stat-card.test.d.ts +2 -0
  149. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  150. package/dist/guard/check-source.test.d.ts +2 -0
  151. package/dist/guard/check-source.test.d.ts.map +1 -0
  152. package/dist/guard/guard-hook.test.d.ts +2 -0
  153. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +1 -3
  156. package/dist/index.d.ts +4 -4
  157. package/dist/index.d.ts.map +1 -1
  158. package/dist/index.js +12 -2
  159. package/dist/provider/index.test.d.ts +2 -0
  160. package/dist/provider/index.test.d.ts.map +1 -0
  161. package/dist/provider/lab-theme.test.d.ts +2 -0
  162. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  163. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  164. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  165. package/dist/styles.floor.test.d.ts +2 -0
  166. package/dist/styles.floor.test.d.ts.map +1 -0
  167. package/dist/surfaces.d.ts +5 -5
  168. package/dist/surfaces.d.ts.map +1 -1
  169. package/dist/surfaces.js +13 -4
  170. package/dist/theme/border-coverage.test.d.ts +18 -0
  171. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  172. package/dist/theme/contrast.test.d.ts +2 -0
  173. package/dist/theme/contrast.test.d.ts.map +1 -0
  174. package/dist/theme/density-option.test.d.ts +2 -0
  175. package/dist/theme/density-option.test.d.ts.map +1 -0
  176. package/dist/theme/density-relations.test.d.ts +2 -0
  177. package/dist/theme/density-relations.test.d.ts.map +1 -0
  178. package/dist/theme/derive-theme.test.d.ts +2 -0
  179. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  180. package/dist/theme/fonts-option.test.d.ts +2 -0
  181. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  182. package/dist/theme/radius-option.test.d.ts +2 -0
  183. package/dist/theme/radius-option.test.d.ts.map +1 -0
  184. package/dist/theme/radius.test.d.ts +2 -0
  185. package/dist/theme/radius.test.d.ts.map +1 -0
  186. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  187. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  188. package/dist/theme/spacing.test.d.ts +2 -0
  189. package/dist/theme/spacing.test.d.ts.map +1 -0
  190. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  191. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  192. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  193. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  194. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  195. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  196. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  197. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  198. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  199. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  200. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  201. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  202. package/dist/tokens/density.test.d.ts +2 -0
  203. package/dist/tokens/density.test.d.ts.map +1 -0
  204. package/dist/tokens/derive-radius.test.d.ts +2 -0
  205. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  206. package/dist/tokens/derive.test.d.ts +2 -0
  207. package/dist/tokens/derive.test.d.ts.map +1 -0
  208. package/dist/tokens/palette.test.d.ts +2 -0
  209. package/dist/tokens/palette.test.d.ts.map +1 -0
  210. package/dist/vite-color-mix.test.d.ts +2 -0
  211. package/dist/vite-color-mix.test.d.ts.map +1 -0
  212. package/dist/vite.test.d.ts +2 -0
  213. package/dist/vite.test.d.ts.map +1 -0
  214. package/llms.txt +7 -7
  215. package/package.json +6 -1
  216. package/src/agent/adapter.test.ts +1403 -0
  217. package/src/agent/adapter.ts +887 -0
  218. package/src/agent/ai-sdk-transport.test.ts +380 -55
  219. package/src/agent/ai-sdk-transport.ts +206 -53
  220. package/src/agent/coalesce.test.ts +387 -0
  221. package/src/agent/coalesce.ts +229 -0
  222. package/src/agent/foreign.test.ts +51 -0
  223. package/src/agent/foreign.ts +140 -0
  224. package/src/agent/history.ts +2 -0
  225. package/src/agent/id.test.ts +75 -0
  226. package/src/agent/id.ts +91 -0
  227. package/src/agent/index.ts +34 -3
  228. package/src/agent/merge.test.ts +206 -0
  229. package/src/agent/merge.ts +114 -0
  230. package/src/agent/part-list.test.tsx +112 -0
  231. package/src/agent/part-list.tsx +108 -35
  232. package/src/agent/parts.test.ts +516 -57
  233. package/src/agent/parts.ts +323 -32
  234. package/src/agent/thread.test.ts +75 -2
  235. package/src/agent/thread.ts +21 -1
  236. package/src/agent/transport.test.ts +70 -0
  237. package/src/agent/transport.ts +72 -20
  238. package/src/agent/use-agent-stream.test.tsx +53 -19
  239. package/src/agent/use-agent-stream.ts +13 -3
  240. package/src/agent/use-agent-thread-runs.foreign.test.tsx +138 -0
  241. package/src/agent/use-agent-thread-runs.resume.test.tsx +44 -11
  242. package/src/agent/use-agent-thread-runs.test.tsx +418 -22
  243. package/src/agent/use-agent-thread-runs.ts +170 -26
  244. package/src/agent/use-agent-thread-runs.wedge.test.tsx +15 -7
  245. package/src/agent-chat/composer.test.tsx +924 -0
  246. package/src/agent-chat/composer.tsx +521 -52
  247. package/src/agent-chat/index.ts +5 -1
  248. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  249. package/src/agent-chat/thread-message.test.tsx +351 -0
  250. package/src/agent-chat/thread-message.tsx +262 -64
  251. package/src/agent-chat/thread-workspace.tsx +1 -1
  252. package/src/agent-chat/tool-chip.test.tsx +139 -0
  253. package/src/agent-chat/tool-chip.tsx +219 -0
  254. package/src/cli/index.ts +0 -1
  255. package/src/cli/template-format.test.ts +2 -2
  256. package/src/content/fence-block.tsx +242 -15
  257. package/src/content/heading-components.test.tsx +65 -0
  258. package/src/content/heading-components.tsx +26 -5
  259. package/src/content/index.ts +19 -3
  260. package/src/content/markdown.test.tsx +603 -0
  261. package/src/content/markdown.tsx +355 -35
  262. package/src/content/sanitize.test.ts +659 -0
  263. package/src/content/sanitize.ts +510 -0
  264. package/src/dashboard/stat-card.test.tsx +6 -1
  265. package/src/guard/check-source.test.ts +8 -4
  266. package/src/guard/index.ts +1 -3
  267. package/src/index.ts +15 -1
  268. package/src/surfaces.ts +13 -4
  269. package/src/theme/contrast.test.ts +1 -4
  270. package/src/theme/density-relations.test.ts +4 -2
  271. package/src/theme/fonts-option.test.ts +7 -1
  272. package/src/theme/shadow-surfaces.test.ts +12 -6
  273. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -1,10 +1,12 @@
1
1
  /**
2
- * useAgentThreadRuns — concurrency, busy-thread no-op, finalize ordering, failure status, retry.
2
+ * useAgentThreadRuns — concurrency, busy-thread no-op, finalize ordering, failure status, retry,
3
+ * and (as of B2) the stop()/stopAll() lifecycle (partial-turn preservation, the idle no-op
4
+ * guard, and the resolveOutcome-race fix).
3
5
  *
4
- * Scope: the multi-thread run manager AS THE PUBLIC API EXISTS TODAY (B1). No mergePart/part ids/
6
+ * Scope: the multi-thread run manager AS THE PUBLIC API EXISTS TODAY. No mergePart/part ids/
5
7
  * offsets, no seven-state ToolCallPart, no ResumableAgentTransport/isResumable/idempotentReplay
6
- * beyond the plain optional `resume` already on AgentTransport, no stop-preserves-partial-turn, no
7
- * ThreadsStoreAdapter — all later releases, not exercised here.
8
+ * beyond the plain optional `resume` already on AgentTransport, no ThreadsStoreAdapter — later
9
+ * releases, not exercised here.
8
10
  *
9
11
  * The mount-reconcile/resume path (StartPart handling, orphaned-thread resumption on mount) has
10
12
  * its own file, `use-agent-thread-runs.resume.test.tsx`; the F3 wedge has its own file,
@@ -15,7 +17,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'
15
17
  import { useAgentThreadRuns } from './use-agent-thread-runs'
16
18
  import type { AgentThread, ThreadsStore } from './thread'
17
19
  import type { AgentTransport } from './transport'
18
- import type { AgentPart } from './parts'
20
+ import type { AgentPart, AgentPartDraft } from './parts'
19
21
  import type { AgentOutcome, OutcomeResolver } from './outcome'
20
22
 
21
23
  // ── test-only ThreadsStore double — see use-agent-thread-runs.wedge.test.tsx for the rationale ──
@@ -98,6 +100,11 @@ function createTestThreadsStore(
98
100
  threads = []
99
101
  activeId = null
100
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,
101
108
  }
102
109
  }
103
110
 
@@ -128,7 +135,7 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
128
135
  const transport: AgentTransport<AgentPart, string> = {
129
136
  async *stream(input) {
130
137
  await gates.get(input)?.promise
131
- yield { type: 'text', text: `done:${input}` }
138
+ yield { id: 'p1', type: 'text', text: `done:${input}` }
132
139
  },
133
140
  }
134
141
 
@@ -171,7 +178,7 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
171
178
  async *stream(input) {
172
179
  calls.push(input)
173
180
  await gate.promise
174
- yield { type: 'text', text: 'ok' }
181
+ yield { id: 'p1', type: 'text', text: 'ok' }
175
182
  },
176
183
  }
177
184
 
@@ -216,7 +223,7 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
216
223
  const transport: AgentTransport<AgentPart, string> = {
217
224
  async *stream() {
218
225
  await gate.promise
219
- yield { type: 'text', text: 'hi' }
226
+ yield { id: 'p1', type: 'text', text: 'hi' }
220
227
  },
221
228
  }
222
229
 
@@ -263,7 +270,7 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
263
270
  async *stream() {
264
271
  throw new Error('boom')
265
272
  // eslint-disable-next-line no-unreachable
266
- yield { type: 'text', text: 'unreachable' }
273
+ yield { id: 'p1', type: 'text', text: 'unreachable' }
267
274
  },
268
275
  }
269
276
 
@@ -292,7 +299,7 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
292
299
  const transport: AgentTransport<AgentPart, string> = {
293
300
  async *stream(input) {
294
301
  calls.push(input)
295
- yield { type: 'text', text: 'x' }
302
+ yield { id: 'p1', type: 'text', text: 'x' }
296
303
  },
297
304
  }
298
305
 
@@ -314,7 +321,7 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
314
321
  const transport: AgentTransport<AgentPart, string> = {
315
322
  async *stream(input) {
316
323
  calls.push(input)
317
- yield { type: 'text', text: `done:${input}` }
324
+ yield { id: 'p1', type: 'text', text: `done:${input}` }
318
325
  },
319
326
  }
320
327
 
@@ -345,15 +352,12 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
345
352
  expect(store.threads.find((t) => t.id === threadId)?.messages).toHaveLength(4)
346
353
  })
347
354
 
348
- // Flagged for scheduling, not fixed here (out of this task's scope — see the brief's "while you
349
- // are in there, report, do not fix" note (i)): consumeAndFinalize's success path awaits
350
- // resolveOutcome (:219) and then writes setOutcome/setStatus (:221-222) WITHOUT re-checking the
351
- // supersede/abort guards it checked right before the await (:205-207). A slow resolveOutcome
352
- // racing a stop() call is reachable: stop() sets the thread to 'done' immediately, but once the
353
- // delayed resolveOutcome finally resolves, the unconditional setStatus(threadId, outcome.status)
354
- // silently overwrites that 'done' with whatever the outcome resolved to. Verified failing against
355
- // the current implementation before being marked .skip here.
356
- test.skip('stop() during a slow resolveOutcome must not be clobbered by the outcome settling later', async () => {
355
+ // Pins the fix for a race in consumeAndFinalize's success path: it awaits resolveOutcome and
356
+ // then writes setOutcome/setStatus, and now RE-CHECKS the supersede/abort guards after that
357
+ // await (not just before it) before performing those writes. Without the recheck, a slow
358
+ // resolveOutcome racing a stop() call would clobber stop()'s synchronous 'done' with whatever
359
+ // the outcome resolved to once it finally settled.
360
+ test('stop() during a slow resolveOutcome must not be clobbered by the outcome settling later', async () => {
357
361
  const store = createTestThreadsStore()
358
362
  const threadId = store.create()
359
363
 
@@ -365,7 +369,7 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
365
369
 
366
370
  const transport: AgentTransport<AgentPart, string> = {
367
371
  async *stream() {
368
- yield { type: 'text', text: 'hi' }
372
+ yield { id: 'p1', type: 'text', text: 'hi' }
369
373
  },
370
374
  }
371
375
 
@@ -391,8 +395,400 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
391
395
  releaseOutcome?.()
392
396
 
393
397
  await waitFor(() => {
394
- // Currently fails: the late setStatus overwrites 'done' with 'attention'.
398
+ // The late resolveOutcome settling must not resurrect the thread past the 'done' stop()
399
+ // already committed.
395
400
  expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
396
401
  })
397
402
  })
398
403
  })
404
+
405
+ describe('useAgentThreadRuns — stop()/stopAll() lifecycle', () => {
406
+ test('stop() mid-stream persists the accumulated parts as a finish: "stopped" assistant message', async () => {
407
+ const store = createTestThreadsStore()
408
+ const threadId = store.create()
409
+
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).
415
+ const transport: AgentTransport<AgentPart, string> = {
416
+ async *stream() {
417
+ yield { type: 'text', text: 'partial' } as AgentPartDraft as AgentPart
418
+ await gate.promise
419
+ yield { type: 'text', text: 'never arrives' } as AgentPartDraft as AgentPart
420
+ },
421
+ }
422
+
423
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
424
+
425
+ act(() => {
426
+ result.current.start(threadId, 'hi')
427
+ })
428
+
429
+ await waitFor(() => {
430
+ expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
431
+ })
432
+
433
+ act(() => {
434
+ result.current.stop(threadId)
435
+ })
436
+
437
+ const thread = store.threads.find((t) => t.id === threadId)
438
+ expect(thread?.status).toBe('done')
439
+ expect(thread?.messages).toHaveLength(2)
440
+ const assistantMessage = thread?.messages[1]
441
+ expect(assistantMessage?.role).toBe('assistant')
442
+ expect(assistantMessage?.finish).toBe('stopped')
443
+ // withPartIds stamps the missing id as `${threadId}#0` — the first (and only) id-less part
444
+ // this run's generator yielded.
445
+ expect(assistantMessage?.parts).toEqual([
446
+ { id: `${threadId}#0`, type: 'text', text: 'partial' },
447
+ ])
448
+ // The run entry is torn down synchronously — no lingering 'streaming' indicator.
449
+ expect(result.current.runs.has(threadId)).toBe(false)
450
+
451
+ // Release the still-suspended (aborted, guard-caught) generator so it doesn't leak into the
452
+ // next test.
453
+ gate.resolve(undefined)
454
+ })
455
+
456
+ test('stop() does not double-append when the stream already fully arrived before stop() was called', async () => {
457
+ const store = createTestThreadsStore()
458
+ const threadId = store.create()
459
+
460
+ let releaseOutcome: (() => void) | undefined
461
+ const slowResolveOutcome: OutcomeResolver<AgentPart> = () =>
462
+ new Promise<AgentOutcome>((resolve) => {
463
+ releaseOutcome = () => resolve({ title: 't', summary: 's', status: 'done' })
464
+ })
465
+
466
+ const transport: AgentTransport<AgentPart, string> = {
467
+ async *stream() {
468
+ yield { id: 'p1', type: 'text', text: 'hi' }
469
+ },
470
+ }
471
+
472
+ const { result } = renderHook(() =>
473
+ useAgentThreadRuns({ transport, store, resolveOutcome: slowResolveOutcome }),
474
+ )
475
+
476
+ act(() => {
477
+ result.current.start(threadId, 'hi')
478
+ })
479
+
480
+ // Wait until consumeAndFinalize's own appendMessage has already landed (it happens BEFORE
481
+ // its await on resolveOutcome) — this is the narrow window stop() can race without ever
482
+ // being able to observe "not yet appended" (see finalizeStop's doc comment).
483
+ await waitFor(() => {
484
+ expect(store.threads.find((t) => t.id === threadId)?.messages.length).toBe(2)
485
+ })
486
+
487
+ act(() => {
488
+ result.current.stop(threadId)
489
+ })
490
+
491
+ // Still exactly 2 messages (user + the ORIGINAL 'complete' assistant message) — stop() must
492
+ // recognize the terminal message already landed and skip its own append rather than
493
+ // duplicating it as a second 'stopped' message.
494
+ const thread = store.threads.find((t) => t.id === threadId)
495
+ expect(thread?.messages).toHaveLength(2)
496
+ expect(thread?.messages[1]?.finish).toBe('complete')
497
+
498
+ releaseOutcome?.()
499
+ })
500
+
501
+ test('stop() is a true no-op when threadId has no in-flight run', () => {
502
+ const store = createTestThreadsStore()
503
+ // No store.create() here on purpose — mirrors the "retry() is a no-op" test above: isolates
504
+ // stop()'s own "nothing in flight" guard from the mount-reconcile effect's unrelated handling
505
+ // of a freshly-created 'pending' thread.
506
+ const threadId = crypto.randomUUID()
507
+
508
+ const transport: AgentTransport<AgentPart, string> = {
509
+ async *stream() {
510
+ yield { id: 'p1', type: 'text', text: 'x' }
511
+ },
512
+ }
513
+
514
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
515
+
516
+ act(() => {
517
+ result.current.stop(threadId)
518
+ })
519
+
520
+ expect(store.threads).toEqual([])
521
+ })
522
+
523
+ // Pins the fix for the reference-equality hole: runsRef mirrors `runs` only as of the last
524
+ // FLUSHED render (assigned during render, `:~355`), so a stop() landing inside the narrow
525
+ // window between consumeAndFinalize's post-loop appendMessage and its `await resolveOutcome`
526
+ // resolving could observe a STALE parts array (one delta behind) via runsRef — defeating the
527
+ // old `lastMessage.parts === parts` guard and double-appending. This test lands stop()
528
+ // deterministically inside that exact window (fired synchronously from inside the injected
529
+ // resolveOutcome, itself called synchronously right after appendMessage, before any of the
530
+ // hook's pending state updates can flush — flushing needs a macrotask, and nothing here crosses
531
+ // one) rather than relying on timing luck, so it is a reliable regression guard for the fixed
532
+ // marker-based mechanism, not just a one-off repro.
533
+ test("stop() fired synchronously from inside resolveOutcome (before the final delta's render flushes) must not double-append", async () => {
534
+ const store = createTestThreadsStore()
535
+ const threadId = store.create()
536
+
537
+ const gate = deferred<void>()
538
+ const transport: AgentTransport<AgentPart, string> = {
539
+ async *stream() {
540
+ // Distinct ids: mergePart addresses by id, and two parts sharing the (undefined) id would
541
+ // splice into ONE entry instead of accumulating two — this test needs the SECOND delta to
542
+ // produce a genuinely NEW array entry (and therefore a new `parts` array reference) to
543
+ // exercise the race at all.
544
+ yield { id: 'p1', type: 'text', text: 'first' }
545
+ await gate.promise
546
+ yield { id: 'p2', type: 'text', text: 'second' }
547
+ },
548
+ }
549
+
550
+ const hookRef: { stop?: (id: string) => void } = {}
551
+ let resolveCalls = 0
552
+ const raceResolveOutcome: OutcomeResolver<AgentPart> = async () => {
553
+ resolveCalls += 1
554
+ // Fire stop() from inside the FIRST resolveOutcome call — synchronously, before this
555
+ // function even returns its promise. Everything from the second delta's setRuns dispatch to
556
+ // this point runs purely through microtasks (async-generator machinery + this direct call),
557
+ // so no macrotask has elapsed and React's scheduler cannot have flushed that dispatch into
558
+ // runsRef yet — this reliably lands stop() inside the race window.
559
+ if (resolveCalls === 1) {
560
+ hookRef.stop?.(threadId)
561
+ }
562
+ return { title: 't', summary: 's', status: 'done' }
563
+ }
564
+
565
+ const { result } = renderHook(() =>
566
+ useAgentThreadRuns({ transport, store, resolveOutcome: raceResolveOutcome }),
567
+ )
568
+ hookRef.stop = result.current.stop
569
+
570
+ act(() => {
571
+ result.current.start(threadId, 'hi')
572
+ })
573
+
574
+ // Let the FIRST delta's render flush — runsRef now mirrors a non-empty parts array
575
+ // (['first']), which is what makes the eventual mismatch observable rather than vacuously
576
+ // guarded by parts.length === 0.
577
+ await waitFor(() => {
578
+ expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
579
+ })
580
+
581
+ // Resolve the gate outside act() on purpose: this must not synchronously flush React state —
582
+ // the whole point is to let the second delta, the loop exit, and appendMessage run purely as
583
+ // microtask continuations, with stop() firing from inside resolveOutcome before any render for
584
+ // the second delta occurs.
585
+ gate.resolve(undefined)
586
+
587
+ await waitFor(() => {
588
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
589
+ })
590
+
591
+ const thread = store.threads.find((t) => t.id === threadId)
592
+ const assistantMessages = thread?.messages.filter((m) => m.role === 'assistant') ?? []
593
+ expect(assistantMessages).toHaveLength(1)
594
+ // The surviving message must be the ORIGINAL 'complete' one with the full (['first','second'])
595
+ // content — not a truncated 'stopped' message built from the stale ['first']-only parts.
596
+ expect(assistantMessages[0]?.finish).toBe('complete')
597
+ expect(assistantMessages[0]?.parts).toEqual([
598
+ { id: 'p1', type: 'text', text: 'first' },
599
+ { id: 'p2', type: 'text', text: 'second' },
600
+ ])
601
+ })
602
+
603
+ // Pins the flip side of the marker fix: the marker must be cleared once a run concludes, or a
604
+ // stale entry from an EARLIER finished run on the same thread would wrongly suppress a
605
+ // legitimate append on a LATER run — a worse bug than the double-append this mechanism exists to
606
+ // prevent.
607
+ test('a stale marker from an earlier finished run must not suppress a legitimate later stop() append', async () => {
608
+ const store = createTestThreadsStore()
609
+ const threadId = store.create()
610
+
611
+ const inputs: string[] = []
612
+ let gate = deferred<void>()
613
+ const transport: AgentTransport<AgentPart, string> = {
614
+ async *stream(input) {
615
+ inputs.push(input)
616
+ // Distinct ids: two id-less parts sharing the (undefined) id would silently splice into
617
+ // ONE entry (the F1 bug) — turn 1's own "did these two deltas stay separate?" assertion
618
+ // below is invisible otherwise, since it only checked assistant MESSAGE count, not parts.
619
+ yield { id: `${input}-partial`, type: 'text', text: `${input}-partial` }
620
+ await gate.promise
621
+ yield { id: `${input}-final`, type: 'text', text: `${input}-final` }
622
+ },
623
+ }
624
+
625
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
626
+
627
+ // Turn 1: runs to full completion (no stop()) so consumeAndFinalize's own success path sets
628
+ // and then clears the marker for this thread.
629
+ act(() => {
630
+ result.current.start(threadId, 'first-turn')
631
+ })
632
+ gate.resolve(undefined)
633
+ await waitFor(() => {
634
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
635
+ })
636
+ const turn1Assistant = store.threads
637
+ .find((t) => t.id === threadId)
638
+ ?.messages.filter((m) => m.role === 'assistant')
639
+ expect(turn1Assistant).toHaveLength(1)
640
+ // The two deltas landed as two SEPARATE parts, not spliced into one.
641
+ expect(turn1Assistant?.[0]?.parts).toEqual([
642
+ { id: 'first-turn-partial', type: 'text', text: 'first-turn-partial' },
643
+ { id: 'first-turn-final', type: 'text', text: 'first-turn-final' },
644
+ ])
645
+
646
+ // Turn 2: a genuinely new run on the SAME thread, stopped mid-stream. If a stale marker from
647
+ // turn 1 leaked, this stop() would be wrongly treated as "already persisted" and silently drop
648
+ // the partial content instead of appending it.
649
+ gate = deferred<void>()
650
+ act(() => {
651
+ result.current.retry(threadId)
652
+ })
653
+ expect(inputs).toEqual(['first-turn', 'first-turn'])
654
+
655
+ await waitFor(() => {
656
+ expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
657
+ })
658
+
659
+ act(() => {
660
+ result.current.stop(threadId)
661
+ })
662
+
663
+ const thread = store.threads.find((t) => t.id === threadId)
664
+ const assistantMessages = thread?.messages.filter((m) => m.role === 'assistant') ?? []
665
+ expect(assistantMessages).toHaveLength(2)
666
+ expect(assistantMessages[1]?.finish).toBe('stopped')
667
+ expect(assistantMessages[1]?.parts).toEqual([
668
+ { id: 'first-turn-partial', type: 'text', text: 'first-turn-partial' },
669
+ ])
670
+
671
+ gate.resolve(undefined)
672
+ })
673
+
674
+ test('stopAll() resolves every aborted thread instead of stranding them in "streaming"', async () => {
675
+ const store = createTestThreadsStore()
676
+ const threadA = store.create()
677
+ const threadB = store.create()
678
+
679
+ const gateA = deferred<void>()
680
+ const gateB = deferred<void>()
681
+ const transport: AgentTransport<AgentPart, string> = {
682
+ async *stream(input) {
683
+ if (input === 'a') {
684
+ yield { id: 'p1', type: 'text', text: 'partial-a' }
685
+ await gateA.promise
686
+ } else {
687
+ yield { id: 'p1', type: 'text', text: 'partial-b' }
688
+ await gateB.promise
689
+ }
690
+ },
691
+ }
692
+
693
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
694
+
695
+ act(() => {
696
+ result.current.start(threadA, 'a')
697
+ result.current.start(threadB, 'b')
698
+ })
699
+
700
+ await waitFor(() => {
701
+ expect(result.current.runs.get(threadA)?.parts).toHaveLength(1)
702
+ expect(result.current.runs.get(threadB)?.parts).toHaveLength(1)
703
+ })
704
+
705
+ act(() => {
706
+ result.current.stopAll()
707
+ })
708
+
709
+ expect(store.threads.find((t) => t.id === threadA)?.status).toBe('done')
710
+ expect(store.threads.find((t) => t.id === threadB)?.status).toBe('done')
711
+ expect(result.current.runs.size).toBe(0)
712
+
713
+ // Release the still-suspended (aborted, guard-caught) generators so they don't leak into the
714
+ // next test.
715
+ gateA.resolve(undefined)
716
+ gateB.resolve(undefined)
717
+ })
718
+
719
+ // F4 regression: stop()'s no-op guard used to read runsRef, which only mirrors `runs` as of the
720
+ // last FLUSHED RENDER. start() then stop() called in the SAME synchronous act() batch — e.g.
721
+ // `onClick={() => { start(id, text); stop(id) }}` — never lets a render land in between, so
722
+ // runsRef was still empty when stop() ran and it wrongly no-op'd: the controller was never
723
+ // aborted, and a transport that only resolves on its AbortSignal firing would strand the thread
724
+ // in 'streaming' forever (this is exactly what `stopAll()`'s own regression test above cannot
725
+ // see — it deliberately `waitFor`s a render to flush before calling stopAll()).
726
+ test('F4 regression: start() then stop() in the same synchronous tick must actually abort, not strand the thread in "streaming"', async () => {
727
+ const store = createTestThreadsStore()
728
+ const threadId = store.create()
729
+
730
+ // Hangs until its AbortSignal fires — never resolves on its own. If stop()'s no-op guard skips
731
+ // the abort, this generator never settles and the thread is stuck 'streaming' forever.
732
+ const transport: AgentTransport<AgentPart, string> = {
733
+ // oxlint-disable-next-line require-yield -- hangs until aborted; never has anything to yield
734
+ async *stream(_input, signal) {
735
+ await new Promise((resolve) => {
736
+ signal?.addEventListener('abort', () => resolve(undefined))
737
+ })
738
+ },
739
+ }
740
+
741
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
742
+
743
+ act(() => {
744
+ result.current.start(threadId, 'hi')
745
+ result.current.stop(threadId)
746
+ })
747
+
748
+ await waitFor(() => {
749
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
750
+ })
751
+ expect(result.current.runs.has(threadId)).toBe(false)
752
+ })
753
+ })
754
+
755
+ describe('useAgentThreadRuns — F1 regression: id-less drafts within one run', () => {
756
+ test('drafts of different types do not destroy each other — mergePart no longer sees undefined === undefined', async () => {
757
+ const store = createTestThreadsStore()
758
+ const threadId = store.create()
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).
764
+ const transport: AgentTransport<AgentPart, string> = {
765
+ async *stream() {
766
+ yield { type: 'text', text: 'Hi' } as AgentPartDraft as AgentPart
767
+ yield {
768
+ type: 'tool',
769
+ toolCallId: 'c1',
770
+ toolName: 'search',
771
+ state: 'input-available',
772
+ input: { q: 'x' },
773
+ } as AgentPartDraft as AgentPart
774
+ yield { type: 'text', text: 'Bye' } as AgentPartDraft as AgentPart
775
+ },
776
+ }
777
+
778
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
779
+
780
+ act(() => {
781
+ result.current.start(threadId, 'go')
782
+ })
783
+
784
+ await waitFor(() => {
785
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
786
+ })
787
+
788
+ const assistantMessage = store.threads
789
+ .find((t) => t.id === threadId)
790
+ ?.messages.find((m) => m.role === 'assistant')
791
+ expect(assistantMessage?.parts).toHaveLength(3)
792
+ expect(assistantMessage?.parts.map((part) => part.type)).toEqual(['text', 'tool', 'text'])
793
+ })
794
+ })