basalt-ui 1.10.0 → 1.11.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 (88) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +10 -0
  3. package/dist/agent/ai-sdk-transport.d.ts +3 -52
  4. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  5. package/dist/agent/ai-sdk-transport.js +108 -24
  6. package/dist/agent/coalesce.d.ts +26 -0
  7. package/dist/agent/coalesce.d.ts.map +1 -0
  8. package/dist/agent/coalesce.js +69 -0
  9. package/dist/agent/foreign.d.ts +97 -0
  10. package/dist/agent/foreign.d.ts.map +1 -0
  11. package/dist/agent/foreign.js +18 -0
  12. package/dist/agent/history.d.ts +2 -0
  13. package/dist/agent/history.d.ts.map +1 -1
  14. package/dist/agent/id.d.ts +35 -0
  15. package/dist/agent/id.d.ts.map +1 -0
  16. package/dist/agent/id.js +14 -0
  17. package/dist/agent/index.d.ts +9 -4
  18. package/dist/agent/index.d.ts.map +1 -1
  19. package/dist/agent/index.js +15 -3
  20. package/dist/agent/merge.d.ts +8 -0
  21. package/dist/agent/merge.d.ts.map +1 -0
  22. package/dist/agent/merge.js +23 -0
  23. package/dist/agent/part-list.d.ts.map +1 -1
  24. package/dist/agent/part-list.js +16 -6
  25. package/dist/agent/parts.d.ts +100 -21
  26. package/dist/agent/parts.d.ts.map +1 -1
  27. package/dist/agent/parts.js +128 -11
  28. package/dist/agent/transport.d.ts +39 -3
  29. package/dist/agent/transport.d.ts.map +1 -1
  30. package/dist/agent/transport.js +20 -17
  31. package/dist/agent/use-agent-stream.d.ts +2 -1
  32. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  33. package/dist/agent/use-agent-stream.js +5 -2
  34. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  35. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  36. package/dist/agent/use-agent-thread-runs.js +75 -23
  37. package/dist/agent-chat/index.d.ts +2 -0
  38. package/dist/agent-chat/index.d.ts.map +1 -1
  39. package/dist/agent-chat/index.js +2 -0
  40. package/dist/agent-chat/thread-message.d.ts +24 -7
  41. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  42. package/dist/agent-chat/thread-message.js +102 -43
  43. package/dist/agent-chat/tool-chip.d.ts +21 -0
  44. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  45. package/dist/agent-chat/tool-chip.js +120 -0
  46. package/dist/guard/index.d.ts.map +1 -1
  47. package/dist/guard/index.js +1 -3
  48. package/dist/index.d.ts +2 -2
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +3 -1
  51. package/dist/surfaces.d.ts +2 -2
  52. package/dist/surfaces.js +2 -2
  53. package/llms.txt +4 -4
  54. package/package.json +1 -1
  55. package/src/agent/ai-sdk-transport.test.ts +338 -40
  56. package/src/agent/ai-sdk-transport.ts +206 -53
  57. package/src/agent/coalesce.test.ts +194 -0
  58. package/src/agent/coalesce.ts +118 -0
  59. package/src/agent/foreign.test.ts +47 -0
  60. package/src/agent/foreign.ts +140 -0
  61. package/src/agent/history.ts +2 -0
  62. package/src/agent/id.test.ts +71 -0
  63. package/src/agent/id.ts +43 -0
  64. package/src/agent/index.ts +26 -3
  65. package/src/agent/merge.test.ts +119 -0
  66. package/src/agent/merge.ts +67 -0
  67. package/src/agent/part-list.test.tsx +82 -0
  68. package/src/agent/part-list.tsx +31 -6
  69. package/src/agent/parts.test.ts +401 -59
  70. package/src/agent/parts.ts +262 -32
  71. package/src/agent/transport.test.ts +70 -0
  72. package/src/agent/transport.ts +72 -20
  73. package/src/agent/use-agent-stream.test.tsx +47 -17
  74. package/src/agent/use-agent-stream.ts +13 -3
  75. package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
  76. package/src/agent/use-agent-thread-runs.resume.test.tsx +34 -6
  77. package/src/agent/use-agent-thread-runs.test.tsx +397 -14
  78. package/src/agent/use-agent-thread-runs.ts +170 -26
  79. package/src/agent/use-agent-thread-runs.wedge.test.tsx +4 -2
  80. package/src/agent-chat/index.ts +4 -0
  81. package/src/agent-chat/thread-message.test.tsx +211 -0
  82. package/src/agent-chat/thread-message.tsx +235 -62
  83. package/src/agent-chat/tool-chip.test.tsx +139 -0
  84. package/src/agent-chat/tool-chip.tsx +219 -0
  85. package/src/guard/check-source.test.ts +8 -4
  86. package/src/guard/index.ts +1 -3
  87. package/src/index.ts +2 -0
  88. package/src/surfaces.ts +2 -2
