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
@@ -0,0 +1,140 @@
1
+ /**
2
+ * foreign.ts — the open half of the transcript's part-rendering registry.
3
+ *
4
+ * `AgentPart` (`./parts`) is a closed, exhaustively-switched union — adding a variant without
5
+ * updating every consuming switch is a tsc error (`PartList`'s `assertNever` gate). A transport
6
+ * that streams app-specific structured data (a chart spec, a progress ping) needs a part type
7
+ * `AgentPart` will never carry — closing the union around it would defeat the whole point of the
8
+ * gate. This file is the seam: `ForeignPart` describes what basalt does NOT know, `ConsumerPart`
9
+ * resolves the augmented union a consumer registers via `declare module 'basalt-ui'`, and
10
+ * `definePartRenderers` types an exhaustive renderer map against it. See
11
+ * `agent-chat/thread-message.tsx`'s three-step resolution order for how this plugs into
12
+ * `ThreadTranscript`: a consumer renderer wins first, `narrowAgentPart` routes the built-in six to
13
+ * `PartList`'s own exhaustive switch second, and `fallbackRenderer` catches anything left.
14
+ *
15
+ * @example
16
+ * // app-side: src/features/chat/parts.ts
17
+ * type ChartPart = { type: 'data-chart'; id: string; spec: ChartSpec }
18
+ * declare module 'basalt-ui' {
19
+ * interface BasaltRegister { parts: ChartPart }
20
+ * }
21
+ * export const chatRenderers = definePartRenderers({
22
+ * 'data-chart': ({ part, settled }) => (settled ? <Chart spec={part.spec} /> : <ChartSkeleton />),
23
+ * })
24
+ */
25
+ import type { ReactNode } from 'react'
26
+ import type { BasaltRegister } from '../register'
27
+ import type { ChatMessage } from './history'
28
+ import type { AgentPart } from './parts'
29
+
30
+ // ── ForeignPart / TranscriptPart ──────────────────────────────────────────────
31
+
32
+ /** A part basalt does not know. NOT a member of AgentPart — the built-in union stays closed. */
33
+ export type ForeignPart = {
34
+ readonly type: string
35
+ readonly id: string
36
+ readonly [k: string]: unknown
37
+ }
38
+
39
+ /** What a transcript actually holds — basalt's own six variants plus anything a consumer registers. */
40
+ export type TranscriptPart = AgentPart | ForeignPart
41
+
42
+ // ── ConsumerPart ───────────────────────────────────────────────────────────────
43
+ //
44
+ // JUDGMENT POINT — this is a sibling conditional, NOT a reuse of `register.ts`'s `Slot<K,
45
+ // Constraint>`, and deliberately so:
46
+ //
47
+ // `Slot<K, Constraint>` is built for MAP-shaped slots (series, commands, overlays,
48
+ // notifications): its un-augmented fallback is the never-keyed empty object `{}`, which is the
49
+ // correct zero for a MAP (`keyof {}` is `never`, so the derived key-union — e.g. `SeriesKey` — is
50
+ // `never` too). `parts` is UNION-shaped, not map-shaped: there is no key to look anything up by,
51
+ // the type itself IS the legal value set. So the un-augmented fallback here has to be `never`
52
+ // (the empty union), not `{}`.
53
+ //
54
+ // Concretely, reusing `Slot<'parts', ForeignPart>` would hand back `{}` when un-augmented (an
55
+ // empty object does not itself satisfy `ForeignPart`'s `{ type: string; id: string }` shape, but
56
+ // `Slot`'s fallback branch returns it unconditionally regardless of `Constraint`), and then
57
+ // `PartRenderers` below — which reads `ConsumerPart['type']` on the augmented branch and checks
58
+ // `[ConsumerPart] extends [never]` to pick between the two branches — would see a non-`never`,
59
+ // property-less `{}` and take the WRONG branch (or fail to compile the indexed access) even with
60
+ // zero augmentation, exactly the case that must compile cleanly. `Slot`'s fallback isn't a
61
+ // parameter (nor should it be — every existing map slot wants `{}`), so this is a sibling
62
+ // conditional purpose-built for a union slot, not a gap in `Slot` itself.
63
+ /** The consumer's registered foreign-part union, or `never` when un-augmented. */
64
+ export type ConsumerPart = BasaltRegister extends { parts: infer P extends ForeignPart } ? P : never
65
+
66
+ // ── PartRenderers ────────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Un-augmented: a loose string-keyed map (any renderer, any key — there is nothing to check
70
+ * against). Augmented: a MISSING key is a tsc error (`definePartRenderers` requires the full
71
+ * registered union), and each renderer's `part` is narrowed to exactly its variant. Same
72
+ * bound-check mechanism as `SeriesKey` (`register.ts:26-58`), applied to a union instead of a map.
73
+ *
74
+ * NOT caught: a STALE (extra, unregistered) key. `<const T extends Constraint>` inference does
75
+ * not excess-property-check an object-literal argument the way a direct assignment to a fixed
76
+ * target type would (verified against this repo's tsc — see
77
+ * `apps/playground/src/demo/agent-part-registry.type-guard.ts`'s Fixture 3) — an extra key
78
+ * compiles as dead code rather than erroring. Catching it would need an `Exact<T, Shape>`-style
79
+ * wrapper that departs from the `spec: T` "canonical token-factory contract" every other `defineX`
80
+ * in this codebase follows verbatim; that trade was left as a documented gap rather than a one-off
81
+ * factory shape.
82
+ */
83
+ export type PartRenderers = [ConsumerPart] extends [never]
84
+ ? Readonly<Record<string, PartRenderer<ForeignPart>>>
85
+ : { readonly [K in ConsumerPart['type']]: PartRenderer<Extract<ConsumerPart, { type: K }>> }
86
+
87
+ /** Everything a foreign-part renderer needs beyond the part itself. */
88
+ export type PartRenderContext<TPart = ForeignPart> = {
89
+ readonly part: TPart
90
+ readonly messageId: string
91
+ readonly partId: string
92
+ /** false while this part belongs to the in-flight tail of a streaming turn. */
93
+ readonly settled: boolean
94
+ readonly role: ChatMessage['role']
95
+ }
96
+
97
+ export type PartRenderer<TPart = ForeignPart> = (ctx: PartRenderContext<TPart>) => ReactNode
98
+
99
+ /**
100
+ * Canonical const-generic factory (`packages/basalt-ui/CLAUDE.md`, "Canonical token-factory
101
+ * contract") — identity passthrough, exact-keyed return, no builder, no config bag. Mirrors
102
+ * `defineSeries` (`tokens/index.ts:648-650`) and `defineNotifications`
103
+ * (`notifications/define-notifications.ts:112-115`).
104
+ *
105
+ * @example
106
+ * const chatRenderers = definePartRenderers({
107
+ * 'data-chart': ({ part }) => <Chart spec={part.spec} />,
108
+ * })
109
+ */
110
+ export function definePartRenderers<const T extends PartRenderers>(map: T): T {
111
+ return map
112
+ }
113
+
114
+ // ── narrowAgentPart ──────────────────────────────────────────────────────────
115
+
116
+ const AGENT_PART_TYPES: ReadonlySet<string> = new Set([
117
+ 'start',
118
+ 'text',
119
+ 'reasoning',
120
+ 'tool',
121
+ 'source',
122
+ 'error',
123
+ ])
124
+
125
+ /**
126
+ * Returns `part` as an `AgentPart` when its `type` is one of the six built-in variants, else
127
+ * `null`. Deliberately NOT routed through `parseAgentPart`: that function is a VALIDATING parser
128
+ * for untrusted wire data — it checks every field a variant requires (`runId`, `toolCallId`,
129
+ * `approval`, ...) and would reject an already-well-typed in-memory `AgentPart` on shape grounds
130
+ * if it doesn't happen to match the wire representation byte-for-byte. `narrowAgentPart` is the
131
+ * cheap complement: a plain discriminator check on a value already known to be a `TranscriptPart`,
132
+ * used purely to route rendering — it trusts the caller's typing rather than re-validating it.
133
+ *
134
+ * @example
135
+ * const agentPart = narrowAgentPart(part)
136
+ * if (agentPart !== null) return <PartList parts={[agentPart]} />
137
+ */
138
+ export function narrowAgentPart(part: TranscriptPart): AgentPart | null {
139
+ return AGENT_PART_TYPES.has(part.type) ? (part as AgentPart) : null
140
+ }
@@ -43,6 +43,8 @@ export type ChatMessage<TPart = AgentPart> = {
43
43
  readonly role: 'user' | 'assistant'
44
44
  readonly parts: TPart[]
45
45
  readonly createdAt: number
46
+ /** How the turn ended. Absent on user messages and on pre-1.11 persisted assistant messages. */
47
+ readonly finish?: 'complete' | 'stopped' | 'error'
46
48
  }
47
49
 
48
50
  // ── createChatHistoryStore options ────────────────────────────────────────────
@@ -0,0 +1,75 @@
1
+ /**
2
+ * withPartIds — stamps `${runId}#${n}` onto any draft part arriving without an id; idempotent for
3
+ * drafts that already have one (untouched, and doesn't advance the counter).
4
+ */
5
+ import { describe, expect, test } from 'bun:test'
6
+ import { withPartIds } from './id'
7
+ import type { AgentPartDraft } from './parts'
8
+
9
+ async function* gen<T>(values: T[]): AsyncGenerator<T> {
10
+ for (const value of values) yield value
11
+ }
12
+
13
+ async function collect<T>(source: AsyncGenerator<T>): Promise<T[]> {
14
+ const out: T[] = []
15
+ for await (const value of source) out.push(value)
16
+ return out
17
+ }
18
+
19
+ describe('withPartIds', () => {
20
+ test('stamps a sequential id on every draft missing one', async () => {
21
+ // Annotated: all three literals uniformly omit `id`, so without this TS infers an `id`-less
22
+ // element type that has no properties in common with withPartIds' `{ id?: string }`
23
+ // constraint — a weak-type check failure, not a real shape mismatch.
24
+ const drafts: AgentPartDraft[] = [
25
+ { type: 'text', text: 'a' },
26
+ { type: 'text', text: 'b' },
27
+ { type: 'text', text: 'c' },
28
+ ]
29
+ const result = await collect(withPartIds('run-1', gen(drafts)))
30
+ expect(result).toEqual([
31
+ { type: 'text', text: 'a', id: 'run-1#0' },
32
+ { type: 'text', text: 'b', id: 'run-1#1' },
33
+ { type: 'text', text: 'c', id: 'run-1#2' },
34
+ ])
35
+ })
36
+
37
+ test('leaves a part that already has an id untouched', async () => {
38
+ const drafts = [{ type: 'text', text: 'a', id: 'custom-id' }]
39
+ const result = await collect(withPartIds('run-1', gen(drafts)))
40
+ expect(result).toEqual([{ type: 'text', text: 'a', id: 'custom-id' }])
41
+ })
42
+
43
+ test('is idempotent: re-running withPartIds over already-identified parts changes nothing', async () => {
44
+ const drafts: AgentPartDraft[] = [{ type: 'text', text: 'a' }]
45
+ const once = await collect(withPartIds('run-1', gen(drafts)))
46
+ const twice = await collect(withPartIds('run-1', gen(once)))
47
+ expect(twice).toEqual(once)
48
+ })
49
+
50
+ test('does not advance the counter for parts that already carry an id', async () => {
51
+ const drafts = [
52
+ { type: 'text', text: 'a', id: 'preset' },
53
+ { type: 'text', text: 'b' },
54
+ ]
55
+ const result = await collect(withPartIds('run-1', gen(drafts)))
56
+ expect(result).toEqual([
57
+ { type: 'text', text: 'a', id: 'preset' },
58
+ { type: 'text', text: 'b', id: 'run-1#0' },
59
+ ])
60
+ })
61
+
62
+ test('mixed run: only missing ids are stamped, sequentially among themselves', async () => {
63
+ const drafts = [
64
+ { type: 'text', text: 'a' },
65
+ { type: 'text', text: 'b', id: 'preset' },
66
+ { type: 'text', text: 'c' },
67
+ ]
68
+ const result = await collect(withPartIds('run-42', gen(drafts)))
69
+ expect(result).toEqual([
70
+ { type: 'text', text: 'a', id: 'run-42#0' },
71
+ { type: 'text', text: 'b', id: 'preset' },
72
+ { type: 'text', text: 'c', id: 'run-42#1' },
73
+ ])
74
+ })
75
+ })
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Id-minting helpers shared by the thread stores: `mintThreadId` (a client-side thread id, used by
3
+ * both `./thread`'s `createThreadsStore` and `./adapter`'s `createAdapterThreadsStore`) and
4
+ * `withPartIds` (stamps sequence ids onto streamed parts). Related in theme — both mint an id a
5
+ * store needs — unrelated in mechanism; grouped here because they're the two id-minting leaves the
6
+ * two thread stores both sit above.
7
+ */
8
+
9
+ /**
10
+ * Mints a client-side thread id. `crypto.randomUUID()` exists only in SECURE CONTEXTS (HTTPS or
11
+ * localhost) — a consumer served over plain HTTP on a LAN hostname (a homelab dashboard on a bare
12
+ * IP, a staging box) would otherwise have `create()`, about the most basic user action a thread
13
+ * store has, throw a plain TypeError. A store used from a browser must not throw on a user gesture
14
+ * — the same principle already applied twice this release (`spliceText` clamps rather than throws,
15
+ * `coalesceParts` degrades rather than throws).
16
+ *
17
+ * Fallback chain, each rung reached only when the one above it is unavailable:
18
+ * 1. `crypto.randomUUID()` — cryptographically random, RFC 4122 UUID. The normal path.
19
+ * 2. `crypto.getRandomValues()` — still cryptographically random; hand-assembled into a UUIDv4
20
+ * (the version/variant bits are set per RFC 4122, since this rung hands back raw bytes, not a
21
+ * formatted UUID).
22
+ * 3. No usable `crypto` at all (missing entirely, or missing both methods above — an old WebView,
23
+ * an SSR shim). NOT cryptographically random and NOT collision-resistant in general. This rung
24
+ * exists solely so `create()` never throws on such a host. It is acceptable ONLY because this
25
+ * id is minted a handful of times per client session and is not the idempotency key any write
26
+ * here depends on — `ThreadsStoreAdapter.appendMessage`'s idempotency key is the MESSAGE id,
27
+ * not this thread id, so a collision on this rung costs a locally merged/duplicated thread, not
28
+ * silent data loss on the append path. Do not reuse this rung for anything with a higher
29
+ * collision cost than that.
30
+ */
31
+ export function mintThreadId(): string {
32
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
33
+ return crypto.randomUUID()
34
+ }
35
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
36
+ const bytes = crypto.getRandomValues(new Uint8Array(16))
37
+ const hex = Array.from({ length: 16 }, (_, i) => {
38
+ const raw = bytes.at(i) ?? 0
39
+ // Version 4 (bits 12-15 of time_hi_and_version) and variant 10 (bits 6-7 of
40
+ // clock_seq_hi_and_reserved) — the only structure a UUIDv4 promises over raw random bytes.
41
+ const byte = i === 6 ? (raw & 0x0f) | 0x40 : i === 8 ? (raw & 0x3f) | 0x80 : raw
42
+ return byte.toString(16).padStart(2, '0')
43
+ }).join('')
44
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
45
+ }
46
+ return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
47
+ }
48
+
49
+ /**
50
+ * withPartIds — stamps a stable id onto every draft part arriving without one.
51
+ *
52
+ * A transport is allowed to yield a draft (`AgentPartDraft` — `id` optional); this wraps such a
53
+ * generator and normalizes it into a fully-identified stream, minting `${runId}#${n}` for any
54
+ * part that arrives without an `id`. A part that already carries an id passes through untouched
55
+ * (idempotent) and does not advance the counter.
56
+ *
57
+ * NOT a replay-convergence mechanism. The minted id is a bare per-call sequence number with no
58
+ * relationship to content or offset — it identifies "the nth id-less part THIS invocation saw",
59
+ * nothing more. Calling `withPartIds` again over a resumed/replayed stream restarts its counter at
60
+ * 0, so the resumed stream's first delta mints the SAME id as the original run's first delta
61
+ * (`${runId}#0`) regardless of whether it carries the same content — if the resumed transport
62
+ * re-sends the full text as one chunk while the original sent it as several deltas, mergePart's
63
+ * tail-splice will corrupt the accumulated text rather than converge it. A transport that needs
64
+ * genuine replay convergence (one asserting `ResumableAgentTransport`'s `idempotentReplay: true`)
65
+ * MUST mint its own content-stable ids — e.g. `aiSdkTransport`'s `${chatId}#${index}` /
66
+ * `tool#${toolCallId}` — and must not rely on this helper for that guarantee. Combining
67
+ * `withPartIds` with a transport that asserts `idempotentReplay: true` is only safe when that
68
+ * transport already mints stable ids itself, in which case every part already carries an `id` and
69
+ * `withPartIds` is a no-op passthrough (see the idempotent behavior above) — it is never the thing
70
+ * providing replay safety.
71
+ *
72
+ * @example
73
+ * const identified = withPartIds(runId, transport.stream(input, signal))
74
+ * for await (const part of identified) {
75
+ * // part.id is always defined here
76
+ * }
77
+ */
78
+ export async function* withPartIds<TPart extends { id?: string }>(
79
+ runId: string,
80
+ source: AsyncGenerator<TPart>,
81
+ ): AsyncGenerator<TPart & { id: string }> {
82
+ let n = 0
83
+ for await (const part of source) {
84
+ if (part.id !== undefined) {
85
+ yield part as TPart & { id: string }
86
+ continue
87
+ }
88
+ yield { ...part, id: `${runId}#${n}` }
89
+ n += 1
90
+ }
91
+ }
@@ -34,18 +34,41 @@
34
34
  // ── AgentPart discriminated union ─────────────────────────────────────────────
