basalt-ui 1.11.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 (237) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +15 -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.test.d.ts +2 -0
  11. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  12. package/dist/agent/coalesce.d.ts +20 -4
  13. package/dist/agent/coalesce.d.ts.map +1 -1
  14. package/dist/agent/coalesce.js +76 -8
  15. package/dist/agent/coalesce.test.d.ts +2 -0
  16. package/dist/agent/coalesce.test.d.ts.map +1 -0
  17. package/dist/agent/foreign.test.d.ts +2 -0
  18. package/dist/agent/foreign.test.d.ts.map +1 -0
  19. package/dist/agent/id.d.ts +30 -0
  20. package/dist/agent/id.d.ts.map +1 -1
  21. package/dist/agent/id.js +16 -0
  22. package/dist/agent/id.test.d.ts +2 -0
  23. package/dist/agent/id.test.d.ts.map +1 -0
  24. package/dist/agent/index.d.ts +2 -0
  25. package/dist/agent/index.d.ts.map +1 -1
  26. package/dist/agent/index.js +3 -0
  27. package/dist/agent/merge.d.ts.map +1 -1
  28. package/dist/agent/merge.js +19 -7
  29. package/dist/agent/merge.test.d.ts +2 -0
  30. package/dist/agent/merge.test.d.ts.map +1 -0
  31. package/dist/agent/part-list.d.ts +49 -34
  32. package/dist/agent/part-list.d.ts.map +1 -1
  33. package/dist/agent/part-list.js +42 -6
  34. package/dist/agent/part-list.test.d.ts +2 -0
  35. package/dist/agent/part-list.test.d.ts.map +1 -0
  36. package/dist/agent/parts.d.ts +46 -10
  37. package/dist/agent/parts.d.ts.map +1 -1
  38. package/dist/agent/parts.js +16 -6
  39. package/dist/agent/parts.test.d.ts +2 -0
  40. package/dist/agent/parts.test.d.ts.map +1 -0
  41. package/dist/agent/thread.d.ts +14 -0
  42. package/dist/agent/thread.d.ts.map +1 -1
  43. package/dist/agent/thread.js +8 -2
  44. package/dist/agent/thread.test.d.ts +2 -0
  45. package/dist/agent/thread.test.d.ts.map +1 -0
  46. package/dist/agent/transport.test.d.ts +2 -0
  47. package/dist/agent/transport.test.d.ts.map +1 -0
  48. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  49. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  50. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  51. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  52. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  53. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  54. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  55. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  56. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  57. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  58. package/dist/agent-chat/composer.d.ts +130 -8
  59. package/dist/agent-chat/composer.d.ts.map +1 -1
  60. package/dist/agent-chat/composer.js +189 -23
  61. package/dist/agent-chat/composer.test.d.ts +2 -0
  62. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  63. package/dist/agent-chat/index.d.ts +1 -1
  64. package/dist/agent-chat/index.d.ts.map +1 -1
  65. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  66. package/dist/agent-chat/thread-detail-panel.js +1 -1
  67. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  68. package/dist/agent-chat/thread-message.js +14 -3
  69. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  70. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  71. package/dist/agent-chat/thread-workspace.js +1 -1
  72. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  73. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  74. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  75. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  76. package/dist/charts/utils/domain.test.d.ts +2 -0
  77. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  78. package/dist/cli/check-theme.test.d.ts +2 -0
  79. package/dist/cli/check-theme.test.d.ts.map +1 -0
  80. package/dist/cli/help.test.d.ts +2 -0
  81. package/dist/cli/help.test.d.ts.map +1 -0
  82. package/dist/cli/index.d.ts.map +1 -1
  83. package/dist/cli/init-sync.test.d.ts +2 -0
  84. package/dist/cli/init-sync.test.d.ts.map +1 -0
  85. package/dist/cli/placement-engine.test.d.ts +2 -0
  86. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  87. package/dist/cli/tarball-parity.test.d.ts +2 -0
  88. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  89. package/dist/cli/template-format.test.d.ts +2 -0
  90. package/dist/cli/template-format.test.d.ts.map +1 -0
  91. package/dist/cli/template-ledger.test.d.ts +2 -0
  92. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  93. package/dist/cli/tokens-css.test.d.ts +2 -0
  94. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  95. package/dist/content/article-actions.test.d.ts +2 -0
  96. package/dist/content/article-actions.test.d.ts.map +1 -0
  97. package/dist/content/article-model.test.d.ts +2 -0
  98. package/dist/content/article-model.test.d.ts.map +1 -0
  99. package/dist/content/block-split.test.d.ts +2 -0
  100. package/dist/content/block-split.test.d.ts.map +1 -0
  101. package/dist/content/fence-block.d.ts +37 -10
  102. package/dist/content/fence-block.d.ts.map +1 -1
  103. package/dist/content/fence-block.js +80 -14
  104. package/dist/content/heading-components.d.ts.map +1 -1
  105. package/dist/content/heading-components.js +7 -4
  106. package/dist/content/heading-components.test.d.ts +2 -0
  107. package/dist/content/heading-components.test.d.ts.map +1 -0
  108. package/dist/content/index.d.ts +6 -3
  109. package/dist/content/index.d.ts.map +1 -1
  110. package/dist/content/index.js +5 -1
  111. package/dist/content/markdown.d.ts +101 -14
  112. package/dist/content/markdown.d.ts.map +1 -1
  113. package/dist/content/markdown.js +141 -28
  114. package/dist/content/markdown.test.d.ts +2 -0
  115. package/dist/content/markdown.test.d.ts.map +1 -0
  116. package/dist/content/prose.module.css.test.d.ts +2 -0
  117. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  118. package/dist/content/sanitize.d.ts +170 -0
  119. package/dist/content/sanitize.d.ts.map +1 -0
  120. package/dist/content/sanitize.js +178 -0
  121. package/dist/content/sanitize.test.d.ts +2 -0
  122. package/dist/content/sanitize.test.d.ts.map +1 -0
  123. package/dist/content/slug.test.d.ts +2 -0
  124. package/dist/content/slug.test.d.ts.map +1 -0
  125. package/dist/dashboard/stat-card.test.d.ts +2 -0
  126. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  127. package/dist/guard/check-source.test.d.ts +2 -0
  128. package/dist/guard/check-source.test.d.ts.map +1 -0
  129. package/dist/guard/guard-hook.test.d.ts +2 -0
  130. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  131. package/dist/index.d.ts +3 -3
  132. package/dist/index.d.ts.map +1 -1
  133. package/dist/index.js +9 -1
  134. package/dist/provider/index.test.d.ts +2 -0
  135. package/dist/provider/index.test.d.ts.map +1 -0
  136. package/dist/provider/lab-theme.test.d.ts +2 -0
  137. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  138. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  139. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  140. package/dist/styles.floor.test.d.ts +2 -0
  141. package/dist/styles.floor.test.d.ts.map +1 -0
  142. package/dist/surfaces.d.ts +4 -4
  143. package/dist/surfaces.d.ts.map +1 -1
  144. package/dist/surfaces.js +12 -3
  145. package/dist/theme/border-coverage.test.d.ts +18 -0
  146. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  147. package/dist/theme/contrast.test.d.ts +2 -0
  148. package/dist/theme/contrast.test.d.ts.map +1 -0
  149. package/dist/theme/density-option.test.d.ts +2 -0
  150. package/dist/theme/density-option.test.d.ts.map +1 -0
  151. package/dist/theme/density-relations.test.d.ts +2 -0
  152. package/dist/theme/density-relations.test.d.ts.map +1 -0
  153. package/dist/theme/derive-theme.test.d.ts +2 -0
  154. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  155. package/dist/theme/fonts-option.test.d.ts +2 -0
  156. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  157. package/dist/theme/radius-option.test.d.ts +2 -0
  158. package/dist/theme/radius-option.test.d.ts.map +1 -0
  159. package/dist/theme/radius.test.d.ts +2 -0
  160. package/dist/theme/radius.test.d.ts.map +1 -0
  161. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  162. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  163. package/dist/theme/spacing.test.d.ts +2 -0
  164. package/dist/theme/spacing.test.d.ts.map +1 -0
  165. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  166. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  167. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  168. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  169. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  170. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  171. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  172. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  173. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  174. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  175. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  176. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  177. package/dist/tokens/density.test.d.ts +2 -0
  178. package/dist/tokens/density.test.d.ts.map +1 -0
  179. package/dist/tokens/derive-radius.test.d.ts +2 -0
  180. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  181. package/dist/tokens/derive.test.d.ts +2 -0
  182. package/dist/tokens/derive.test.d.ts.map +1 -0
  183. package/dist/tokens/palette.test.d.ts +2 -0
  184. package/dist/tokens/palette.test.d.ts.map +1 -0
  185. package/dist/vite-color-mix.test.d.ts +2 -0
  186. package/dist/vite-color-mix.test.d.ts.map +1 -0
  187. package/dist/vite.test.d.ts +2 -0
  188. package/dist/vite.test.d.ts.map +1 -0
  189. package/llms.txt +6 -6
  190. package/package.json +6 -1
  191. package/src/agent/adapter.test.ts +1403 -0
  192. package/src/agent/adapter.ts +887 -0
  193. package/src/agent/ai-sdk-transport.test.ts +53 -26
  194. package/src/agent/coalesce.test.ts +202 -9
  195. package/src/agent/coalesce.ts +126 -15
  196. package/src/agent/foreign.test.ts +5 -1
  197. package/src/agent/id.test.ts +6 -2
  198. package/src/agent/id.ts +48 -0
  199. package/src/agent/index.ts +8 -0
  200. package/src/agent/merge.test.ts +87 -0
  201. package/src/agent/merge.ts +57 -10
  202. package/src/agent/part-list.test.tsx +31 -1
  203. package/src/agent/part-list.tsx +77 -29
  204. package/src/agent/parts.test.ts +121 -4
  205. package/src/agent/parts.ts +78 -17
  206. package/src/agent/thread.test.ts +75 -2
  207. package/src/agent/thread.ts +21 -1
  208. package/src/agent/use-agent-stream.test.tsx +9 -5
  209. package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
  210. package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
  211. package/src/agent/use-agent-thread-runs.test.tsx +30 -17
  212. package/src/agent/use-agent-thread-runs.wedge.test.tsx +13 -7
  213. package/src/agent-chat/composer.test.tsx +924 -0
  214. package/src/agent-chat/composer.tsx +521 -52
  215. package/src/agent-chat/index.ts +1 -1
  216. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  217. package/src/agent-chat/thread-message.test.tsx +141 -1
  218. package/src/agent-chat/thread-message.tsx +29 -4
  219. package/src/agent-chat/thread-workspace.tsx +1 -1
  220. package/src/cli/index.ts +0 -1
  221. package/src/cli/template-format.test.ts +2 -2
  222. package/src/content/fence-block.tsx +242 -15
  223. package/src/content/heading-components.test.tsx +65 -0
  224. package/src/content/heading-components.tsx +26 -5
  225. package/src/content/index.ts +19 -3
  226. package/src/content/markdown.test.tsx +603 -0
  227. package/src/content/markdown.tsx +355 -35
  228. package/src/content/sanitize.test.ts +659 -0
  229. package/src/content/sanitize.ts +510 -0
  230. package/src/dashboard/stat-card.test.tsx +6 -1
  231. package/src/index.ts +13 -1
  232. package/src/surfaces.ts +12 -3
  233. package/src/theme/contrast.test.ts +1 -4
  234. package/src/theme/density-relations.test.ts +4 -2
  235. package/src/theme/fonts-option.test.ts +7 -1
  236. package/src/theme/shadow-surfaces.test.ts +12 -6
  237. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -23,7 +23,7 @@ import { aiSdkTransport } from './ai-sdk-transport'
