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,11 +1,13 @@
1
1
  /**
2
- * aiSdkTransport — snapshot→delta diffing (text/reasoning/source/tool), signal-abort mid-stream,
3
- * and deterministic chat-id binding via `.forThread()`.
2
+ * aiSdkTransport — snapshot→delta diffing (text/reasoning/source/tool), deterministic id minting,
3
+ * durationMs, signal-abort mid-stream, and deterministic chat-id binding via `.forThread()`.
4
4
  *
5
- * Scope: the diffing behaviour AS IT EXISTS TODAY (B1) — no seven-state ToolCallPart, no
6
- * `output-error` carrying a dedicated `error` field (today it is smuggled through `output`, see
7
- * the `output-error` test below), no `durationMs` on terminal states. Those are 1.10.0 part-
8
- * identity work; this file pins the CURRENT shape so that work has a baseline to diff against.
5
+ * Scope: the diffing behaviour post-1.11.0's B2 rewrite — `diffToolPart` now models all seven
6
+ * `UIToolInvocation` states (including `input-streaming`, the nested `approval` envelope, and
7
+ * `output-denied`), mints deterministic ids (`${chatId}#${index}` for index-addressed parts,
8
+ * `tool#${toolCallId}` for tool parts), carries an authoritative `offset` on every text/reasoning
9
+ * delta, derives a dynamic tool's real name instead of the literal `'dynamic-tool'`, and does not
10
+ * suppress a `preliminary: true` output-available refinement.
9
11
  *
10
12
  * Driving `aiSdkTransport` through a scripted HTTP response — the only way to exercise its public
11
13
  * stream()/resume() diffing at all, since the diffing internals are not exported — requires a real
@@ -18,8 +20,10 @@
18
20
  import { describe, expect, test } from 'bun:test'
19
21
  import { JsonToSseTransformStream } from 'ai'
20
22
  import { aiSdkTransport } from './ai-sdk-transport'
23
+ import { withPartIds } from './id'
24
+ import { mergePart } from './merge'
21
25
  import type { UIMessageChunk } from 'ai'
22
- import type { AgentPart } from './parts'
26
+ import type { AgentPart, ToolCallPart } from './parts'
23
27
 
24
28
  /**
25
29
  * Builds a fetch-compatible Response whose body is the given low-level UIMessageChunks, SSE-
@@ -39,6 +43,16 @@ function scriptedResponse(chunks: UIMessageChunk[]): Response {
39
43
  return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } })
40
44
  }
41
45
 
46
+ /**
47
+ * Casts a scripted fetch stub into `aiSdkTransport`'s `fetch: typeof globalThis.fetch` option.
48
+ * The DOM lib's `typeof fetch` type also demands a static `preconnect` method (a real
49
+ * browser/undici API these scripted mocks never call and don't need to implement) — the cast
50
+ * documents that the only structural gap is that one, unused static member.
51
+ */
52
+ function mockFetch(impl: () => Promise<Response>): typeof fetch {
53
+ return impl as unknown as typeof fetch
54
+ }
55
+
42
56
  /** Drains an aiSdkTransport `.stream()`/`.resume()` async generator into a plain array. */