35
35
  export type {
36
36
  AgentPart,
37
+ AgentPartDraft,
38
+ Drafted,
37
39
  StartPart,
38
40
  TextPart,
39
41
  ReasoningPart,
40
42
  ToolCallPart,
43
+ ToolCallState,
41
44
  SourcePart,
42
45
  ErrorPart,
43
46
  } from './parts'
44
- export { parseAgentPart, isStartPart } from './parts'
47
+ export { parseAgentPart, isStartPart, isToolCallSettled, TERMINAL_TOOL_STATES } from './parts'
48
+
49
+ // ── mergePart (identity-addressed accumulator) ───────────────────────────────
50
+ export { mergePart } from './merge'
51
+
52
+ // ── withPartIds (drafts → identified parts) ──────────────────────────────────
53
+ export { withPartIds } from './id'
54
+
55
+ // ── coalesceParts (adjacent + by-id merge for display) ───────────────────────
56
+ export { coalesceParts } from './coalesce'
57
+
58
+ // ── foreign parts (the open half of the part-renderer registry) ──────────────
59
+ export { definePartRenderers, narrowAgentPart } from './foreign'
60
+ export type {
61
+ ForeignPart,
62
+ TranscriptPart,
63
+ ConsumerPart,
64
+ PartRenderers,
65
+ PartRenderContext,
66
+ PartRenderer,
67
+ } from './foreign'
45
68
 