23
23
  import { withPartIds } from './id'
24
24
  import { mergePart } from './merge'
25
25
  import type { UIMessageChunk } from 'ai'
26
- import type { AgentPart } from './parts'
26
+ import type { AgentPart, ToolCallPart } from './parts'
27
27
 
28
28
  /**
29
29
  * Builds a fetch-compatible Response whose body is the given low-level UIMessageChunks, SSE-
@@ -43,6 +43,16 @@ function scriptedResponse(chunks: UIMessageChunk[]): Response {
43
43
  return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } })
44
44
  }
45
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
+
46
56
  /** Drains an aiSdkTransport `.stream()`/`.resume()` async generator into a plain array. */
47
57
  async function collect(gen: AsyncGenerator<AgentPart>): Promise<AgentPart[]> {
48
58
  const parts: AgentPart[] = []
@@ -50,11 +60,24 @@ async function collect(gen: AsyncGenerator<AgentPart>): Promise<AgentPart[]> {
50
60
  return parts
51
61
  }
52
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
+
53
71
  /** Strips a non-deterministic `durationMs` (wall-clock, so never equality-comparable) before an
54
- * exact `toEqual`, and separately asserts its type. */
55
- function withoutDurationMs<T extends { durationMs?: number }>(part: T): Omit<T, 'durationMs'> {
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'> {
56
79
  const { durationMs: _durationMs, ...rest } = part
57
- return rest
80
+ return rest as DistributiveOmit<T, 'durationMs'>
58
81
  }
59
82
 
60
83
  describe('aiSdkTransport — snapshot→delta diffing', () => {
@@ -67,7 +90,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
67
90
  ]
68
91
  const transport = aiSdkTransport({
69
92
  api: '/api/chat',
70
- fetch: async () => scriptedResponse(chunks),
93
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
71
94
  })
72
95
  const parts = await collect(transport.stream('hi'))
73
96
 
@@ -107,7 +130,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
107
130
  // trivially fail to match on that alone, masking whether withPartIds itself changed anything.
108
131
  const transport = aiSdkTransport({
109
132
  api: '/api/chat',
110
- fetch: async () => scriptedResponse(chunks),
133
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
111
134
  })
112
135
  const direct = await collect(transport.stream('hi'))
113
136
  const wrapped = await collect(withPartIds('unrelated-run-id', transport.stream('hi')))
@@ -126,7 +149,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
126
149
  ]
127
150
  const transport = aiSdkTransport({
128
151
  api: '/api/chat',
129
- fetch: async () => scriptedResponse(chunks),
152
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
130
153
  })
131
154
  const parts = await collect(transport.stream('hi'))
132
155
 
@@ -146,7 +169,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
146
169
  ]
