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
@@ -29,7 +29,7 @@
29
29
  * api.chat.post({ body: { message: input }, fetch: { signal } }),
30
30
  * )
31
31
  */
32
- import type { AgentPart } from './parts'
32
+ import type { AgentPart, AgentPartDraft } from './parts'
33
33
 
34
34
  // ── AgentTransport ────────────────────────────────────────────────────────────
35
35
 
@@ -37,6 +37,11 @@ import type { AgentPart } from './parts'
37
37
  * The injected transport seam. `stream(input, signal)` returns an async generator of parts.
38
38
  * Implement this interface for any backend — Eden, fetch-based SSE, AI SDK, or a mock.
39
39
  *
40
+ * The default `TPart` is `AgentPartDraft` (not `AgentPart`): transports yield DRAFTS — `id`
41
+ * optional — and the consuming hooks (or `withPartIds`) normalize a draft into a fully-identified
42
+ * part before it is ever accumulated. Pass an explicit `AgentPart` generic when a transport mints
43
+ * its own ids directly (as `aiSdkTransport` does).
44
+ *
40
45
  * @example
41
46
  * // Minimal mock transport for tests / playground:
42
47
  * const mockTransport: AgentTransport = {
@@ -45,12 +50,48 @@ import type { AgentPart } from './parts'
45
50
  * },
46
51
  * }
47
52
  */