46
69
  // ── AgentTransport + edenTransport ───────────────────────────────────────────
47
- export type { AgentTransport } from './transport'
48
- export { edenTransport } from './transport'
70
+ export type { AgentTransport, ResumableAgentTransport } from './transport'
71
+ export { edenTransport, isResumable } from './transport'
49
72
 
50
73
  // ── aiSdkTransport (recommended default, optional peer: ai) ──────────────────
51
74
  export { aiSdkTransport } from './ai-sdk-transport'
@@ -90,3 +113,11 @@ export type { AgentThread, ThreadStatus, ThreadsStore, ThreadsStoreOptions } fro
90
113
  // ── AgentOutcome + heuristicOutcome ────────────────────────────────────────────
91
114
  export { heuristicOutcome } from './outcome'
92
115
  export type { AgentOutcome, OutcomeResolver } from './outcome'
116
+
117
+ // ── createAdapterThreadsStore + threadsStoreAdapterContract ──────────────────
118
+ export { createAdapterThreadsStore, threadsStoreAdapterContract } from './adapter'
119
+ export type {
120
+ AdapterThreadsStoreOptions,
121
+ ThreadsStoreAdapter,
122
+ ThreadsStoreAdapterContractCase,
123
+ } from './adapter'
@@ -0,0 +1,206 @@
1
+ /**
2
+ * mergePart — the identity-addressed accumulator. The behaviour under test that matters most:
3
+ * replaying a whole run from character offset 0 rebuilds the identical parts array it produced
4
+ * the first time — it cannot double the content.
5
+ */
6
+ import { describe, expect, test } from 'bun:test'
7
+ import { mergePart } from './merge'
8
+ import type { TranscriptPart } from './foreign'
9
+ import type { AgentPart, TextPart, ToolCallPart } from './parts'
10
+
11
+ describe('mergePart', () => {
12
+ test('appends a part whose id is not yet present', () => {
13
+ const parts: AgentPart[] = [{ id: 'p1', type: 'text', text: 'Hi' }]
14
+ const next: AgentPart = { id: 'p2', type: 'text', text: 'there' }
15
+ expect(mergePart(parts, next)).toEqual([
16
+ { id: 'p1', type: 'text', text: 'Hi' },
17
+ { id: 'p2', type: 'text', text: 'there' },
18
+ ])
19
+ })
20
+
21
+ test('an undefined offset splices at the tail (append)', () => {
22
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hel' }]
23
+ const next: TextPart = { id: 'p1', type: 'text', text: 'lo' }
24
+ const [merged] = mergePart(parts, next)
25
+ expect(merged?.text).toBe('Hello')
26
+ })
27
+
28
+ test('splices an explicit offset into the middle of the existing text', () => {
29
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hello world' }]
30
+ // Replace the 5-char span starting at offset 6 ("world") with "there"
31
+ const next: TextPart = { id: 'p1', type: 'text', text: 'there', offset: 6 }
32
+ const [merged] = mergePart(parts, next)
33
+ expect(merged?.text).toBe('Hello there')
34
+ })
35
+
36
+ test('reasoning splices the same way as text', () => {
37
+ const parts = [{ id: 'p1', type: 'reasoning' as const, text: 'thinking' }]
38
+ const next = { id: 'p1', type: 'reasoning' as const, text: ' more' }
39
+ const [merged] = mergePart(parts, next)
40
+ expect(merged?.text).toBe('thinking more')
41
+ })
42
+
43
+ test('offset-0 full replay rebuilds an identical array — it does not double the content', () => {
44
+ // First pass: two deltas building "Hello world" from nothing.
45
+ let parts: TextPart[] = []
46
+ parts = mergePart(parts, { id: 'p1', type: 'text', text: 'Hello' })
47
+ parts = mergePart(parts, { id: 'p1', type: 'text', text: ' world', offset: 5 })
48
+ expect(parts).toHaveLength(1)
49
+ expect(parts[0]?.text).toBe('Hello world')
50
+ const firstPassResult = parts
51
+
52
+ // Replay from character 0: the whole run resent as one delta at offset 0.
53
+ const replayed = mergePart(parts, { id: 'p1', type: 'text', text: 'Hello world', offset: 0 })
54
+
55
+ expect(replayed).toHaveLength(1)
56
+ expect(replayed[0]?.text).toBe('Hello world')
57
+ expect(replayed).toEqual(firstPassResult.map((p) => ({ ...p, offset: 0 })))
58
+ })
59
+
60
+ // ── out-of-range offsets ───────────────────────────────────────────────────
61
+ // spliceText supports exactly two shapes (append at the end, in-range replace). Everything else
62
+ // is CLAMPED into [0, existing.text.length] with a dev warning — never thrown on, because a
63
+ // throw on the render path turns a transient wire anomaly into a permanently dead transcript.
64
+
65
+ test('an offset past the end of the existing text clamps to the tail (appends, leaves no hole)', () => {
66
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hi' }]
67
+ const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: 10 })
68
+ expect(merged?.text).toBe('Hi!')
69
+ })
70
+
71
+ test('a negative offset clamps to 0 rather than slicing from the end', () => {
72
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hello world' }]
73
+ const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: 'Hey', offset: -3 })
74
+ expect(merged?.text).toBe('Heylo world')
75
+ })
76
+
77
+ test('an out-of-order (already-covered) offset replaces in place — it never re-appends', () => {
78
+ // 'Hello world' is fully accumulated; a straggler delta for offset 6 arrives after the tail
79
+ // has moved past it. In-range, so it is honoured as a replace, not clamped.
80
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hello world' }]
81
+ const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: 'WORLD', offset: 6 })
82
+ expect(merged?.text).toBe('Hello WORLD')
83
+ })
84
+
85
+ test('a non-finite offset clamps to the tail rather than blanking the part', () => {
86
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hi' }]
87
+ const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: Number.NaN })
88
+ expect(merged?.text).toBe('Hi!')
89
+ })
90
+
91
+ test('a FIRST insertion with a nonzero offset clamps and warns like any other out-of-range one', () => {
92
+ // A first insertion splices against an EMPTY existing text, so the offset is out of range
93
+ // [0, 0]: mergePart never invents the 2 missing characters, it stores what arrived — but the
94
+ // anomaly is announced, exactly as it is for an existing part. One contract, one code path.
95
+ const warnings: string[] = []
96
+ const original = console.warn
97
+ console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
98
+ let first: TextPart[]
99
+ try {
100
+ first = mergePart<TextPart>([], { id: 'p1', type: 'text', text: 'llo', offset: 2 })
101
+ } finally {
102
+ console.warn = original
103
+ }
104
+ expect(warnings).toHaveLength(1)
105
+ expect(warnings[0]).toContain('out of range [0, 0]')
106
+ expect(first).toEqual([{ id: 'p1', type: 'text', text: 'llo', offset: 2 }])
107
+
108
+ // The offset rides along inert; the next delta for this id splices relative to the STORED text.
109
+ const [merged] = mergePart(first, { id: 'p1', type: 'text', text: ' there' })
110
+ expect(merged?.text).toBe('llo there')
111
+ })
112
+
113
+ test('a FIRST insertion with an in-range (0 or undefined) offset stays silent and stores the text as-is', () => {
114
+ const warnings: string[] = []
115
+ const original = console.warn
116
+ console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
117
+ try {
118
+ expect(mergePart<TextPart>([], { id: 'p1', type: 'text', text: 'Hello', offset: 0 })).toEqual(
119
+ [{ id: 'p1', type: 'text', text: 'Hello', offset: 0 }],
120
+ )
121
+ expect(mergePart<TextPart>([], { id: 'p2', type: 'text', text: 'Hello' })).toEqual([
122
+ { id: 'p2', type: 'text', text: 'Hello' },
123
+ ])
124
+ } finally {
125
+ console.warn = original
126
+ }
127
+ expect(warnings).toHaveLength(0)
128
+ })
129
+
130
+ test('warns in dev on an out-of-range offset, and stays silent on an in-range one', () => {
131
+ const warnings: string[] = []
132
+ const original = console.warn
133
+ console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
134
+ try {
135
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hi' }]
136
+ mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: 1 })
137
+ expect(warnings).toHaveLength(0)
138
+
139
+ mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: 10 })
140
+ expect(warnings).toHaveLength(1)
141
+ expect(warnings[0]).toContain('out of range')
142
+ } finally {
143
+ console.warn = original
144
+ }
145
+ })
146
+
147
+ test('non-text/reasoning variants replace wholesale rather than merging fields', () => {
148
+ const parts: ToolCallPart[] = [
149
+ {
150
+ id: 'p1',
151
+ type: 'tool',
152
+ toolCallId: 'call-1',
153
+ toolName: 'search',
154
+ state: 'input-available',
155
+ input: { q: 'x' },
156
+ },
157
+ ]
158
+ const next: ToolCallPart = {
159
+ id: 'p1',
160
+ type: 'tool',
161
+ toolCallId: 'call-1',
162
+ toolName: 'search',
163
+ state: 'output-available',
164
+ input: { q: 'x' },
165
+ output: { hits: 3 },
166
+ }
167
+ expect(mergePart(parts, next)).toEqual([next])
168
+ })
169
+
170
+ test('a rewrite keeps the original position — ordering is stable', () => {
171
+ const parts: AgentPart[] = [
172
+ { id: 'a', type: 'text', text: 'first' },
173
+ { id: 'b', type: 'text', text: 'second' },
174
+ { id: 'c', type: 'text', text: 'third' },
175
+ ]
176
+ const next: AgentPart = { id: 'b', type: 'text', text: '!!', offset: 6 }
177
+ const merged = mergePart(parts, next)
178
+ expect(merged.map((p) => p.id)).toEqual(['a', 'b', 'c'])
179
+ expect((merged[1] as TextPart).text).toBe('second!!')
180
+ })
181
+
182
+ // B2 convergence fix: mergePart is constrained on PartLike (structural `id` + `type`), not the
183
+ // closed AgentPart union, precisely so a ForeignPart (never text-like) flows through the same
184
+ // accumulator. This is the compile-time contract `useAgentThreadRuns<TranscriptPart>` depends on.
185
+ test('a foreign part (never text-like) survives a round trip unchanged', () => {
186
+ const parts: TranscriptPart[] = []
187
+ const first = mergePart(parts, {
188
+ id: 'f1',
189
+ type: 'data-toolProgress',
190
+ tool: 'search',
191
+ message: 'searching',
192
+ })
193
+ expect(first).toEqual([
194
+ { id: 'f1', type: 'data-toolProgress', tool: 'search', message: 'searching' },
195
+ ])
196
+ })
197
+
198
+ test('a replayed foreign part (same id) replaces wholesale rather than duplicating', () => {
199
+ let parts: TranscriptPart[] = []
200
+ parts = mergePart(parts, { id: 'f1', type: 'data-toolProgress', message: 'searching' })
201
+ parts = mergePart(parts, { id: 'f1', type: 'data-toolProgress', message: 'done' })
202
+
203
+ expect(parts).toHaveLength(1)
204
+ expect(parts).toEqual([{ id: 'f1', type: 'data-toolProgress', message: 'done' }])
205
+ })
206
+ })