147
170
  const transport = aiSdkTransport({
148
171
  api: '/api/chat',
149
- fetch: async () => scriptedResponse(chunks),
172
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
150
173
  })
151
174
  const parts = await collect(transport.stream('hi'))
152
175
 
@@ -168,13 +191,16 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
168
191
  ]
169
192
  const transport = aiSdkTransport({
170
193
  api: '/api/chat',
171
- fetch: async () => scriptedResponse(chunks),
194
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
172
195
  })
173
196
  const parts = await collect(transport.stream('hi'))
174
197
 
175
198
  const toolParts = parts.filter((p) => p.type === 'tool')
176
199
  expect(toolParts.every((p) => (p as { id: string }).id === 'tool#call-1')).toBe(true)
177
- expect(toolParts.map(withoutDurationMs)).toEqual([
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'>[] = [
178
204
  {
179
205
  id: 'tool#call-1',
180
206
  type: 'tool',
@@ -199,7 +225,8 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
199
225
  input: { q: 'x' },
200
226
  output: { hits: 3 },
201
227
  },
202
- ])
228
+ ]
229
+ expect(toolParts.map(withoutDurationMs)).toEqual(expectedToolParts)
203
230
  // durationMs is only set once the call reaches a terminal state (output-available here).
204
231
  expect((toolParts[0] as { durationMs?: number }).durationMs).toBeUndefined()