@@ -47,13 +47,22 @@
47
47
  * resolveOutcome: heuristicOutcome,
48
48
  * })
49
49
  */
50
- import type { AgentPart, SourcePart, ToolCallPart } from './parts'
51
- import type { AgentTransport } from './transport'
50
+ import { assertNever } from '../register'
51
+ import { TERMINAL_TOOL_STATES } from './parts'
52
+ import type { AgentPart, AgentPartDraft } from './parts'
53
+ import type { ResumableAgentTransport } from './transport'
52
54
 
53
55
  // ── ai (optional peer) — TYPE-ONLY imports, erased at runtime ────────────────
54
56
  // verbatimModuleSyntax-safe: these never trigger a runtime resolution of 'ai'. The actual runtime
55
57
  // access goes through the memoized `import('ai')` inside resolveAiSdk() below.
56
- import type { DefaultChatTransport, UIMessage, UIMessageChunk, readUIMessageStream } from 'ai'
58
+ import type {
59
+ DefaultChatTransport,
60
+ DynamicToolUIPart,
61
+ ToolUIPart,
62
+ UIMessage,
63
+ UIMessageChunk,
64
+ readUIMessageStream,
65
+ } from 'ai'
57
66
 
58
67
  /** The element type of an AI SDK UIMessage's `parts` array (defaults applied). */
59
68
  type AiPart = UIMessage['parts'][number]