48
- export type AgentTransport<TPart = AgentPart, TInput = string> = {
53
+ export type AgentTransport<TPart = AgentPartDraft, TInput = string> = {
49
54
  stream: (input: TInput, signal?: AbortSignal) => AsyncGenerator<TPart>
50
55
  /** Optional: resume a previously-started run from its resumeToken (see StartPart). Transports that don't support resumption simply omit this. */
51
56
  resume?: (resumeToken: string, signal?: AbortSignal) => AsyncGenerator<TPart>
52
57
  }
53
58
 
59
+ // ── ResumableAgentTransport ───────────────────────────────────────────────────
60
+
61
+ /**
62
+ * A transport whose replay is safe to run over an already-rendered turn. Implementing `resume`
63
+ * alone is no longer enough: `idempotentReplay: true` is a literal-typed assertion that every
64
+ * text/reasoning part this transport emits carries an authoritative `offset`, so mergePart
65
+ * rewrites instead of appending.
66
+ */
67
+ export type ResumableAgentTransport<TPart = AgentPartDraft, TInput = string> = AgentTransport<
68
+ TPart,
69
+ TInput
70
+ > & {
71
+ readonly resume: (resumeToken: string, signal?: AbortSignal) => AsyncGenerator<TPart>
72
+ readonly idempotentReplay: true
73
+ }
74
+
75
+ /**
76
+ * Type guard: true when `t` both implements `resume` AND has explicitly asserted
77
+ * `idempotentReplay: true`. A transport with `resume` but no `idempotentReplay` is NOT resumable
78
+ * — the mount-time reconcile in `useAgentThreadRuns` falls back to `'interrupted'` for it rather
79
+ * than risk duplicating already-rendered content.
80
+ *
81
+ * @example
82
+ * if (isResumable(transport)) {
83
+ * for await (const part of transport.resume(resumeToken)) { ... }
84
+ * }
85
+ */
86
+ export function isResumable<TPart, TInput>(
87
+ t: AgentTransport<TPart, TInput>,
88
+ ): t is ResumableAgentTransport<TPart, TInput> {
89
+ return (
90
+ typeof t.resume === 'function' &&
91
+ (t as Partial<ResumableAgentTransport<TPart, TInput>>).idempotentReplay === true
92
+ )
93
+ }
94
+
54
95
  // ── edenTransport ─────────────────────────────────────────────────────────────
55
96
 
56
97
  /**
@@ -70,6 +111,12 @@ export type AgentTransport<TPart = AgentPart, TInput = string> = {
70
111
  * object has NO `resume` key at all (not `resume: undefined`) — consumers that don't wire a
71
112
  * resumable backend get a transport that behaves exactly as before.
72
113
  *
114
+ * `resumeCall` alone does NOT make the transport resumable per `isResumable` — pass
115
+ * `{ idempotentReplay: true }` as the third argument to opt in EXPLICITLY once the server-side
116
+ * `resumeCall` route is verified to re-emit every text/reasoning delta with an authoritative
117
+ * `offset` (never a bare append). This is an opt-in, not an inference: a `resumeCall` whose deltas
118
+ * aren't offset-accurate would double-render on replay if treated as resumable.
119
+ *
73
120
  * @example
74
121
  * import { edenTransport, type AgentPart } from 'basalt-ui/agent'
75
122
  *
@@ -78,6 +125,7 @@ export type AgentTransport<TPart = AgentPart, TInput = string> = {
78
125
  * const transport = edenTransport<AgentPart>(
79
126
  * (input, signal) => api.chat.post({ body: { message: input }, fetch: { signal } }),
80
127
  * (resumeToken, signal) => api.chat.resume.post({ body: { resumeToken }, fetch: { signal } }),
128
+ * { idempotentReplay: true },
81
129
  * )
82
130
  *
83
131
  * // Wire into useAgentStream:
@@ -92,23 +140,27 @@ export function edenTransport<TPart = AgentPart, TInput = string>(
92
140
  resumeToken: string,
93
141
  signal?: AbortSignal,
94
142
  ) => Promise<{ data: AsyncGenerator<TPart> | null; error: unknown }>,
95
- ): AgentTransport<TPart, TInput> {
96
- return {
97
- async *stream(input: TInput, signal?: AbortSignal): AsyncGenerator<TPart> {
98
- const { data, error } = await call(input, signal)
99
- if (error) throw error
100
- if (data === null) return
101
- yield* data
102
- },
103
- ...(resumeCall !== undefined
104
- ? {
105
- async *resume(resumeToken: string, signal?: AbortSignal): AsyncGenerator<TPart> {
106
- const { data, error } = await resumeCall(resumeToken, signal)
107
- if (error) throw error
108
- if (data === null) return
109
- yield* data
110
- },
111
- }
112
- : {}),
143
+ options?: { readonly idempotentReplay?: true },
144
+ ): AgentTransport<TPart, TInput> | ResumableAgentTransport<TPart, TInput> {
145
+ const stream = async function* (input: TInput, signal?: AbortSignal): AsyncGenerator<TPart> {
146
+ const { data, error } = await call(input, signal)
147
+ if (error) throw error
148
+ if (data === null) return
149
+ yield* data
150
+ }
151
+
152
+ if (resumeCall === undefined) return { stream }
153
+
154
+ const resume = async function* (
155
+ resumeToken: string,
156
+ signal?: AbortSignal,
157
+ ): AsyncGenerator<TPart> {
158
+ const { data, error } = await resumeCall(resumeToken, signal)
159
+ if (error) throw error
160
+ if (data === null) return
161
+ yield* data
113
162
  }
163
+
164
+ if (options?.idempotentReplay !== true) return { stream, resume }
165
+ return { stream, resume, idempotentReplay: true }
114
166
  }
@@ -11,7 +11,7 @@ import { StrictMode, useEffect } from 'react'
11
11
  import { act, render, renderHook, waitFor } from '@testing-library/react'
12
12
  import { useAgentStream } from './use-agent-stream'
13
13
  import type { AgentTransport } from './transport'
14
- import type { AgentPart } from './parts'
14
+ import type { AgentPart, AgentPartDraft } from './parts'
15
15
 
16
16
  function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
17
17
  let resolve!: (value: T) => void
@@ -26,10 +26,10 @@ describe('useAgentStream', () => {
26
26
  const gate = deferred<void>()
27
27
  const transport: AgentTransport<AgentPart, string> = {
28
28
  async *stream() {
29
- yield { type: 'text', text: 'a' }
29
+ yield { id: 'p1', type: 'text', text: 'a' }
30
30
  await gate.promise
31
31
  // Must never be observed — the consumer's guard should discard this once aborted.
32
- yield { type: 'text', text: 'b' }
32
+ yield { id: 'p2', type: 'text', text: 'b' }
33
33
  },
34
34
  }
35
35
 
@@ -39,7 +39,7 @@ describe('useAgentStream', () => {
39
39
  void result.current.send('hi')
40
40
  })
41
41
  await waitFor(() => {
42
- expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
42
+ expect(result.current.parts).toEqual([{ id: 'p1', type: 'text', text: 'a' }])
43
43
  })
44
44
  expect(result.current.status).toBe('streaming')
45
45
 
@@ -53,7 +53,7 @@ describe('useAgentStream', () => {
53
53
  await waitFor(() => {
54
54
  expect(result.current.status).toBe('done')
55
55
  })
56
- expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
56
+ expect(result.current.parts).toEqual([{ id: 'p1', type: 'text', text: 'a' }])
57
57
  })
58
58
 
59
59
  test("a second send() supersedes an in-flight one — the first stream's later parts are discarded", async () => {
@@ -61,12 +61,12 @@ describe('useAgentStream', () => {
61
61
  const transport: AgentTransport<AgentPart, string> = {
62
62
  async *stream(input) {
63
63
  if (input === 'first') {
64
- yield { type: 'text', text: 'first-1' }
64
+ yield { id: 'f1', type: 'text', text: 'first-1' }
65
65
  await gateFirst.promise
66
66
  // Must never be observed — 'second' has already superseded this controller.
67
- yield { type: 'text', text: 'first-2' }
67
+ yield { id: 'f2', type: 'text', text: 'first-2' }
68
68
  } else {
69
- yield { type: 'text', text: 'second-1' }
69
+ yield { id: 's1', type: 'text', text: 'second-1' }
70
70
  }
71
71
  },
72
72
  }
@@ -77,14 +77,14 @@ describe('useAgentStream', () => {
77
77
  void result.current.send('first')
78
78
  })
79
79
  await waitFor(() => {
80
- expect(result.current.parts).toEqual([{ type: 'text', text: 'first-1' }])
80
+ expect(result.current.parts).toEqual([{ id: 'f1', type: 'text', text: 'first-1' }])
81
81
  })
82
82
 
83
83
  act(() => {
84
84
  void result.current.send('second')
85
85
  })
86
86
  await waitFor(() => {
87
- expect(result.current.parts).toEqual([{ type: 'text', text: 'second-1' }])
87
+ expect(result.current.parts).toEqual([{ id: 's1', type: 'text', text: 'second-1' }])
88
88
  })
89
89
  expect(result.current.status).toBe('done')
90
90
 
@@ -92,7 +92,41 @@ describe('useAgentStream', () => {
92
92
  await waitFor(() => {
93
93
  expect(result.current.status).toBe('done')
94
94
  })
95
- expect(result.current.parts).toEqual([{ type: 'text', text: 'second-1' }])
95
+ expect(result.current.parts).toEqual([{ id: 's1', type: 'text', text: 'second-1' }])
96
+ })
97
+
98
+ test('F1 regression: id-less drafts of different types within one send() do not destroy each other', async () => {
99
+ // Yields id-less drafts on purpose — the whole point of this regression is that TWO drafts
100
+ // (of different types, so a real bug wouldn't be "same id" but "no id at all") must not
101
+ // collide via `undefined === undefined`. The cast documents that the type says AgentPart
102
+ // (identified) while a transport is always permitted to omit id (AgentPartDraft).
103
+ const transport: AgentTransport<AgentPart, string> = {
104
+ async *stream() {
105
+ yield { type: 'text', text: 'Hi' } as AgentPartDraft as AgentPart
106
+ yield {
107
+ type: 'tool',
108
+ toolCallId: 'c1',
109
+ toolName: 'search',
110
+ state: 'input-available',
111
+ input: { q: 'x' },
112
+ } as AgentPartDraft as AgentPart
113
+ yield { type: 'text', text: 'Bye' } as AgentPartDraft as AgentPart
114
+ },
115
+ }
116
+
117
+ const { result } = renderHook(() => useAgentStream({ transport }))
118
+
119
+ await act(async () => {
120
+ await result.current.send('hi')
121
+ })
122
+
123
+ // Pre-fix: mergePart matched all three drafts on `undefined === undefined`, so text→tool→text
124
+ // collapsed to a single (wrong-typed) entry instead of three. withPartIds stamping a distinct
125
+ // id per draft is what keeps them apart.
126
+ expect(result.current.parts).toHaveLength(3)
127
+ expect(result.current.parts.map((part) => part.type)).toEqual(['text', 'tool', 'text'])
128
+ const ids = result.current.parts.map((part) => (part as { id: string }).id)
129
+ expect(new Set(ids).size).toBe(3)
96
130
  })
97
131
 
98
132
  test('a thrown (non-abort) error sets status "error" and captures the error', async () => {
@@ -101,7 +135,7 @@ describe('useAgentStream', () => {
101
135
  async *stream() {
102
136
  throw boom
103
137
  // eslint-disable-next-line no-unreachable
104
- yield { type: 'text', text: 'unreachable' }
138
+ yield { id: 'unreachable', type: 'text', text: 'unreachable' }
105
139
  },
106
140
  }
107
141
 
@@ -119,10 +153,10 @@ describe('useAgentStream', () => {
119
153
  const gate = deferred<void>()
120
154
  const transport: AgentTransport<AgentPart, string> = {
121
155
  async *stream(_input, signal) {
122
- yield { type: 'text', text: 'a' }
156
+ yield { id: 'p1', type: 'text', text: 'a' }
123
157
  await gate.promise
124
158
  if (signal?.aborted) throw new DOMException('The operation was aborted.', 'AbortError')
125
- yield { type: 'text', text: 'b' }
159
+ yield { id: 'p2', type: 'text', text: 'b' }
126
160
  },
127
161
  }
128
162
 
@@ -132,7 +166,7 @@ describe('useAgentStream', () => {
132
166
  void result.current.send('hi')
133
167
  })
134
168
  await waitFor(() => {
135
- expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
169
+ expect(result.current.parts).toEqual([{ id: 'p1', type: 'text', text: 'a' }])
136
170
  })
137
171
 
138
172
  // stop() aborts the signal AND sets 'done' synchronously.
@@ -155,7 +189,7 @@ describe('useAgentStream', () => {
155
189
  const gate = deferred<void>()
156
190
  const transport: AgentTransport<AgentPart, string> = {
157
191
  async *stream(_input, signal) {
158
- yield { type: 'text', text: 'a' }
192
+ yield { id: 'p1', type: 'text', text: 'a' }
159
193
  await gate.promise
160
194
  observedAbortedOnResume = signal?.aborted ?? false
161
195
  },
@@ -167,7 +201,7 @@ describe('useAgentStream', () => {
167
201
  void result.current.send('hi')
168
202
  })
169
203
  await waitFor(() => {
170
- expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
204
+ expect(result.current.parts).toEqual([{ id: 'p1', type: 'text', text: 'a' }])
171
205
  })
172
206
 
173
207
  unmount()
@@ -197,7 +231,7 @@ describe('useAgentStream', () => {
197
231
  const transport: AgentTransport<AgentPart, string> = {
198
232
  async *stream() {
199
233
  streamCalls++
200
- yield { type: 'text', text: `call-${streamCalls}` }
234
+ yield { id: `call-${streamCalls}`, type: 'text', text: `call-${streamCalls}` }
201
235
  },
202
236
  }
203
237
 
@@ -226,6 +260,6 @@ describe('useAgentStream', () => {
226
260
 
227
261
  expect(streamCalls).toBe(2)
228
262
  // Only the SECOND (surviving) call's part made it into final state — the first is discarded.
229
- expect(capturedParts).toEqual([{ type: 'text', text: 'call-2' }])
263
+ expect(capturedParts).toEqual([{ id: 'call-2', type: 'text', text: 'call-2' }])
230
264
  })
231
265
  })
@@ -27,7 +27,10 @@
27
27
  * }
28
28
  */
29
29
  import { useCallback, useEffect, useRef, useState } from 'react'
30
+ import { withPartIds } from './id'
30
31
  import { isStartPart } from './parts'
32
+ import { mergePart } from './merge'
33
+ import type { PartLike } from './merge'
31
34
  import type { AgentPart } from './parts'
32
35
  import type { AgentTransport } from './transport'
33
36
 
@@ -67,7 +70,7 @@ export type UseAgentStreamReturn<TPart, TInput> = {
67
70
  * // parts accumulates as the stream arrives
68
71
  * // status: 'idle' → 'streaming' → 'done' (or 'error')
69
72
  */
70
- export function useAgentStream<TPart = AgentPart, TInput = string>({
73
+ export function useAgentStream<TPart extends PartLike = AgentPart, TInput = string>({
71
74
  transport,
72
75
  }: {
73
76
  transport: AgentTransport<TPart, TInput>
@@ -100,15 +103,22 @@ export function useAgentStream<TPart = AgentPart, TInput = string>({
100
103
  setError(undefined)
101
104
  setStatus('streaming')
102
105
 
106
+ // Per-send namespace: this hook has no thread/session id to key off, and each send() call
107
+ // fully resets `parts` anyway, so a fresh id per call is enough to keep two id-less drafts
108
+ // ARRIVING WITHIN THE SAME SEND from colliding on `undefined === undefined` in mergePart (the
109
+ // F1 bug — text→tool→text destroying each other). Not a replay mechanism — this hook has no
110
+ // resume() path — see withPartIds' own doc for why it can't be one anyway.
111
+ const runId = crypto.randomUUID()
112
+
103
113
  try {
104
- for await (const part of transport.stream(input, controller.signal)) {
114
+ for await (const part of withPartIds(runId, transport.stream(input, controller.signal))) {
105
115
  // Guard: if a newer send() has superseded this stream, stop updating state.
106
116
  if (controllerRef.current !== controller) return
107
117
  if (controller.signal.aborted) break
108
118
  // StartPart is a no-op signal (run id + resume token), not renderable content — this
109
119
  // hook has no persistence layer to resume across, so just skip it.
110
120
  if (isStartPart(part)) continue
111
- setParts((prev) => [...prev, part])
121
+ setParts((prev) => mergePart(prev, part))
112
122
  }
113
123
  // Only mark done if this stream is still the current one and wasn't aborted.
114
124
  if (controllerRef.current === controller && !controller.signal.aborted) setStatus('done')
@@ -0,0 +1,138 @@
1
+ /**
2
+ * useAgentThreadRuns — foreign-part round trip (B2 convergence fix).
3
+ *
4
+ * The open part registry (./foreign) only serves its headline purpose — a transport streaming an
5
+ * app-specific part type (`data-toolProgress`, `data-chart`, …) reaching `ThreadTranscript` — if a
6
+ * ForeignPart can flow all the way through this hook's mergePart accumulator: transport →
7
+ * consumeAndFinalize → runs.get(id).parts → the persisted assistant ChatMessage. This file proves
8
+ * that path end to end with `useAgentThreadRuns<TranscriptPart>`, which was a tsc error before
9
+ * mergePart's constraint was widened from `AgentPart` to the structural `PartLike`.
10
+ */
11
+ import { describe, expect, test } from 'bun:test'
12
+ import { act, renderHook, waitFor } from '@testing-library/react'
13
+ import { useAgentThreadRuns } from './use-agent-thread-runs'
14
+ import type { AgentThread, ThreadsStore } from './thread'
15
+ import type { AgentTransport } from './transport'
16
+ import type { TranscriptPart } from './foreign'
17
+ import type { AgentOutcome, OutcomeResolver } from './outcome'
18
+
19
+ // ── test-only ThreadsStore double, typed over TranscriptPart ─────────────────────────────────────
20
+ function createTestThreadsStore(): ThreadsStore<TranscriptPart> {
21
+ let threads: AgentThread<TranscriptPart>[] = []
22
+ let activeId: string | null = null
23
+
24
+ return {
25
+ get threads() {
26
+ return threads
27
+ },
28
+ get activeId() {
29
+ return activeId
30
+ },
31
+ select(id) {
32
+ activeId = id
33
+ },
34
+ create(createOpts) {
35
+ const id = crypto.randomUUID()
36
+ const now = Date.now()
37
+ const thread: AgentThread<TranscriptPart> = {
38
+ id,
39
+ messages: [],
40
+ outcome: null,
41
+ status: 'pending',
42
+ read: false,
43
+ createdAt: now,
44
+ updatedAt: now,
45
+ ...(createOpts?.meta !== undefined ? { meta: createOpts.meta } : {}),
46
+ }
47
+ threads = [thread, ...threads]
48
+ return id
49
+ },
50
+ appendMessage(id, message) {
51
+ threads = threads.map((thread) =>
52
+ thread.id === id
53
+ ? { ...thread, messages: [...thread.messages, message], updatedAt: Date.now() }
54
+ : thread,
55
+ )
56
+ },
57
+ setOutcome(id, outcome) {
58
+ threads = threads.map((thread) =>
59
+ thread.id === id ? { ...thread, outcome, updatedAt: Date.now() } : thread,
60
+ )
61
+ },
62
+ setStatus(id, status) {
63
+ threads = threads.map((thread) =>
64
+ thread.id === id ? { ...thread, status, updatedAt: Date.now() } : thread,
65
+ )
66
+ },
67
+ setResumeToken(id, token) {
68
+ threads = threads.map((thread) => {
69
+ if (thread.id !== id) return thread
70
+ const { resumeToken: _resumeToken, ...rest } = thread
71
+ return {
72
+ ...rest,
73
+ ...(token !== undefined ? { resumeToken: token } : {}),
74
+ updatedAt: Date.now(),
75
+ }
76
+ })
77
+ },
78
+ markRead(id) {
79
+ threads = threads.map((thread) => (thread.id === id ? { ...thread, read: true } : thread))
80
+ },
81
+ remove(id) {
82
+ threads = threads.filter((thread) => thread.id !== id)
83
+ if (activeId === id) activeId = null
84
+ },
85
+ clear() {
86
+ threads = []
87
+ activeId = null
88
+ },
89
+ // Always-hydrated, never-erroring — mirrors the localStorage-backed store's real values
90
+ // (see ThreadsStore.hydrated/.error doc comments); this double is a synchronous in-memory
91
+ // stand-in with no async load path to fail.
92
+ hydrated: true,
93
+ error: undefined,
94
+ }
95
+ }
96
+
97
+ const resolveOutcome: OutcomeResolver<TranscriptPart> = (): AgentOutcome => ({
98
+ title: 'title',
99
+ summary: 'summary',
100
+ status: 'done',
101
+ })
102
+
103
+ describe('useAgentThreadRuns<TranscriptPart> — foreign part round trip', () => {
104
+ test('a foreign part survives accumulation and a replay replaces rather than duplicates', async () => {
105
+ const store = createTestThreadsStore()
106
+ const threadId = store.create()
107
+
108
+ const transport: AgentTransport<TranscriptPart, string> = {
109
+ async *stream() {
110
+ yield { id: 'progress-1', type: 'data-toolProgress', message: 'searching' }
111
+ // Same id, second delta — must REPLACE the first entry, not duplicate it.
112
+ yield { id: 'progress-1', type: 'data-toolProgress', message: 'done' }
113
+ yield { id: 'text-1', type: 'text', text: 'Found it.' }
114
+ },
115
+ }
116
+
117
+ const { result } = renderHook(() =>
118
+ useAgentThreadRuns<TranscriptPart>({ transport, store, resolveOutcome }),
119
+ )
120
+
121
+ act(() => {
122
+ result.current.start(threadId, 'go find it')
123
+ })
124
+
125
+ await waitFor(() => {
126
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
127
+ })
128
+
129
+ const assistantMessage = store.threads
130
+ .find((t) => t.id === threadId)
131
+ ?.messages.find((m) => m.role === 'assistant')
132
+
133
+ expect(assistantMessage?.parts).toEqual([
134
+ { id: 'progress-1', type: 'data-toolProgress', message: 'done' },
135
+ { id: 'text-1', type: 'text', text: 'Found it.' },
136
+ ])
137
+ })
138
+ })
@@ -4,8 +4,10 @@
4
4
  * The mount-reconcile effect (empty deps — a one-time sweep of whatever the store held when this
5
5
  * manager first attaches) treats any persisted 'pending' or 'streaming' thread with no live
6
6
  * controller as orphaned, and either attempts `transport.resume(resumeToken, signal)` (when the
7
- * transport supports it, a resumeToken is present, and there's a last user message to attribute
8
- * the resumed turn to) or falls back straight to 'interrupted'.
7
+ * transport is RESUMABLE per `isResumable` — `resume` AND the literal `idempotentReplay: true`
8
+ * assertion, a resumeToken is present, and there's a last user message to attribute the resumed
9
+ * turn to) or falls back straight to 'interrupted'. `resume` alone is NOT enough post-B2 — see the
10
+ * dedicated "resume() present but no idempotentReplay" test below.
9
11
  *
10
12
  * The F3 wedge (StrictMode/`<Activity>` re-running this same effect on the SAME fiber) has its
11
13
  * own file: `use-agent-thread-runs.wedge.test.tsx`.
@@ -14,7 +16,7 @@ import { describe, expect, test } from 'bun:test'
14
16
  import { renderHook, waitFor } from '@testing-library/react'
15
17
  import { useAgentThreadRuns } from './use-agent-thread-runs'
16
18
  import type { AgentThread, ThreadsStore } from './thread'
17
- import type { AgentTransport } from './transport'
19
+ import type { AgentTransport, ResumableAgentTransport } from './transport'
18
20
  import type { AgentPart } from './parts'
19
21
  import type { ChatMessage } from './history'
20
22
  import type { AgentOutcome } from './outcome'
@@ -88,6 +90,11 @@ function createTestThreadsStore(initial: AgentThread<AgentPart>[]): ThreadsStore
88
90
  threads = []
89
91
  activeId = null
90
92
  },
93
+ // Always-hydrated, never-erroring — mirrors the localStorage-backed store's real values
94
+ // (see ThreadsStore.hydrated/.error doc comments); this double is a synchronous in-memory
95
+ // stand-in with no async load path to fail.
96
+ hydrated: true,
97
+ error: undefined,
91
98
  }
92
99
  }
93
100
 
@@ -98,7 +105,7 @@ function makeStreamingThread(
98
105
  const userMessage: ChatMessage<AgentPart> = {
99
106
  id: crypto.randomUUID(),
100
107
  role: 'user',
101
- parts: [{ type: 'text', text: 'hello' }],
108
+ parts: [{ id: 'seed-user-part', type: 'text', text: 'hello' }],
102
109
  createdAt: now,
103
110
  }
104
111
  return {
@@ -140,7 +147,7 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
140
147
  const userMessage: ChatMessage<AgentPart> = {
141
148
  id: crypto.randomUUID(),
142
149
  role: 'user',
143
- parts: [{ type: 'text', text: 'hello' }],
150
+ parts: [{ id: 'seed-user-part', type: 'text', text: 'hello' }],
144
151
  createdAt: now,
145
152
  }
146
153
  const thread: AgentThread<AgentPart> = {
@@ -155,12 +162,13 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
155
162
  const store = createTestThreadsStore([thread])
156
163
 
157
164
  let resumeCalls = 0
158
- const transport: AgentTransport<AgentPart, string> = {
165
+ const transport: ResumableAgentTransport<AgentPart, string> = {
159
166
  async *stream() {},
160
167
  async *resume() {
161
168
  resumeCalls++
162
- yield { type: 'text', text: 'resumed' }
169
+ yield { id: 'p1', type: 'text', text: 'resumed' }
163
170
  },
171
+ idempotentReplay: true as const,
164
172
  }
165
173
 
166
174
  renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
@@ -175,13 +183,36 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
175
183
  const thread = makeStreamingThread({ messages: [] })
176
184
  const store = createTestThreadsStore([thread])
177
185
 
186
+ let resumeCalls = 0
187
+ const transport: ResumableAgentTransport<AgentPart, string> = {
188
+ async *stream() {},
189
+ async *resume() {
190
+ resumeCalls++
191
+ yield { id: 'p1', type: 'text', text: 'resumed' }
192
+ },
193
+ idempotentReplay: true as const,
194
+ }
195
+
196
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
197
+
198
+ await waitFor(() => {
199
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
200
+ })
201
+ expect(resumeCalls).toBe(0)
202
+ })
203
+
204
+ test('resume() present but WITHOUT idempotentReplay → still falls back to interrupted (resume alone is not enough post-B2)', async () => {
205
+ const thread = makeStreamingThread()
206
+ const store = createTestThreadsStore([thread])
207
+
178
208
  let resumeCalls = 0
179
209
  const transport: AgentTransport<AgentPart, string> = {
180
210
  async *stream() {},
181
211
  async *resume() {
182
212
  resumeCalls++
183
- yield { type: 'text', text: 'resumed' }
213
+ yield { id: 'p1', type: 'text', text: 'resumed' }
184
214
  },
215
+ // deliberately NO idempotentReplay: true
185
216
  }
186
217
 
187
218
  renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
@@ -197,12 +228,13 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
197
228
  const store = createTestThreadsStore([thread])
198
229
 
199
230
  const resumeArgs: string[] = []
200
- const transport: AgentTransport<AgentPart, string> = {
231
+ const transport: ResumableAgentTransport<AgentPart, string> = {
201
232
  async *stream() {},
202
233
  async *resume(token) {
203
234
  resumeArgs.push(token)
204
- yield { type: 'text', text: 'resumed' }
235
+ yield { id: 'p1', type: 'text', text: 'resumed' }
205
236
  },
237
+ idempotentReplay: true as const,
206
238
  }
207
239
 
208
240
  renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
@@ -217,12 +249,13 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
217
249
  const thread = makeStreamingThread()
218
250
  const store = createTestThreadsStore([thread])
219
251
 
220
- const transport: AgentTransport<AgentPart, string> = {
252
+ const transport = {
221
253
  async *stream() {},
222
254
  // oxlint-disable-next-line require-yield
223
255
  async *resume() {
224
256
  throw new Error('resume failed')
225
257
  },
258
+ idempotentReplay: true as const,
226
259
  }
227
260
 
228
261
  renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))