205
232
  expect(typeof (toolParts.at(-1) as { durationMs?: number }).durationMs).toBe('number')
@@ -215,7 +242,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
215
242
  ]
216
243
  const transport = aiSdkTransport({
217
244
  api: '/api/chat',
218
- fetch: async () => scriptedResponse(chunks),
245
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
219
246
  })
220
247
  const parts = await collect(transport.stream('hi'))
221
248
 
@@ -245,7 +272,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
245
272
  ]
246
273
  const transport = aiSdkTransport({
247
274
  api: '/api/chat',
248
- fetch: async () => scriptedResponse(chunks),
275
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
249
276
  })
250
277
  const parts = await collect(transport.stream('hi'))
251
278
 
@@ -282,7 +309,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
282
309
  ]
283
310
  const transport = aiSdkTransport({
284
311
  api: '/api/chat',
285
- fetch: async () => scriptedResponse(chunks),
312
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
286
313
  })
287
314
  const parts = await collect(transport.stream('hi'))
288
315
 
@@ -330,7 +357,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
330
357
  ]
331
358
  const transport = aiSdkTransport({
332
359
  api: '/api/chat',
333
- fetch: async () => scriptedResponse(chunks),
360
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
334
361
  })
335
362
  const parts = await collect(transport.stream('hi'))
336
363
 
@@ -362,7 +389,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
362
389
  ]
363
390
  const transport = aiSdkTransport({
364
391
  api: '/api/chat',
365
- fetch: async () => scriptedResponse(chunks),
392
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
366
393
  })
367
394
  const parts = await collect(transport.stream('hi'))
368
395
 
@@ -394,7 +421,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
394
421
  ]
395
422
  const transport = aiSdkTransport({
396
423
  api: '/api/chat',
397
- fetch: async () => scriptedResponse(chunks),
424
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
398
425
  })
399
426
  const parts = await collect(transport.stream('hi'))
400
427
 