@@ -76,13 +85,13 @@ export type AiSdkTransportOptions = {
76
85
 
77
86
  // ── AiSdkTransport ─────────────────────────────────────────────────────────────
78
87
 
79
- export type AiSdkTransport<TPart = AgentPart> = AgentTransport<TPart, string> & {
88
+ export type AiSdkTransport<TPart = AgentPart> = ResumableAgentTransport<TPart, string> & {
80
89
  /**
81
90
  * Returns an AgentTransport bound to a stable chat id (e.g. a basalt-ui thread id) instead of
82
91
  * this transport's own fixed one. Use this for useAgentThreadRuns's per-thread transport-factory
83
92
  * form so each thread's turns share one continuous AI SDK chat session.
84
93
  */
85
- readonly forThread: (chatId: string) => AgentTransport<TPart, string>
94
+ readonly forThread: (chatId: string) => ResumableAgentTransport<TPart, string>
86
95
  }
87
96
 
88
97
  // ── Lazy, memoized 'ai' resolution ────────────────────────────────────────────
@@ -120,80 +129,209 @@ function createAiSdkResolver(options: AiSdkTransportOptions): () => Promise<Reso
120
129
 
121
130
  /**
122
131
  * Diffs one AI SDK part (by array index) against its previous snapshot at that same index,
123
- * returning zero or more new AgentPart deltas.
132
+ * returning zero or more new AgentPart drafts, each carrying a deterministic id.
124
133
  *
125
- * source-document, file, reasoning-file, data-*, step-start, custom, and dynamic-tool parts have
126
- * no AgentPart equivalent yet — deliberate v1 gap, skipped rather than inventing new variants.
134
+ * Index-addressed parts (text, reasoning, source) get `${chatId}#${index}` — stable across
135
+ * replays of the same snapshot sequence, which is what lets `mergePart` rewrite instead of
136
+ * duplicate on a resumed/replayed stream. text/reasoning also carry an authoritative `offset`
137
+ * (the length of the previously-seen text for that part) so a full-length resend at offset 0
138
+ * splices over the existing content instead of appending — the literal mechanism
139
+ * `idempotentReplay: true` asserts.
140
+ *
141
+ * source-document, file, reasoning-file, data-*, step-start, and custom parts have no AgentPart
142
+ * equivalent yet — deliberate v1 gap, skipped rather than inventing new variants.
127
143
  */
128
- function diffPart(prevPart: AiPart | undefined, currPart: AiPart): AgentPart[] {
144
+ function diffPart(
145
+ chatId: string,
146
+ index: number,
147
+ prevPart: AiPart | undefined,
148
+ currPart: AiPart,
149
+ toolStartTimes: Map<string, number>,
150
+ ): AgentPartDraft[] {
129
151
  switch (currPart.type) {
130
152
  case 'text': {
131
153
  const prevText = prevPart?.type === 'text' ? prevPart.text : ''
132
154
  const delta = currPart.text.slice(prevText.length)
133
- return delta.length === 0 ? [] : [{ type: 'text', text: delta }]
155
+ return delta.length === 0
156
+ ? []
157
+ : [{ id: `${chatId}#${index}`, type: 'text', text: delta, offset: prevText.length }]
134
158
  }
135
159
  case 'reasoning': {
136
160
  const prevText = prevPart?.type === 'reasoning' ? prevPart.text : ''
137
161
  const delta = currPart.text.slice(prevText.length)
138
- return delta.length === 0 ? [] : [{ type: 'reasoning', text: delta }]
162
+ return delta.length === 0
163
+ ? []
164
+ : [{ id: `${chatId}#${index}`, type: 'reasoning', text: delta, offset: prevText.length }]
139
165
  }
140
166
  case 'source-url': {
141
167
  // Emit once, the first time this part appears at this index.
142
168
  if (prevPart !== undefined) return []
143
- const source: SourcePart = { type: 'source', url: currPart.url }
169
+ const source: AgentPartDraft = { id: `${chatId}#${index}`, type: 'source', url: currPart.url }
144
170
  return [currPart.title !== undefined ? { ...source, title: currPart.title } : source]
145
171
  }
146
172
  default:
147
- return currPart.type.startsWith('tool-') ? diffToolPart(prevPart, currPart) : []
173
+ return isToolLikePart(currPart) ? diffToolPart(prevPart, currPart, toolStartTimes) : []
148
174
  }
149
175
  }
150
176
 
177
+ /** True for a `tool-${name}` static part OR a `dynamic-tool` part — the two AI SDK shapes basalt
178
+ * models as one `ToolCallPart`. `'dynamic-tool'` does NOT start with `'tool-'`, so this must be
179
+ * its own check, not a `.startsWith('tool-')` alone (that silently drops every dynamic tool call). */
180
+ function isToolLikePart(part: AiPart): part is ToolUIPart | DynamicToolUIPart {
181
+ return part.type === 'dynamic-tool' || part.type.startsWith('tool-')
182
+ }
183
+
184
+ /** Derives the tool's display name — mirrors AI SDK's own `getToolName`/`getStaticToolName`
185
+ * runtime (reimplemented locally rather than value-imported, so this module never resolves 'ai'
186
+ * at runtime; see the module docblock). Static tools carry their name only in the `tool-${name}`
187
+ * discriminator; dynamic tools carry it in `toolName`. */
188
+ function deriveToolName(part: ToolUIPart | DynamicToolUIPart): string {
189
+ return part.type === 'dynamic-tool' ? part.toolName : part.type.split('-').slice(1).join('-')
190
+ }
191
+
192
+ /** The `{ output, preliminary }` snapshot of an `output-available` tool part, or undefined for
193
+ * any other state. */
194
+ function outputSnapshot(
195
+ part: ToolUIPart | DynamicToolUIPart,
196
+ ): { readonly output: unknown; readonly preliminary: boolean | undefined } | undefined {
197
+ return part.state === 'output-available'
198
+ ? { output: part.output, preliminary: part.preliminary }
199
+ : undefined
200
+ }
201
+
151
202
  /**
152
- * Minimal structural shape read off a `tool-${name}` UIMessagePart. Cast to this (rather than
153
- * AI SDK's own generic ToolUIPart<TOOLS>) because we diff by raw `type`/`state` string comparison,
154
- * not by a caller-supplied tool registry.
203
+ * True when `curr` reports nothing new relative to `prev` — same state AND (for output-available)
204
+ * the same output/preliminary content. A `preliminary: true` result streams several
205
+ * output-available snapshots with the SAME `state` but a MUTATING `output`; naive state-equality
206
+ * would freeze on the first snapshot and drop every refinement. Content (not reference) equality:
207
+ * each snapshot from `readUIMessageStream` is a fresh `structuredClone`, so `===` on nested output
208
+ * objects would never match even when genuinely unchanged.
155
209
  */
156
- type ToolLike = {
157
- readonly type: string
158
- readonly toolCallId: string
159
- readonly state: string
160
- readonly input?: unknown
161
- readonly output?: unknown
162
- readonly errorText?: string
210
+ function isSameToolState(
211
+ prev: ToolUIPart | DynamicToolUIPart,
212
+ curr: ToolUIPart | DynamicToolUIPart,
213
+ ): boolean {
214
+ if (prev.state !== curr.state) return false
215
+ const prevOutput = outputSnapshot(prev)
216
+ const currOutput = outputSnapshot(curr)
217
+ if (prevOutput === undefined || currOutput === undefined) return true
218
+ return (
219
+ JSON.stringify(prevOutput.output) === JSON.stringify(currOutput.output) &&
220
+ prevOutput.preliminary === currOutput.preliminary
221
+ )
163
222
  }
164
223
 
165
- function diffToolPart(prevPart: AiPart | undefined, currPart: AiPart): AgentPart[] {
166
- const curr = currPart as unknown as ToolLike
167
- // Deliberate v1 simplification: never emit on 'input-streaming' (partial/DeepPartial input) —
168
- // wait for at least 'input-available' so the consumer isn't flooded with incomplete fragments.
169
- if (curr.state === 'input-streaming') return []
224
+ /**
225
+ * Diffs one tool part across the seven-state `UIToolInvocation` union into a `ToolCallPart` draft,
226
+ * addressed by `tool#${toolCallId}` — stable across every state transition of one call, so
227
+ * `mergePart` rewrites the same part in place rather than stacking one entry per state.
228
+ *
229
+ * `durationMs` is basalt's own field (the SDK doesn't provide one): wall-clock from this
230
+ * toolCallId's first sighting (recorded in `toolStartTimes`, scoped to ONE stream() / resume()
231
+ * call — never module-global, so two concurrent threads never share timings) to a terminal state.
232
+ */
233
+ function diffToolPart(
234
+ prevPart: AiPart | undefined,
235
+ currPart: ToolUIPart | DynamicToolUIPart,
236
+ toolStartTimes: Map<string, number>,
237
+ ): AgentPartDraft[] {
238
+ if (!toolStartTimes.has(currPart.toolCallId)) {
239
+ toolStartTimes.set(currPart.toolCallId, Date.now())
240
+ }
241
+
242
+ const prev = prevPart !== undefined && isToolLikePart(prevPart) ? prevPart : undefined
243
+ if (prev !== undefined && isSameToolState(prev, currPart)) return []
170
244
 
171
- const prev =
172
- prevPart !== undefined && prevPart.type === currPart.type
173
- ? (prevPart as unknown as ToolLike)
174
- : undefined
175
- // Already emitted this exact state for this tool call index — nothing new to report.
176
- if (prev?.state === curr.state) return []
245
+ const settled = (TERMINAL_TOOL_STATES as readonly string[]).includes(currPart.state)
246
+ const durationMs = settled
247
+ ? Date.now() - (toolStartTimes.get(currPart.toolCallId) ?? Date.now())
248
+ : undefined
177
249
 
178
- const toolName = curr.type.slice('tool-'.length)
179
- const base: ToolCallPart = {
180
- type: 'tool',
181
- toolName,
182
- toolCallId: curr.toolCallId,
183
- input: curr.input,
250
+ const base = {
251
+ id: `tool#${currPart.toolCallId}`,
252
+ type: 'tool' as const,
253
+ toolCallId: currPart.toolCallId,
254
+ toolName: deriveToolName(currPart),
255
+ ...(currPart.providerExecuted !== undefined
256
+ ? { providerExecuted: currPart.providerExecuted }
257
+ : {}),
258
+ ...(durationMs !== undefined ? { durationMs } : {}),
259
+ }
260
+
261
+ switch (currPart.state) {
262
+ case 'input-streaming':
263
+ return [
264
+ {
265
+ ...base,
266
+ state: 'input-streaming',
267
+ ...(currPart.input !== undefined ? { input: currPart.input } : {}),
268
+ },
269
+ ]
270
+ case 'input-available':
271
+ return [{ ...base, state: 'input-available', input: currPart.input }]
272
+ case 'approval-requested':
273
+ return [
274
+ {
275
+ ...base,
276
+ state: 'approval-requested',
277
+ input: currPart.input,
278
+ approval: currPart.approval,
279
+ },
280
+ ]
281
+ case 'approval-responded':
282
+ return [
283
+ {
284
+ ...base,
285
+ state: 'approval-responded',
286
+ input: currPart.input,
287
+ approval: currPart.approval,
288
+ },
289
+ ]
290
+ case 'output-available':
291
+ return [
292
+ {
293
+ ...base,
294
+ state: 'output-available',
295
+ input: currPart.input,
296
+ output: currPart.output,
297
+ ...(currPart.preliminary !== undefined ? { preliminary: currPart.preliminary } : {}),
298
+ ...(currPart.approval !== undefined ? { approval: currPart.approval } : {}),
299
+ },
300
+ ]
301
+ case 'output-error': {
302
+ // rawInput only exists on the static ToolUIPart variant — a DynamicToolUIPart's output-error
303
+ // has no such field at all, so this must be read defensively behind the type discriminant.
304
+ const rawInput = currPart.type === 'dynamic-tool' ? undefined : currPart.rawInput
305
+ return [
306
+ {
307
+ ...base,
308
+ state: 'output-error',
309
+ ...(currPart.input !== undefined ? { input: currPart.input } : {}),
310
+ errorText: currPart.errorText,
311
+ ...(rawInput !== undefined ? { rawInput } : {}),
312
+ ...(currPart.approval !== undefined ? { approval: currPart.approval } : {}),
313
+ },
314
+ ]
315
+ }
316
+ case 'output-denied':
317
+ return [
318
+ { ...base, state: 'output-denied', input: currPart.input, approval: currPart.approval },
319
+ ]
320
+ default:
321
+ return assertNever(currPart)
184
322
  }
185
- if (curr.state === 'output-available') return [{ ...base, output: curr.output }]
186
- // output-error: still surface it as a tool part rather than silently dropping the failure — the
187
- // error text becomes the output payload since ToolCallPart has no dedicated error field.
188
- if (curr.state === 'output-error') return [{ ...base, output: { error: curr.errorText } }]
189
- return [base]
190
323
  }
191
324
 
192
325
  /** Diffs every index of one UIMessage snapshot against the previous snapshot. */
193
- function diffMessage(prev: UIMessage | undefined, curr: UIMessage): AgentPart[] {
194
- const deltas: AgentPart[] = []
326
+ function diffMessage(
327
+ chatId: string,
328
+ prev: UIMessage | undefined,
329
+ curr: UIMessage,
330
+ toolStartTimes: Map<string, number>,
331
+ ): AgentPartDraft[] {
332
+ const deltas: AgentPartDraft[] = []
195
333
  curr.parts.forEach((part, i) => {
196
- deltas.push(...diffPart(prev?.parts[i], part))
334
+ deltas.push(...diffPart(chatId, i, prev?.parts[i], part, toolStartTimes))
197
335
  })
198
336
  return deltas
199
337
  }
@@ -204,19 +342,28 @@ function diffMessage(prev: UIMessage | undefined, curr: UIMessage): AgentPart[]
204
342
  * built-in AgentPart variants, so TPart must be — or be assignable from — AgentPart for a
205
343
  * meaningful result; same documented-cast convention as useAgentThreadRuns' defaultToUserParts).
206
344
  *
345
+ * `chatId` is the id-namespace for this diff pass — the caller's chat id on stream(), the
346
+ * resumeToken (which IS the chat id, see the module docblock's CHAT ID BINDING note) on resume() —
347
+ * so index-addressed ids line up with the original run's ids across a reconnect.
348
+ *
349
+ * A fresh `toolStartTimes` map is scoped to ONE diffChunkStream call (one stream()/resume()
350
+ * invocation), never shared across calls or threads.
351
+ *
207
352
  * `resume()`'s underlying reconnectToStream call has no AbortSignal parameter in the current AI
208
353
  * SDK API (unlike sendMessages), so we can't cancel the in-flight fetch directly — checking
209
354
  * `signal.aborted` here at least stops yielding further deltas to an aborted caller promptly.
210
355
  */
211
356
  async function* diffChunkStream<TPart>(
357
+ chatId: string,
212
358
  chunkStream: ReadableStream<UIMessageChunk>,
213
359
  readStream: typeof readUIMessageStream,
214
360
  signal?: AbortSignal,
215
361
  ): AsyncGenerator<TPart> {
216
362
  let prev: UIMessage | undefined
363
+ const toolStartTimes = new Map<string, number>()
217
364
  for await (const curr of readStream<UIMessage>({ stream: chunkStream })) {
218
365
  if (signal?.aborted) return
219
- for (const part of diffMessage(prev, curr)) {
366
+ for (const part of diffMessage(chatId, prev, curr, toolStartTimes)) {
220
367
  yield part as TPart
221
368
  }
222
369
  prev = curr
@@ -241,12 +388,16 @@ export function aiSdkTransport<TPart = AgentPart>(
241
388
  ): AiSdkTransport<TPart> {
242
389
  const resolveAiSdk = createAiSdkResolver(options)
243
390
 
244
- function makeTransport(chatId: string): AgentTransport<TPart, string> {
391
+ function makeTransport(chatId: string): ResumableAgentTransport<TPart, string> {
245
392
  return {
393
+ // Every text/reasoning delta this transport emits carries an authoritative `offset` (see
394
+ // diffPart), so a replayed run rewrites in place instead of duplicating — the literal
395
+ // condition this assertion stands for.
396
+ idempotentReplay: true,
246
397
  async *stream(input: string, signal?: AbortSignal): AsyncGenerator<TPart> {
247
398
  // Synthesized locally, first — we already know the chat id client-side, so there is no
248
399
  // need to wait on the server to hand us a run id (unlike a river-style protocol).
249
- yield { type: 'start', runId: chatId, resumeToken: chatId } as TPart
400
+ yield { id: `${chatId}#start`, type: 'start', runId: chatId, resumeToken: chatId } as TPart
250
401
 
251
402
  const { httpTransport, readUIMessageStream: readStream } = await resolveAiSdk()
252
403
  const userMessage: UIMessage = {
@@ -261,13 +412,15 @@ export function aiSdkTransport<TPart = AgentPart>(
261
412
  messageId: undefined,
262
413
  abortSignal: signal,
263
414
  })
264
- yield* diffChunkStream<TPart>(chunkStream, readStream, signal)
415
+ yield* diffChunkStream<TPart>(chatId, chunkStream, readStream, signal)
265
416
  },
266
417
  async *resume(resumeToken: string, signal?: AbortSignal): AsyncGenerator<TPart> {
267
418
  const { httpTransport, readUIMessageStream: readStream } = await resolveAiSdk()
268
419
  const chunkStream = await httpTransport.reconnectToStream({ chatId: resumeToken })
269
420
  if (chunkStream === null) return
270
- yield* diffChunkStream<TPart>(chunkStream, readStream, signal)
421
+ // resumeToken IS the chat id (see CHAT ID BINDING above) — reusing it as the id-namespace
422
+ // here keeps replayed index-addressed ids identical to the ones the original run minted.
423
+ yield* diffChunkStream<TPart>(resumeToken, chunkStream, readStream, signal)
271
424
  },
272
425
  }
273
426
  }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * coalesceParts — merges a rendered turn's parts for display: consecutive text/reasoning runs,
3
+ * and tool parts by toolCallId regardless of adjacency (later state wins, input/toolName from an
4
+ * earlier state survive a later state that omits them, first-occurrence position is kept). An
5
+ * approval-bearing part with no directly usable toolCallId resolves via the approvalId side index.
6
+ */
7
+ import { describe, expect, test } from 'bun:test'
8
+ import { coalesceParts } from './coalesce'
9
+ import type { AgentPart, ToolCallPart } from './parts'
10
+
11
+ describe('coalesceParts', () => {
12
+ test('merges consecutive text parts into one', () => {
13
+ const parts: AgentPart[] = [
14
+ { id: 'p1', type: 'text', text: 'Hel' },
15
+ { id: 'p2', type: 'text', text: 'lo' },
16
+ ]
17
+ expect(coalesceParts(parts)).toEqual([{ id: 'p2', type: 'text', text: 'Hello' }])
18
+ })
19
+
20
+ test('merges consecutive reasoning parts into one', () => {
21
+ const parts: AgentPart[] = [
22
+ { id: 'p1', type: 'reasoning', text: 'thinking' },
23
+ { id: 'p2', type: 'reasoning', text: ' more' },
24
+ ]
25
+ expect(coalesceParts(parts)).toEqual([{ id: 'p2', type: 'reasoning', text: 'thinking more' }])
26
+ })
27
+
28
+ test('does not merge text across a non-text part (a tool call interleaved)', () => {
29
+ const parts: AgentPart[] = [
30
+ { id: 'p1', type: 'text', text: 'a' },
31
+ {
32
+ id: 'p2',
33
+ type: 'tool',
34
+ toolCallId: 'call-1',
35
+ toolName: 'search',
36
+ state: 'input-available',
37
+ input: {},
38
+ },
39
+ { id: 'p3', type: 'text', text: 'b' },
40
+ ]
41
+ const out = coalesceParts(parts)
42
+ expect(out).toHaveLength(3)
43
+ expect(out[0]?.type).toBe('text')
44
+ expect(out[2]?.type).toBe('text')
45
+ })
46
+
47
+ test('merges tool parts by toolCallId across NON-ADJACENT positions', () => {
48
+ const inputAvailable: ToolCallPart = {
49
+ id: 'p1',
50
+ type: 'tool',
51
+ toolCallId: 'call-1',
52
+ toolName: 'search',
53
+ state: 'input-available',
54
+ input: { q: 'x' },
55
+ }
56
+ const outputAvailable: ToolCallPart = {
57
+ id: 'p3',
58
+ type: 'tool',
59
+ toolCallId: 'call-1',
60
+ toolName: 'search',
61
+ state: 'output-available',
62
+ input: { q: 'x' },
63
+ output: { hits: 3 },
64
+ }
65
+ const parts: AgentPart[] = [
66
+ inputAvailable,
67
+ { id: 'p2', type: 'text', text: 'meanwhile...' },
68
+ outputAvailable,
69
+ ]
70
+
71
+ const out = coalesceParts(parts)
72
+ expect(out).toHaveLength(2)
73
+ // The merged tool part keeps the position of its FIRST occurrence.
74
+ expect(out[0]?.type).toBe('tool')
75
+ expect((out[0] as ToolCallPart).state).toBe('output-available')
76
+ expect(out[1]?.type).toBe('text')
77
+ })
78
+
79
+ test('the later terminal state wins over an earlier one for the same toolCallId', () => {
80
+ const outputAvailable: ToolCallPart = {
81
+ id: 'p1',
82
+ type: 'tool',
83
+ toolCallId: 'call-1',
84
+ toolName: 'search',
85
+ state: 'output-available',
86
+ input: { q: 'x' },
87
+ output: { hits: 3 },
88
+ }
89
+ const outputError: ToolCallPart = {
90
+ id: 'p2',
91
+ type: 'tool',
92
+ toolCallId: 'call-1',
93
+ toolName: 'search',
94
+ state: 'output-error',
95
+ errorText: 'timed out',
96
+ }
97
+ const out = coalesceParts([outputAvailable, outputError])
98
+ expect(out).toHaveLength(1)
99
+ expect((out[0] as ToolCallPart).state).toBe('output-error')
100
+ })
101
+
102
+ test('input from an earlier state survives a later state that omits it', () => {
103
+ const inputAvailable: ToolCallPart = {
104
+ id: 'p1',
105
+ type: 'tool',
106
+ toolCallId: 'call-1',
107
+ toolName: 'search',
108
+ state: 'input-available',
109
+ input: { q: 'x' },
110
+ }
111
+ // Simulates the wire shape where `tool-output-available` carries only `output` — a raw
112
+ // pre-normalization shape our strict type doesn't allow, hence the cast.
113
+ const outputAvailableNoInput = {
114
+ id: 'p2',
115
+ type: 'tool',
116
+ toolCallId: 'call-1',
117
+ toolName: 'search',
118
+ state: 'output-available',
119
+ output: { hits: 3 },
120
+ } as unknown as ToolCallPart
121
+
122
+ const out = coalesceParts([inputAvailable, outputAvailableNoInput])
123
+ expect(out).toHaveLength(1)
124
+ const merged = out[0] as ToolCallPart & { output: unknown }
125
+ expect(merged.state).toBe('output-available')
126
+ expect(merged.input).toEqual({ q: 'x' })
127
+ expect(merged.output).toEqual({ hits: 3 })
128
+ })
129
+
130
+ test('toolName from an earlier state survives a later state that omits it', () => {
131
+ const inputAvailable: ToolCallPart = {
132
+ id: 'p1',
133
+ type: 'tool',
134
+ toolCallId: 'call-1',
135
+ toolName: 'search',
136
+ state: 'input-available',
137
+ input: { q: 'x' },
138
+ }
139
+ const outputAvailableNoToolName = {
140
+ id: 'p2',
141
+ type: 'tool',
142
+ toolCallId: 'call-1',
143
+ toolName: '',
144
+ state: 'output-available',
145
+ input: { q: 'x' },
146
+ output: { hits: 3 },
147
+ } as unknown as ToolCallPart
148
+
149
+ const out = coalesceParts([inputAvailable, outputAvailableNoToolName])
150
+ expect((out[0] as ToolCallPart).toolName).toBe('search')
151
+ })
152
+
153
+ test('an approval-responded part with no toolCallId is resolved via the approvalId side index', () => {
154
+ const approvalRequested: ToolCallPart = {
155
+ id: 'p1',
156
+ type: 'tool',
157
+ toolCallId: 'call-1',
158
+ toolName: 'delete_file',
159
+ state: 'approval-requested',
160
+ input: { path: '/etc/passwd' },
161
+ approval: { id: 'appr-1' },
162
+ }
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 = {
166
+ id: 'p2',
167
+ type: 'tool',
168
+ toolCallId: '',
169
+ toolName: 'delete_file',
170
+ state: 'approval-responded',
171
+ input: { path: '/etc/passwd' },
172
+ approval: { id: 'appr-1', approved: true },
173
+ } as unknown as ToolCallPart
174
+
175
+ const out = coalesceParts([approvalRequested, approvalResponded])
176
+ expect(out).toHaveLength(1)
177
+ expect((out[0] as ToolCallPart).state).toBe('approval-responded')
178
+ })
179
+
180
+ test('a tool part unresolvable by either toolCallId or approvalId is kept as-is, never dropped', () => {
181
+ const orphan = {
182
+ id: 'p1',
183
+ type: 'tool',
184
+ toolCallId: '',
185
+ toolName: 'mystery',
186
+ state: 'approval-responded',
187
+ input: {},
188
+ approval: { id: 'unknown-approval', approved: true },
189
+ } as unknown as ToolCallPart
190
+
191
+ const out = coalesceParts([orphan])
192
+ expect(out).toEqual([orphan])
193
+ })
194
+ })
@@ -0,0 +1,118 @@
1
+ /**
2
+ * coalesceParts — merges a rendered-turn's parts array for display.
3
+ *
4
+ * Merges consecutive `text`+`text` and `reasoning`+`reasoning` parts (streamed answers arrive as
5
+ * many small fragments; this renders a streaming reply as one flowing block instead of a stack of
6
+ * fragments). Merges `tool` parts BY `toolCallId` regardless of adjacency — the AI SDK re-emits a
7
+ * tool call's `input-available` then `output-available` as two separate parts, not necessarily
8
+ * adjacent once other parts interleave; without this rule a UI renders two stacked tool blocks for
9
+ * one call. The later state always wins; the merged part keeps the position of its FIRST
10
+ * occurrence (stable ordering); and an `input` (or `toolName`) supplied by an earlier state
11
+ * survives a later state that omits it — `tool-output-available` carries only `output` on the
12
+ * wire, the SDK re-supplies `input` by reading it back off the stored invocation, and a merge that
13
+ * replaces wholesale rather than accumulating would lose it outright.
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.
19
+ *
20
+ * @example
21
+ * import { coalesceParts } from 'basalt-ui/agent'
22
+ * <PartList parts={coalesceParts(parts)} />
23
+ */
24
+ import type { AgentPart, ReasoningPart, TextPart, ToolCallPart } from './parts'
25
+
26
+ type TextLike = TextPart | ReasoningPart
27
+
28
+ function isTextLikeType(type: AgentPart['type']): type is TextLike['type'] {
29
+ return type === 'text' || type === 'reasoning'
30
+ }
31
+
32
+ /** Resolves a usable toolCallId for `part`, either off the part itself or (for an approval-bearing
33
+ * part missing one) via the approvalId -> toolCallId side index built while folding. */
34
+ function resolveToolCallId(
35
+ part: ToolCallPart,
36
+ toolCallIdByApprovalId: Map<string, string>,
37
+ ): string | undefined {
38
+ if (typeof part.toolCallId === 'string' && part.toolCallId.length > 0) return part.toolCallId
39
+ const approval = 'approval' in part ? part.approval : undefined
40
+ return approval !== undefined ? toolCallIdByApprovalId.get(approval.id) : undefined
41
+ }
42
+
43
+ /** Registers this part's approval.id -> toolCallId mapping (when both are known) so a later
44
+ * approval-bearing part missing its own toolCallId can still resolve to the right tool call. */
45
+ function registerApprovalIndex(
46
+ part: ToolCallPart,
47
+ toolCallIdByApprovalId: Map<string, string>,
48
+ ): void {
49
+ const approval = 'approval' in part ? part.approval : undefined
50
+ if (approval === undefined) return
51
+ if (typeof part.toolCallId !== 'string' || part.toolCallId.length === 0) return
52
+ toolCallIdByApprovalId.set(approval.id, part.toolCallId)
53
+ }
54
+
55
+ /** The later state always wins; `input`/`toolName` from the earlier state survive a later state
56
+ * that omits them. */
57
+ function mergeToolPart(existing: ToolCallPart, next: ToolCallPart): ToolCallPart {
58
+ const existingInput = 'input' in existing ? existing.input : undefined
59
+ const nextInput = 'input' in next ? next.input : undefined
60
+ const input = nextInput !== undefined ? nextInput : existingInput
61
+ const toolName = next.toolName.length > 0 ? next.toolName : existing.toolName
62
+
63
+ return {
64
+ ...existing,
65
+ ...next,
66
+ toolName,
67
+ ...(input !== undefined ? { input } : {}),
68
+ } as ToolCallPart
69
+ }
70
+
71
+ export function coalesceParts<TPart extends AgentPart>(parts: readonly TPart[]): TPart[] {
72
+ const out: TPart[] = []
73
+ const toolIndexByCallId = new Map<string, number>()
74
+ const toolCallIdByApprovalId = new Map<string, string>()
75
+
76
+ for (const part of parts) {
77
+ if (isTextLikeType(part.type)) {
78
+ const last = out[out.length - 1]
79
+ if (last !== undefined && last.type === part.type) {
80
+ const merged = {
81
+ ...part,
82
+ text: (last as unknown as TextLike).text + (part as unknown as TextLike).text,
83
+ }
84
+ out[out.length - 1] = merged as TPart
85
+ continue
86
+ }
87
+ out.push(part)
88
+ continue
89
+ }
90
+
91
+ if (part.type === 'tool') {
92
+ const toolPart = part as unknown as ToolCallPart
93
+ registerApprovalIndex(toolPart, toolCallIdByApprovalId)
94
+ const resolvedCallId = resolveToolCallId(toolPart, toolCallIdByApprovalId)
95
+
96
+ if (resolvedCallId === undefined) {
97
+ // Unresolvable by either key — never silently discard.
98
+ out.push(part)
99
+ continue
100
+ }
101
+
102
+ const existingIndex = toolIndexByCallId.get(resolvedCallId)
103
+ if (existingIndex === undefined) {
104
+ toolIndexByCallId.set(resolvedCallId, out.length)
105
+ out.push(part)
106
+ continue
107
+ }
108
+
109
+ const existing = out[existingIndex] as unknown as ToolCallPart
110
+ out[existingIndex] = mergeToolPart(existing, toolPart) as unknown as TPart
111
+ continue
112
+ }
113
+
114
+ out.push(part)
115
+ }
116
+
117
+ return out
118
+ }