43
57
  async function collect(gen: AsyncGenerator<AgentPart>): Promise<AgentPart[]> {
44
58
  const parts: AgentPart[] = []
@@ -46,8 +60,28 @@ async function collect(gen: AsyncGenerator<AgentPart>): Promise<AgentPart[]> {
46
60
  return parts
47
61
  }
48
62
 
63
+ /**
64
+ * `Omit<T, K>` does not distribute over a union — `keyof T` on a union collapses to only the
65
+ * KEYS COMMON to every member, so `Omit<ToolCallPart, 'durationMs'>` would silently drop each
66
+ * state's own fields (`input`, `output`, …) rather than just `durationMs`. This variant forces
67
+ * distribution via a naked conditional type parameter.
68
+ */
69
+ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
70
+
71
+ /** Strips a non-deterministic `durationMs` (wall-clock, so never equality-comparable) before an
72
+ * exact `toEqual`, and separately asserts its type. Constrained to `Record<string, unknown>`
73
+ * rather than `{ durationMs?: number }` because ErrorPart (uniquely among AgentPart variants)
74
+ * carries no `durationMs` field at all — that stricter constraint trips TS's weak-type check
75
+ * ("no properties in common") the moment this is called with the full AgentPart union. */
76
+ function withoutDurationMs<T extends Record<string, unknown>>(
77
+ part: T,
78
+ ): DistributiveOmit<T, 'durationMs'> {
79
+ const { durationMs: _durationMs, ...rest } = part
80
+ return rest as DistributiveOmit<T, 'durationMs'>
81
+ }
82
+
49
83
  describe('aiSdkTransport — snapshot→delta diffing', () => {
50
- test('yields a synthesized start part first, then diffs text deltas across snapshots', async () => {
84
+ test('yields a synthesized start part first, with a deterministic id, then diffs text deltas across snapshots with an authoritative offset', async () => {
51
85
  const chunks: UIMessageChunk[] = [
52
86
  { type: 'text-start', id: 't1' },
53
87
  { type: 'text-delta', id: 't1', delta: 'Hel' },
@@ -56,23 +90,57 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
56
90
  ]
57
91
  const transport = aiSdkTransport({
58
92
  api: '/api/chat',
59
- fetch: async () => scriptedResponse(chunks),
93
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
60
94
  })
61
95
  const parts = await collect(transport.stream('hi'))
62
96
 
63
97
  expect(parts[0]?.type).toBe('start')
64
- const textParts = parts.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
98
+ expect(typeof (parts[0] as { id: string }).id).toBe('string')
99
+ const textParts = parts.filter(
100
+ (p): p is { id: string; type: 'text'; text: string; offset: number } => p.type === 'text',
101
+ )
65
102
  // AI SDK's snapshot carries the FULL accumulated text at every write — the module's whole job
66
103
  // is re-deriving the two incremental deltas ('Hel' then 'lo') rather than replaying 'Hello'
67
- // twice or once whole.
68
- expect(textParts).toEqual([
69
- { type: 'text', text: 'Hel' },
70
- { type: 'text', text: 'lo' },
71
- ])
104
+ // twice or once whole. Both deltas share ONE id (the same message-part index) so mergePart
105
+ // splices them into a single TextPart; `offset` is the length of the previously-seen text.
106
+ expect(textParts.map((p) => p.text)).toEqual(['Hel', 'lo'])
107
+ expect(textParts.map((p) => p.offset)).toEqual([0, 3])
108
+ expect(new Set(textParts.map((p) => p.id)).size).toBe(1)
72
109
  expect(textParts.reduce((acc, p) => acc + p.text, '')).toBe('Hello')
73
110
  })
74
111
 
75
- test('diffs reasoning deltas the same way as text', async () => {
112
+ // F1: withPartIds is only ever a normalizer for DRAFTS (id-less parts). aiSdkTransport mints its
113
+ // own deterministic, content-stable ids for every part it yields (`${chatId}#${index}`,
114
+ // `tool#${toolCallId}`, `${chatId}#start`), so wrapping its output in withPartIds must be a pure
115
+ // no-op passthrough — proves the two hooks wiring withPartIds in unconditionally does not disturb
116
+ // a transport that already identifies its own parts.
117
+ test('withPartIds is a no-op over aiSdkTransport output — every part already carries a stable id', async () => {
118
+ const chunks: UIMessageChunk[] = [
119
+ { type: 'text-start', id: 't1' },
120
+ { type: 'text-delta', id: 't1', delta: 'Hel' },
121
+ { type: 'text-delta', id: 't1', delta: 'lo' },
122
+ { type: 'text-end', id: 't1' },
123
+ { type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
124
+ { type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
125
+ { type: 'tool-output-available', toolCallId: 'call-1', output: { hits: 3 } },
126
+ ]
127
+
128
+ // Same transport instance for both sides — aiSdkTransport mints its chat id at CONSTRUCTION
129
+ // time, so two separately-constructed transports would mint two different chat ids and
130
+ // trivially fail to match on that alone, masking whether withPartIds itself changed anything.
131
+ const transport = aiSdkTransport({
132
+ api: '/api/chat',
133
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
134
+ })
135
+ const direct = await collect(transport.stream('hi'))
136
+ const wrapped = await collect(withPartIds('unrelated-run-id', transport.stream('hi')))
137
+
138
+ expect(wrapped.map(withoutDurationMs)).toEqual(direct.map(withoutDurationMs))
139
+ // Every part already had an id — withPartIds' counter never had anything id-less to stamp.
140
+ expect(direct.every((part) => typeof (part as { id: string }).id === 'string')).toBe(true)
141
+ })
142
+
143
+ test('diffs reasoning deltas the same way as text, with the same id/offset mechanism', async () => {
76
144
  const chunks: UIMessageChunk[] = [
77
145
  { type: 'reasoning-start', id: 'r1' },
78
146
  { type: 'reasoning-delta', id: 'r1', delta: 'thinking ' },
@@ -81,32 +149,41 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
81
149
  ]
82
150
  const transport = aiSdkTransport({
83
151
  api: '/api/chat',
84
- fetch: async () => scriptedResponse(chunks),
152
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
85
153
  })
86
154
  const parts = await collect(transport.stream('hi'))
87
155
 
88
- const reasoningParts = parts.filter((p) => p.type === 'reasoning')
89
- expect(reasoningParts).toEqual([
90
- { type: 'reasoning', text: 'thinking ' },
91
- { type: 'reasoning', text: 'more' },
92
- ])
156
+ const reasoningParts = parts.filter((p) => p.type === 'reasoning') as {
157
+ id: string
158
+ text: string
159
+ offset: number
160
+ }[]
161
+ expect(reasoningParts.map((p) => p.text)).toEqual(['thinking ', 'more'])
162
+ expect(reasoningParts.map((p) => p.offset)).toEqual([0, 9])
163
+ expect(new Set(reasoningParts.map((p) => p.id)).size).toBe(1)
93
164
  })
94
165
 
95
- test('emits a source part exactly once for a source-url chunk', async () => {
166
+ test('emits a source part exactly once for a source-url chunk, with a deterministic id', async () => {
96
167
  const chunks: UIMessageChunk[] = [
97
168
  { type: 'source-url', sourceId: 's1', url: 'https://example.com', title: 'Example' },
98
169
  ]
99
170
  const transport = aiSdkTransport({
100
171
  api: '/api/chat',
101
- fetch: async () => scriptedResponse(chunks),
172
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
102
173
  })
103
174
  const parts = await collect(transport.stream('hi'))
104
175
 
105
176
  const sourceParts = parts.filter((p) => p.type === 'source')
106
- expect(sourceParts).toEqual([{ type: 'source', url: 'https://example.com', title: 'Example' }])
177
+ expect(sourceParts).toHaveLength(1)
178
+ expect(sourceParts[0]).toMatchObject({
179
+ type: 'source',
180
+ url: 'https://example.com',
181
+ title: 'Example',
182
+ })
183
+ expect(typeof (sourceParts[0] as { id: string }).id).toBe('string')
107
184
  })
108
185
 
109
- test('a tool call progresses input-available → output-available with the accumulated output', async () => {
186
+ test('a tool call progresses input-streaming → input-available → output-available, one draft per state, addressed by tool#<toolCallId>', async () => {
110
187
  const chunks: UIMessageChunk[] = [
111
188
  { type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
112
189
  { type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
@@ -114,23 +191,45 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
114
191
  ]
115
192
  const transport = aiSdkTransport({
116
193
  api: '/api/chat',
117
- fetch: async () => scriptedResponse(chunks),
194
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
118
195
  })
119
196
  const parts = await collect(transport.stream('hi'))
120
197
 
121
198
  const toolParts = parts.filter((p) => p.type === 'tool')
122
- // input-streaming is never emitted (deliberate v1 simplification) — only input-available then
123
- // output-available surface, one AgentPart each.
124
- expect(toolParts).toEqual([
125
- { type: 'tool', toolName: 'search', toolCallId: 'call-1', input: { q: 'x' } },
199
+ expect(toolParts.every((p) => (p as { id: string }).id === 'tool#call-1')).toBe(true)
200
+ // Annotated so each element is checked individually against the ToolCallPart union — passed
201
+ // inline, TS collapses the array's contextual element type and rejects the settled states'
202
+ // extra `input`/`output` fields against the first (narrowest) member it picks.
203
+ const expectedToolParts: DistributiveOmit<ToolCallPart, 'durationMs'>[] = [
204
+ {
205
+ id: 'tool#call-1',
206
+ type: 'tool',
207
+ state: 'input-streaming',
208
+ toolName: 'search',
209
+ toolCallId: 'call-1',
210
+ },
211
+ {
212
+ id: 'tool#call-1',
213
+ type: 'tool',
214
+ state: 'input-available',
215
+ toolName: 'search',
216
+ toolCallId: 'call-1',
217
+ input: { q: 'x' },
218
+ },
126
219
  {
220
+ id: 'tool#call-1',
127
221
  type: 'tool',
222
+ state: 'output-available',
128
223
  toolName: 'search',
129
224
  toolCallId: 'call-1',
130
225
  input: { q: 'x' },
131
226
  output: { hits: 3 },
132
227
  },
133
- ])
228
+ ]
229
+ expect(toolParts.map(withoutDurationMs)).toEqual(expectedToolParts)
230
+ // durationMs is only set once the call reaches a terminal state (output-available here).
231
+ expect((toolParts[0] as { durationMs?: number }).durationMs).toBeUndefined()
232
+ expect(typeof (toolParts.at(-1) as { durationMs?: number }).durationMs).toBe('number')
134
233
  })
135
234
 
136
235
  test('a repeated chunk carrying the SAME tool state is deduped — nothing new to report', async () => {
@@ -143,19 +242,146 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
143
242
  ]
144
243
  const transport = aiSdkTransport({
145
244
  api: '/api/chat',
146
- fetch: async () => scriptedResponse(chunks),
245
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
246
+ })
247
+ const parts = await collect(transport.stream('hi'))
248
+
249
+ const toolParts = parts.filter((p) => p.type === 'tool')
250
+ // input-streaming once, input-available once (redundant resend deduped), output-available
251
+ // once — three states, not four.
252
+ expect(toolParts).toHaveLength(3)
253
+ })
254
+
255
+ test('a preliminary output-available result emits EVERY refinement, not just the first (bug: state-equality alone would freeze on the first snapshot)', async () => {
256
+ const chunks: UIMessageChunk[] = [
257
+ { type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
258
+ { type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
259
+ {
260
+ type: 'tool-output-available',
261
+ toolCallId: 'call-1',
262
+ output: { hits: 1 },
263
+ preliminary: true,
264
+ },
265
+ {
266
+ type: 'tool-output-available',
267
+ toolCallId: 'call-1',
268
+ output: { hits: 5 },
269
+ preliminary: true,
270
+ },
271
+ { type: 'tool-output-available', toolCallId: 'call-1', output: { hits: 5, done: true } },
272
+ ]
273
+ const transport = aiSdkTransport({
274
+ api: '/api/chat',
275
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
276
+ })
277
+ const parts = await collect(transport.stream('hi'))
278
+
279
+ const outputs = parts
280
+ .filter((p) => p.type === 'tool' && p.state === 'output-available')
281
+ .map((p) => (p as { output: unknown; preliminary?: boolean }).output)
282
+ // All THREE refinements surfaced — a naive `prev.state === curr.state` dedup would have
283
+ // dropped the second and third (state stays 'output-available' throughout).
284
+ expect(outputs).toEqual([{ hits: 1 }, { hits: 5 }, { hits: 5, done: true }])
285
+ })
286
+
287
+ test('approval-requested → approval-responded carries the nested approval envelope through verbatim (isAutomatic and signature survive)', async () => {
288
+ const chunks: UIMessageChunk[] = [
289
+ { type: 'tool-input-start', toolCallId: 'call-1', toolName: 'delete-file' },
290
+ {
291
+ type: 'tool-input-available',
292
+ toolCallId: 'call-1',
293
+ toolName: 'delete-file',
294
+ input: { path: '/x' },
295
+ },
296
+ {
297
+ type: 'tool-approval-request',
298
+ approvalId: 'appr-1',
299
+ toolCallId: 'call-1',
300
+ isAutomatic: true,
301
+ signature: 'sig-xyz',
302
+ },
303
+ {
304
+ type: 'tool-approval-response',
305
+ approvalId: 'appr-1',
306
+ approved: true,
307
+ reason: 'looks safe',
308
+ },
309
+ ]
310
+ const transport = aiSdkTransport({
311
+ api: '/api/chat',
312
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
313
+ })
314
+ const parts = await collect(transport.stream('hi'))
315
+
316
+ const toolParts = parts.filter((p) => p.type === 'tool') as {
317
+ state: string
318
+ approval?: {
319
+ id: string
320
+ approved?: boolean
321
+ reason?: string
322
+ isAutomatic?: boolean
323
+ signature?: string
324
+ }
325
+ }[]
326
+
327
+ const requested = toolParts.find((p) => p.state === 'approval-requested')
328
+ // signature only exists on the SDK's approval-requested envelope — this pins that OUR diffing
329
+ // does not drop it (a flattened `approvalId`/`approved` shape, the spec's own error, would).
330
+ expect(requested?.approval).toEqual({ id: 'appr-1', isAutomatic: true, signature: 'sig-xyz' })
331
+
332
+ const responded = toolParts.find((p) => p.state === 'approval-responded')
333
+ // AI SDK's OWN runtime carries `isAutomatic` forward into approval-responded but drops
334
+ // `signature` there (verified against dist/index.js) — this is the SDK's behavior, not a bug
335
+ // in this diffing layer; what this pins is that basalt passes whatever the SDK gives it
336
+ // through unflattened, not that basalt itself re-adds a dropped field.
337
+ expect(responded?.approval).toEqual({
338
+ id: 'appr-1',
339
+ approved: true,
340
+ reason: 'looks safe',
341
+ isAutomatic: true,
342
+ })
343
+ })
344
+
345
+ test('output-denied is unambiguously terminal — no output, no errorText, just the explicit state + a settled durationMs', async () => {
346
+ const chunks: UIMessageChunk[] = [
347
+ { type: 'tool-input-start', toolCallId: 'call-2', toolName: 'delete-file' },
348
+ {
349
+ type: 'tool-input-available',
350
+ toolCallId: 'call-2',
351
+ toolName: 'delete-file',
352
+ input: { path: '/etc' },
353
+ },
354
+ { type: 'tool-approval-request', approvalId: 'appr-2', toolCallId: 'call-2' },
355
+ { type: 'tool-approval-response', approvalId: 'appr-2', approved: false, reason: 'blocked' },
356
+ { type: 'tool-output-denied', toolCallId: 'call-2' },
357
+ ]
358
+ const transport = aiSdkTransport({
359
+ api: '/api/chat',
360
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
147
361
  })
148
362
  const parts = await collect(transport.stream('hi'))
149
363
 
150
364
  const toolParts = parts.filter((p) => p.type === 'tool')
151
- expect(toolParts).toHaveLength(2) // input-available once, output-available once — not three
365
+ const denied = toolParts.at(-1) as {
366
+ state: string
367
+ output?: unknown
368
+ errorText?: unknown
369
+ input: unknown
370
+ approval?: unknown
371
+ durationMs?: number
372
+ }
373
+ expect(denied.state).toBe('output-denied')
374
+ expect('output' in denied).toBe(false)
375
+ expect('errorText' in denied).toBe(false)
376
+ expect(denied.input).toEqual({ path: '/etc' })
377
+ expect(denied.approval).toEqual({ id: 'appr-2', approved: false, reason: 'blocked' })
378
+ expect(typeof denied.durationMs).toBe('number')
152
379
  })
153
380
 
154
- // 1.10.0 will give ToolCallPart a dedicated error state/field (the seven-state union named in
155
- // this file's scope note). TODAY, ai-sdk-transport.ts:187-188 smuggles a tool failure through
156
- // the ordinary `output` field as `{ error: errorText }` — this test pins THAT current shape so
157
- // the 1.10.0 change has a documented baseline to diff against.
158
- test('output-error is currently smuggled through `output`, not a dedicated error field', async () => {
381
+ // ToolCallPart's `output-error` state carries a dedicated `errorText` field (the SDK's own field
382
+ // name — there is no field named `error` anywhere in the union). Stuffing the failure into
383
+ // `output` is gone: `output-error` doesn't even have an `output` field to smuggle it through.
384
+ test('output-error surfaces a dedicated `errorText` field, not `output`, with input carried forward', async () => {
159
385
  const chunks: UIMessageChunk[] = [
160
386
  { type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
161
387
  { type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
@@ -163,19 +389,46 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
163
389
  ]
164
390
  const transport = aiSdkTransport({
165
391
  api: '/api/chat',
166
- fetch: async () => scriptedResponse(chunks),
392
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
167
393
  })
168
394
  const parts = await collect(transport.stream('hi'))
169
395
 
170
396
  const toolParts = parts.filter((p) => p.type === 'tool')
171
397
  const failed = toolParts.at(-1)
172
- expect(failed).toEqual({
398
+ expect(withoutDurationMs(failed as { durationMs?: number })).toEqual({
399
+ id: 'tool#call-1',
173
400
  type: 'tool',
401
+ state: 'output-error',
174
402
  toolName: 'search',
175
403
  toolCallId: 'call-1',
176
404
  input: { q: 'x' },
177
- output: { error: 'boom' },
405
+ errorText: 'boom',
406
+ })
407
+ expect('output' in (failed as object)).toBe(false)
408
+ })
409
+
410
+ test('a dynamic tool call yields its REAL toolName, not the literal "dynamic-tool"', async () => {
411
+ const chunks: UIMessageChunk[] = [
412
+ { type: 'tool-input-start', toolCallId: 'call-4', toolName: 'custom-lookup', dynamic: true },
413
+ {
414
+ type: 'tool-input-available',
415
+ toolCallId: 'call-4',
416
+ toolName: 'custom-lookup',
417
+ input: { id: 7 },
418
+ dynamic: true,
419
+ },
420
+ { type: 'tool-output-available', toolCallId: 'call-4', output: { found: true } },
421
+ ]
422
+ const transport = aiSdkTransport({
423
+ api: '/api/chat',
424
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
178
425
  })
426
+ const parts = await collect(transport.stream('hi'))
427
+
428
+ const toolParts = parts.filter((p) => p.type === 'tool') as { toolName: string; id: string }[]
429
+ expect(toolParts.length).toBeGreaterThan(0)
430
+ expect(toolParts.every((p) => p.toolName === 'custom-lookup')).toBe(true)
431
+ expect(toolParts.every((p) => p.id === 'tool#call-4')).toBe(true)
179
432
  })
180
433
  })
181
434
 
@@ -189,7 +442,7 @@ describe('aiSdkTransport — signal.aborted stops the yield', () => {
189
442
  ]
190
443
  const transport = aiSdkTransport({
191
444
  api: '/api/chat',
192
- fetch: async () => scriptedResponse(chunks),
445
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
193
446
  })
194
447
  const controller = new AbortController()
195
448
  const gen = transport.stream('hi', controller.signal)
@@ -197,14 +450,14 @@ describe('aiSdkTransport — signal.aborted stops the yield', () => {
197
450
  const start = await gen.next()
198
451
  expect(start.value).toMatchObject({ type: 'start' })
199
452
  const firstDelta = await gen.next()
200
- expect(firstDelta.value).toEqual({ type: 'text', text: 'a' })
453
+ expect(firstDelta.value).toMatchObject({ type: 'text', text: 'a', offset: 0 })
201
454
 
202
455
  // Let AI SDK's background readUIMessageStream pump (kicked off inside diffChunkStream, and
203
456
  // NOT gated on our own reads) fully settle before aborting — this is inherent to the library,
204
457
  // not to the AgentTransport code under test: aborting while chunks are still in flight through
205
458
  // that pump races diffChunkStream's early return against the pump's own stream teardown. A
206
459
  // short real-time wait avoids exercising that unrelated race so this test isolates exactly
207
- // ai-sdk-transport.ts:218's `if (signal?.aborted) return`.
460
+ // ai-sdk-transport.ts's `if (signal?.aborted) return`.
208
461
  await new Promise((resolve) => setTimeout(resolve, 100))
209
462
 
210
463
  controller.abort()
@@ -220,9 +473,9 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
220
473
  // generator that never advances past its first `.next()` never touches the network.
221
474
  const transport = aiSdkTransport({
222
475
  api: '/api/chat',
223
- fetch: () => {
476
+ fetch: mockFetch(() => {
224
477
  throw new Error('must not be called — only the first, pre-network yield is read')
225
- },
478
+ }),
226
479
  })
227
480
 
228
481
  const first = await transport.stream('a').next()
@@ -231,12 +484,12 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
231
484
  expect((first.value as { runId: string }).runId).toBe((second.value as { runId: string }).runId)
232
485
  })
233
486
 
234
- test('.forThread(id) binds the StartPart runId/resumeToken deterministically to the given id', async () => {
487
+ test('.forThread(id) binds the StartPart runId/resumeToken/id deterministically to the given id', async () => {
235
488
  const transport = aiSdkTransport({
236
489
  api: '/api/chat',
237
- fetch: () => {
490
+ fetch: mockFetch(() => {
238
491
  throw new Error('must not be called — only the first, pre-network yield is read')
239
- },
492
+ }),
240
493
  })
241
494
  const bound = transport.forThread('caller-supplied-thread-id')
242
495
 
@@ -244,6 +497,7 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
244
497
  const second = await bound.stream('second turn').next()
245
498
 
246
499
  expect(first.value).toEqual({
500
+ id: 'caller-supplied-thread-id#start',
247
501
  type: 'start',
248
502
  runId: 'caller-supplied-thread-id',
249
503
  resumeToken: 'caller-supplied-thread-id',
@@ -252,18 +506,89 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
252
506
  expect(second.value).toEqual(first.value)
253
507
  })
254
508
 
255
- test('two different .forThread() ids produce two different StartPart runIds', async () => {
509
+ test('two different .forThread() ids produce two different StartPart runIds/ids', async () => {
256
510
  const transport = aiSdkTransport({
257
511
  api: '/api/chat',
258
- fetch: () => {
512
+ fetch: mockFetch(() => {
259
513
  throw new Error('must not be called — only the first, pre-network yield is read')
260
- },
514
+ }),
261
515
  })
262
516
 
263
517
  const a = await transport.forThread('thread-a').stream('hi').next()
264
518
  const b = await transport.forThread('thread-b').stream('hi').next()
265
519
 
266
- expect(a.value).toEqual({ type: 'start', runId: 'thread-a', resumeToken: 'thread-a' })
267
- expect(b.value).toEqual({ type: 'start', runId: 'thread-b', resumeToken: 'thread-b' })
520
+ expect(a.value).toEqual({
521
+ id: 'thread-a#start',
522
+ type: 'start',
523
+ runId: 'thread-a',
524
+ resumeToken: 'thread-a',
525
+ })
526
+ expect(b.value).toEqual({
527
+ id: 'thread-b#start',
528
+ type: 'start',
529
+ runId: 'thread-b',
530
+ resumeToken: 'thread-b',
531
+ })
532
+ })
533
+ })
534
+
535
+ describe('aiSdkTransport — deterministic ids and replay idempotency', () => {
536
+ test('the same snapshot sequence produces the same ids twice', async () => {
537
+ const chunks: UIMessageChunk[] = [
538
+ { type: 'text-start', id: 't1' },
539
+ { type: 'text-delta', id: 't1', delta: 'Hello' },
540
+ { type: 'text-end', id: 't1' },
541
+ { type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
542
+ { type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
543
+ ]
544
+ const transport = aiSdkTransport({
545
+ api: '/api/chat',
546
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
547
+ }).forThread('thread-x')
548
+
549
+ const firstIds = (await collect(transport.stream('hi'))).map((p) => (p as { id: string }).id)
550
+ const secondIds = (await collect(transport.stream('hi'))).map((p) => (p as { id: string }).id)
551
+
552
+ expect(secondIds).toEqual(firstIds)
553
+ expect(firstIds).toEqual(['thread-x#start', 'thread-x#0', 'tool#call-1', 'tool#call-1'])
554
+ })
555
+
556
+ test('replaying the identical delta sequence through mergePart converges — it does not double the parts array (the defect this lane exists to make impossible)', async () => {
557
+ const chunks: UIMessageChunk[] = [
558
+ { type: 'text-start', id: 't1' },
559
+ { type: 'text-delta', id: 't1', delta: 'Hel' },
560
+ { type: 'text-delta', id: 't1', delta: 'lo' },
561
+ { type: 'text-end', id: 't1' },
562
+ ]
563
+ const transport = aiSdkTransport({
564
+ api: '/api/chat',
565
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
566
+ }).forThread('thread-x')
567
+
568
+ const firstPass = await collect(transport.stream('hi'))
569
+ let parts: AgentPart[] = []
570
+ for (const part of firstPass) parts = mergePart(parts, part)
571
+ const afterFirstPass = parts
572
+ expect(afterFirstPass).toHaveLength(2) // start + one merged text part
573
+
574
+ // Simulate a resume()/replay that re-diffs the SAME chunk sequence from scratch (a fresh
575
+ // diffChunkStream call starts `prev = undefined` again, so the first snapshot it sees carries
576
+ // the FULL accumulated text at offset 0 — exactly what a real reconnectToStream reconnect
577
+ // looks like). Same bound transport (same chatId) ⇒ identical ids.
578
+ const secondPass = await collect(transport.stream('hi'))
579
+ for (const part of secondPass) parts = mergePart(parts, part)
580
+
581
+ expect(parts).toEqual(afterFirstPass)
582
+ expect(parts).toHaveLength(2)
583
+ const merged = parts.find((p) => p.type === 'text') as { text: string }
584
+ expect(merged.text).toBe('Hello')
585
+
586
+ // What this guards against: naive array-append accumulation (`[...prev, part]`, this lane's
587
+ // predecessor) would have appended the second pass's deltas onto the first pass's, doubling
588
+ // (and garbling) the rendered text instead of converging.
589
+ const naiveAppendTextParts = [...firstPass, ...secondPass].filter((p) => p.type === 'text')
590
+ expect(naiveAppendTextParts.length).toBeGreaterThan(
591
+ parts.filter((p) => p.type === 'text').length,
592
+ )
268
593
  })
269
594
  })