@@ -415,7 +442,7 @@ describe('aiSdkTransport — signal.aborted stops the yield', () => {
415
442
  ]
416
443
  const transport = aiSdkTransport({
417
444
  api: '/api/chat',
418
- fetch: async () => scriptedResponse(chunks),
445
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
419
446
  })
420
447
  const controller = new AbortController()
421
448
  const gen = transport.stream('hi', controller.signal)
@@ -446,9 +473,9 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
446
473
  // generator that never advances past its first `.next()` never touches the network.
447
474
  const transport = aiSdkTransport({
448
475
  api: '/api/chat',
449
- fetch: () => {
476
+ fetch: mockFetch(() => {
450
477
  throw new Error('must not be called — only the first, pre-network yield is read')
451
- },
478
+ }),
452
479
  })
453
480
 
454
481
  const first = await transport.stream('a').next()
@@ -460,9 +487,9 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
460
487
  test('.forThread(id) binds the StartPart runId/resumeToken/id deterministically to the given id', async () => {
461
488
  const transport = aiSdkTransport({
462
489
  api: '/api/chat',
463
- fetch: () => {
490
+ fetch: mockFetch(() => {
464
491
  throw new Error('must not be called — only the first, pre-network yield is read')
465
- },
492
+ }),
466
493
  })
467
494
  const bound = transport.forThread('caller-supplied-thread-id')
468
495
 
@@ -482,9 +509,9 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
482
509
  test('two different .forThread() ids produce two different StartPart runIds/ids', async () => {
483
510
  const transport = aiSdkTransport({
484
511
  api: '/api/chat',
485
- fetch: () => {
512
+ fetch: mockFetch(() => {
486
513
  throw new Error('must not be called — only the first, pre-network yield is read')
487
- },
514
+ }),
488
515
  })
489
516
 
490
517
  const a = await transport.forThread('thread-a').stream('hi').next()
@@ -516,7 +543,7 @@ describe('aiSdkTransport — deterministic ids and replay idempotency', () => {
516
543
  ]
517
544
  const transport = aiSdkTransport({
518
545
  api: '/api/chat',
519
- fetch: async () => scriptedResponse(chunks),
546
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
520
547
  }).forThread('thread-x')
521
548
 
522
549
  const firstIds = (await collect(transport.stream('hi'))).map((p) => (p as { id: string }).id)
@@ -535,7 +562,7 @@ describe('aiSdkTransport — deterministic ids and replay idempotency', () => {
535
562
  ]
536
563
  const transport = aiSdkTransport({
537
564
  api: '/api/chat',
538
- fetch: async () => scriptedResponse(chunks),
565
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
539
566
  }).forThread('thread-x')
540
567
 
541
568
  const firstPass = await collect(transport.stream('hi'))
@@ -136,6 +136,8 @@ describe('coalesceParts', () => {
136
136
  state: 'input-available',
137
137
  input: { q: 'x' },
138
138
  }
139
+ // `toolName` keeps its own empty-string convention for "omitted" (unlike toolCallId, whose
140
+ // absence is now modelled in the type) — the strict type still requires the field, hence the cast.
139
141
  const outputAvailableNoToolName = {
140
142
  id: 'p2',
141
143
  type: 'tool',
@@ -150,6 +152,10 @@ describe('coalesceParts', () => {
150
152
  expect((out[0] as ToolCallPart).toolName).toBe('search')
151
153
  })
152
154
 
155
+ // The SDK's raw approval-responded chunk arrives without its own toolCallId — one of the four
156
+ // SETTLED states whose id is optional in the type (parts.ts, MaybeIdentified), so every fixture
157
+ // below builds a legal wire shape directly, with no cast.
158
+
153
159
  test('an approval-responded part with no toolCallId is resolved via the approvalId side index', () => {
154
160
  const approvalRequested: ToolCallPart = {
155
161
  id: 'p1',
@@ -160,35 +166,222 @@ describe('coalesceParts', () => {
160
166
  input: { path: '/etc/passwd' },
161
167
  approval: { id: 'appr-1' },
162
168
  }
163
- // The SDK's raw approval-responded chunk arrives without its own toolCallId — resolved by
164
- // reverse lookup on approval.id instead.
165
- const approvalResponded = {
169
+ const approvalResponded: ToolCallPart = {
166
170
  id: 'p2',
167
171
  type: 'tool',
168
- toolCallId: '',
169
172
  toolName: 'delete_file',
170
173
  state: 'approval-responded',
171
174
  input: { path: '/etc/passwd' },
172
175
  approval: { id: 'appr-1', approved: true },
173
- } as unknown as ToolCallPart
176
+ }
174
177
 
175
178
  const out = coalesceParts([approvalRequested, approvalResponded])
176
179
  expect(out).toHaveLength(1)
177
- expect((out[0] as ToolCallPart).state).toBe('approval-responded')
180
+ const merged = out[0] as ToolCallPart
181
+ expect(merged.state).toBe('approval-responded')
182
+ // The resolved call keeps the identity the request supplied — the response had none.
183
+ expect(merged.toolCallId).toBe('call-1')
178
184
  })
179
185
 
180
186
  test('a tool part unresolvable by either toolCallId or approvalId is kept as-is, never dropped', () => {
181
- const orphan = {
187
+ const orphan: ToolCallPart = {
182
188
  id: 'p1',
183
189
  type: 'tool',
184
- toolCallId: '',
185
190
  toolName: 'mystery',
186
191
  state: 'approval-responded',
187
192
  input: {},
188
193
  approval: { id: 'unknown-approval', approved: true },
189
- } as unknown as ToolCallPart
194
+ }
190
195
 
191
196
  const out = coalesceParts([orphan])
192
197
  expect(out).toEqual([orphan])
193
198
  })
199
+
200
+ test('an approval-responded seen BEFORE its request stays its own block — the fold never backtracks', () => {
201
+ // The approvalId side index only holds ids seen so far, so a response arriving first cannot
202
+ // resolve. Deliberate: reconciling backwards would let the request (the LATER arrival) win
203
+ // under "later state wins" and re-render an already-answered call as still pending.
204
+ const respondedFirst: ToolCallPart = {
205
+ id: 'p1',
206
+ type: 'tool',
207
+ toolName: 'delete_file',
208
+ state: 'approval-responded',
209
+ input: { path: '/etc/passwd' },
210
+ approval: { id: 'appr-1', approved: true },
211
+ }
212
+ const requestedLater: ToolCallPart = {
213
+ id: 'p2',
214
+ type: 'tool',
215
+ toolCallId: 'call-1',
216
+ toolName: 'delete_file',
217
+ state: 'approval-requested',
218
+ input: { path: '/etc/passwd' },
219
+ approval: { id: 'appr-1' },
220
+ }
221
+ const outputAvailable: ToolCallPart = {
222
+ id: 'p3',
223
+ type: 'tool',
224
+ toolCallId: 'call-1',
225
+ toolName: 'delete_file',
226
+ state: 'output-available',
227
+ input: { path: '/etc/passwd' },
228
+ output: { deleted: true },
229
+ }
230
+
231
+ const out = coalesceParts([respondedFirst, requestedLater, outputAvailable])
232
+ expect(out).toHaveLength(2)
233
+ // The orphan survives untouched at its original position — never dropped, never rewritten.
234
+ expect(out[0]).toEqual(respondedFirst)
235
+ // Everything from the request onward folds into ONE second block, not a third.
236
+ expect((out[1] as ToolCallPart).state).toBe('output-available')
237
+ })
238
+
239
+ test('a failed call keeps NO stale output from the preliminary result it replaces', () => {
240
+ // The defect a blind `{ ...existing, ...next }` spread produced: output-error has no `output`
241
+ // key, so a spread let the preliminary result's `output`/`preliminary` ride along and any
242
+ // consumer branching on `output` before `errorText` rendered a failed call as successful.
243
+ const preliminaryResult: ToolCallPart = {
244
+ id: 'p1',
245
+ type: 'tool',
246
+ toolCallId: 'call-1',
247
+ toolName: 'search',
248
+ state: 'output-available',
249
+ input: { q: 'x' },
250
+ output: { hits: 1, partial: true },
251
+ preliminary: true,
252
+ }
253
+ const failed: ToolCallPart = {
254
+ id: 'p2',
255
+ type: 'tool',
256
+ toolCallId: 'call-1',
257
+ toolName: 'search',
258
+ state: 'output-error',
259
+ errorText: 'upstream timed out',
260
+ durationMs: 900,
261
+ }
262
+
263
+ const out = coalesceParts([preliminaryResult, failed])
264
+ expect(out).toHaveLength(1)
265
+ const merged = out[0] as ToolCallPart
266
+ expect(merged.state).toBe('output-error')
267
+ expect(Object.hasOwn(merged, 'output')).toBe(false)
268
+ expect(Object.hasOwn(merged, 'preliminary')).toBe(false)
269
+ expect(merged).toEqual({
270
+ id: 'p2',
271
+ type: 'tool',
272
+ toolCallId: 'call-1',
273
+ toolName: 'search',
274
+ durationMs: 900,
275
+ state: 'output-error',
276
+ errorText: 'upstream timed out',
277
+ // input is the one thing legitimately carried across the transition.
278
+ input: { q: 'x' },
279
+ })
280
+ })
281
+
282
+ test('a SETTLED part that arrived unidentified keeps the identity an earlier state supplied', () => {
283
+ // `toolCallId` is optional on the four settled states (parts.ts, MaybeIdentified). A terminal
284
+ // part that resolved only through the approvalId side index must come out of the merge WITH the
285
+ // id the request carried — losing it here would unfold the block on the next pass.
286
+ const approvalRequested: ToolCallPart = {
287
+ id: 'p1',
288
+ type: 'tool',
289
+ toolCallId: 'call-1',
290
+ toolName: 'transfer_funds',
291
+ state: 'approval-requested',
292
+ input: { amount: 250 },
293
+ approval: { id: 'appr-1' },
294
+ }
295
+ const unidentifiedResult: ToolCallPart = {
296
+ id: 'p2',
297
+ type: 'tool',
298
+ toolName: 'transfer_funds',
299
+ state: 'output-available',
300
+ input: { amount: 250 },
301
+ output: { ok: true },
302
+ approval: { id: 'appr-1', approved: true },
303
+ }
304
+
305
+ const out = coalesceParts([approvalRequested, unidentifiedResult])
306
+ expect(out).toHaveLength(1)
307
+ const merged = out[0] as ToolCallPart
308
+ expect(merged.state).toBe('output-available')
309
+ expect(merged.toolCallId).toBe('call-1')
310
+ })
311
+
312
+ // ── never throws ───────────────────────────────────────────────────────────
313
+ // The tool-state switch is exhaustive at COMPILE time only. coalesceParts is public, runs on the
314
+ // render path, and folds wire data — an unrecognized state must degrade, not abort the render.
315
+
316
+ test('an unrecognized tool state does not throw and yields a sensible merged part', () => {
317
+ const inputAvailable: ToolCallPart = {
318
+ id: 'p1',
319
+ type: 'tool',
320
+ toolCallId: 'call-1',
321
+ toolName: 'search',
322
+ state: 'input-available',
323
+ input: { q: 'x' },
324
+ }
325
+ // A state no version of the union declares — what a newer server would send to an older client.
326
+ const futureState = {
327
+ id: 'p2',
328
+ type: 'tool',
329
+ toolCallId: 'call-1',
330
+ toolName: '',
331
+ state: 'output-streaming',
332
+ output: { partial: true },
333
+ } as unknown as ToolCallPart
334
+
335
+ const warnings: string[] = []
336
+ const original = console.warn
337
+ console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
338
+ let out: ToolCallPart[]
339
+ try {
340
+ out = coalesceParts([inputAvailable, futureState]) as ToolCallPart[]
341
+ } finally {
342
+ console.warn = original
343
+ }
344
+
345
+ expect(out).toHaveLength(1)
346
+ // `state` widened to `string` — the whole point of this test is a state value the
347
+ // `ToolCallState` union does NOT declare (a future server sending an unknown state to an
348
+ // older client), so the real, narrower type would reject the very literal being asserted.
349
+ const merged = out[0] as Omit<ToolCallPart, 'state'> & { output?: unknown; state: string }
350
+ // The incoming part survives intact…
351
+ expect(merged.state).toBe('output-streaming')
352
+ expect(merged.output).toEqual({ partial: true })
353
+ // …with the whitelisted carry-over fields folded in, exactly as a known state would get.
354
+ expect(merged.toolName).toBe('search')
355
+ expect(merged.toolCallId).toBe('call-1')
356
+ // …and the anomaly is announced in dev rather than thrown.
357
+ expect(warnings).toHaveLength(1)
358
+ expect(warnings[0]).toContain('unrecognized tool state')
359
+ })
360
+
361
+ test('an approval-requested that later succeeds does not carry its pending approval onto the result', () => {
362
+ const approvalRequested: ToolCallPart = {
363
+ id: 'p1',
364
+ type: 'tool',
365
+ toolCallId: 'call-1',
366
+ toolName: 'transfer_funds',
367
+ state: 'approval-requested',
368
+ input: { amount: 250 },
369
+ approval: { id: 'appr-1' },
370
+ }
371
+ const outputAvailable: ToolCallPart = {
372
+ id: 'p2',
373
+ type: 'tool',
374
+ toolCallId: 'call-1',
375
+ toolName: 'transfer_funds',
376
+ state: 'output-available',
377
+ input: { amount: 250 },
378
+ output: { ok: true },
379
+ }
380
+
381
+ const out = coalesceParts([approvalRequested, outputAvailable])
382
+ expect(out).toHaveLength(1)
383
+ // `approval` comes from `next` alone: a pending request envelope (no verdict) must not surface
384
+ // on a settled result, where the type only admits `{ approved?: true }`.
385
+ expect(Object.hasOwn(out[0] as object, 'approval')).toBe(false)
386
+ })
194
387
  })
@@ -12,10 +12,26 @@
12
12
  * wire, the SDK re-supplies `input` by reading it back off the stored invocation, and a merge that
13
13
  * replaces wholesale rather than accumulating would lose it outright.
14
14
  *
15
- * `approval`-bearing responses can arrive without a usable `toolCallId` (the SDK resolves them by
16
- * reverse lookup on `approval.id` instead) — this maintains an `approvalId -> toolCallId` side
17
- * index while folding and routes such a part through it. A part resolvable by neither key is kept
18
- * as-is rather than dropped; never silently discard a part.
15
+ * A settled tool part can arrive without a `toolCallId` at all — `approval-responded` plus the
16
+ * three terminal states all carry it as optional (see `MaybeIdentified` in `parts.ts`).
17
+ * `approval-responded` is where the WIRE itself omits it: the SDK's `tool-approval-response` chunk
18
+ * carries no `toolCallId`, only `approval.id`, so it is resolved by reverse lookup on `approval.id`
19
+ * instead — this maintains an `approvalId -> toolCallId` side index while folding and routes such a
20
+ * part through it. A part resolvable by neither key is kept as-is rather than dropped; never
21
+ * silently discard a part.
22
+ *
23
+ * The fold is strictly LEFT-TO-RIGHT with no backtracking: the side index only holds ids seen so
24
+ * far, so an unidentified `approval-responded` arriving BEFORE its `approval-requested` stays its
25
+ * own block and the later request opens a second one. That is deliberate, not an oversight —
26
+ * reconciling backwards would let the request (the later arrival) win under the "later state wins"
27
+ * rule and render an already-answered call as still pending, which is worse than two blocks in an
28
+ * ordering the wire never produces.
29
+ *
30
+ * **This function never throws.** It is public, it runs on the render path, and it folds data that
31
+ * came off a wire — so every anomaly (an unrecognized tool state, an unresolvable id) degrades to
32
+ * "keep the part as it arrived", announced in dev, rather than aborting the render. A throw here
33
+ * would turn one malformed chunk into a permanently dead transcript. Exhaustiveness over the tool
34
+ * states is still enforced, but at COMPILE time only (see `mergeToolPart`'s default branch).
19
35
  *
20
36
  * @example
21
37
  * import { coalesceParts } from 'basalt-ui/agent'
@@ -35,7 +51,7 @@ function resolveToolCallId(
35
51
  part: ToolCallPart,
36
52
  toolCallIdByApprovalId: Map<string, string>,
37
53
  ): string | undefined {
38
- if (typeof part.toolCallId === 'string' && part.toolCallId.length > 0) return part.toolCallId
54
+ if (part.toolCallId !== undefined) return part.toolCallId
39
55
  const approval = 'approval' in part ? part.approval : undefined
40
56
  return approval !== undefined ? toolCallIdByApprovalId.get(approval.id) : undefined
41
57
  }
@@ -48,24 +64,119 @@ function registerApprovalIndex(
48
64
  ): void {
49
65
  const approval = 'approval' in part ? part.approval : undefined
50
66
  if (approval === undefined) return
51
- if (typeof part.toolCallId !== 'string' || part.toolCallId.length === 0) return
67
+ if (part.toolCallId === undefined) return
52
68
  toolCallIdByApprovalId.set(approval.id, part.toolCallId)
53
69
  }
54
70
 
55
- /** The later state always wins; `input`/`toolName` from the earlier state survive a later state
56
- * that omits them. */
71
+ /**
72
+ * The later state always wins; `toolCallId`/`toolName`/`durationMs`/`providerExecuted` and `input`
73
+ * from the earlier state survive a later state that omits them.
74
+ *
75
+ * The result is RECONSTRUCTED per `next.state`, building only the fields that state legally has —
76
+ * never a blind `{ ...existing, ...next }` spread. A spread lets any field the next state does not
77
+ * redeclare survive onto it: an `output-available` (with `output`, possibly `preliminary`) that
78
+ * fails to `output-error` (which has no `output` key) would keep a stale `output` alongside
79
+ * `errorText`, and a consumer branching on `output` before `errorText` renders a failed call as
80
+ * successful. Only the fields listed above are carried; `output`, `errorText`, `rawInput`,
81
+ * `preliminary` and `approval` come from `next` alone, so nothing from a prior state can leak into
82
+ * a member that has no room for it. Reconstruction is exact enough that tsc proves each returned
83
+ * member valid — there is deliberately no cast here.
84
+ */
57
85
  function mergeToolPart(existing: ToolCallPart, next: ToolCallPart): ToolCallPart {
58
86
  const existingInput = 'input' in existing ? existing.input : undefined
59
87
  const nextInput = 'input' in next ? next.input : undefined
60
88
  const input = nextInput !== undefined ? nextInput : existingInput
61
- const toolName = next.toolName.length > 0 ? next.toolName : existing.toolName
89
+ const durationMs = next.durationMs ?? existing.durationMs
90
+ const providerExecuted = next.providerExecuted ?? existing.providerExecuted
91
+ // `toolCallId` is optional on the four settled states (see `MaybeIdentified` in parts.ts), so a
92
+ // later part that arrived unidentified — resolved here through the approvalId side index — keeps
93
+ // the identity an earlier state supplied instead of losing it on the way through the merge.
94
+ const settledToolCallId = next.toolCallId ?? existing.toolCallId
95
+
96
+ const common = {
97
+ id: next.id,
98
+ type: 'tool' as const,
99
+ toolName: next.toolName.length > 0 ? next.toolName : existing.toolName,
100
+ ...(durationMs !== undefined ? { durationMs } : {}),
101
+ ...(providerExecuted !== undefined ? { providerExecuted } : {}),
102
+ }
62
103
 
63
- return {
64
- ...existing,
65
- ...next,
66
- toolName,
67
- ...(input !== undefined ? { input } : {}),
68
- } as ToolCallPart
104
+ switch (next.state) {
105
+ case 'input-streaming':
106
+ return {
107
+ ...common,
108
+ toolCallId: next.toolCallId,
109
+ state: 'input-streaming',
110
+ ...(input !== undefined ? { input } : {}),
111
+ }
112
+ case 'input-available':
113
+ return { ...common, toolCallId: next.toolCallId, state: 'input-available', input }
114
+ case 'approval-requested':
115
+ return {
116
+ ...common,
117
+ toolCallId: next.toolCallId,
118
+ state: 'approval-requested',
119
+ input,
120
+ approval: next.approval,
121
+ }
122
+ case 'approval-responded':
123
+ return {
124
+ ...common,
125
+ ...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
126
+ state: 'approval-responded',
127
+ input,
128
+ approval: next.approval,
129
+ }
130
+ case 'output-available':
131
+ return {
132
+ ...common,
133
+ ...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
134
+ state: 'output-available',
135
+ input,
136
+ output: next.output,
137
+ ...(next.preliminary !== undefined ? { preliminary: next.preliminary } : {}),
138
+ ...(next.approval !== undefined ? { approval: next.approval } : {}),
139
+ }
140
+ case 'output-error':
141
+ return {
142
+ ...common,
143
+ ...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
144
+ state: 'output-error',
145
+ errorText: next.errorText,
146
+ ...(input !== undefined ? { input } : {}),
147
+ ...(next.rawInput !== undefined ? { rawInput: next.rawInput } : {}),
148
+ ...(next.approval !== undefined ? { approval: next.approval } : {}),
149
+ }
150
+ case 'output-denied':
151
+ return {
152
+ ...common,
153
+ ...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
154
+ state: 'output-denied',
155
+ input,
156
+ approval: next.approval,
157
+ }
158
+ default: {
159
+ // Exhaustive at COMPILE time, defensive at RUNTIME. Binding `next` at `never` keeps tsc's
160
+ // guarantee — add a state to `ToolCallPart` without a case above and this line stops
161
+ // compiling — but this branch must NOT throw. `coalesceParts` is public, runs on the render
162
+ // path, and folds wire data: a throw would turn one transient wire anomaly into a
163
+ // permanently dead transcript (the same trade `spliceText` makes in merge.ts, where an
164
+ // out-of-range offset is clamped rather than thrown on). An unrecognized state therefore
165
+ // falls back to the incoming part with only the whitelisted carry-over fields folded in —
166
+ // nothing state-specific from `existing` can leak, because `common` is all that is merged.
167
+ const unhandled: never = next
168
+ // Widened back deliberately: control-flow analysis would keep an annotated const at `never`
169
+ // and make every field read below a compile error, so the assertion is what makes the
170
+ // runtime value usable. It asserts nothing tsc has not already proved on the line above.
171
+ const fallback = unhandled as ToolCallPart
172
+ if (process.env['NODE_ENV'] !== 'production') {
173
+ console.warn(
174
+ `[basalt] coalesceParts: unrecognized tool state ${JSON.stringify(fallback.state)} — kept, merged best-effort`,
175
+ )
176
+ }
177
+ return { ...fallback, ...common }
178
+ }
179
+ }
69
180
  }
70
181
 
71
182
  export function coalesceParts<TPart extends AgentPart>(parts: readonly TPart[]): TPart[] {
@@ -8,9 +8,13 @@
8
8
  import { describe, expect, test } from 'bun:test'
9
9
  import { definePartRenderers, narrowAgentPart } from './foreign'
10
10
  import type { TranscriptPart } from './foreign'
11
+ import type { AgentPart } from './parts'
11
12
 
12
13
  describe('narrowAgentPart', () => {
13
- const BUILT_IN_SAMPLES: TranscriptPart[] = [
14
+ // Typed AgentPart[], not TranscriptPart[]: every literal here IS a built-in variant (the whole
15
+ // point of this fixture), and narrowAgentPart's return type is AgentPart | null — comparing it
16
+ // against a TranscriptPart-typed element (which also admits ForeignPart) doesn't type-check.
17
+ const BUILT_IN_SAMPLES: AgentPart[] = [
14
18
  { id: 'p1', type: 'start', runId: 'r1' },
15
19
  { id: 'p2', type: 'text', text: 'hi' },
16
20
  { id: 'p3', type: 'reasoning', text: 'thinking' },