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
@@ -40,10 +40,14 @@
40
40
  import { useCallback, useEffect, useRef, useState } from 'react'
41
41
  import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
42
42
  import type { ChatMessage } from './history'
43
+ import { withPartIds } from './id'
44
+ import { mergePart } from './merge'
45
+ import type { PartLike } from './merge'
43
46
  import type { AgentOutcome, OutcomeResolver } from './outcome'
44
47
  import { isStartPart } from './parts'
45
48
  import type { AgentPart } from './parts'
46
49
  import type { AgentThread, ThreadStatus, ThreadsStore } from './thread'
50
+ import { isResumable } from './transport'
47
51
  import type { AgentTransport } from './transport'
48
52
 
49
53
  // ── ThreadRunState ────────────────────────────────────────────────────────────
@@ -106,9 +110,14 @@ export type UseAgentThreadRunsReturn<TPart = AgentPart> = {
106
110
  * `threadId` has never had a turn started, or already has one in flight.
107
111
  */
108
112
  readonly retry: (threadId: string) => void
109
- /** Abort the in-flight stream for `threadId` (no-op if idle). */
113
+ /**
114
+ * Abort the in-flight stream for `threadId` (no-op if idle). Preserves whatever content had
115
+ * already arrived: if the run had accumulated any parts, they're persisted as an assistant
116
+ * ChatMessage with `finish: 'stopped'` (unless that message already landed — see finalizeStop),
117
+ * then distilled into an outcome and the thread is settled to 'done'.
118
+ */
110
119
  readonly stop: (threadId: string) => void
111
- /** Abort every in-flight stream across all threads. */
120
+ /** Abort every in-flight stream across all threads, settling each the same way stop() would. */
112
121
  readonly stopAll: () => void
113
122
  }
114
123
 
@@ -116,7 +125,7 @@ export type UseAgentThreadRunsReturn<TPart = AgentPart> = {
116
125
 
117
126
  /** Default toUserParts: wraps raw input in a single text part. */
118
127
  function defaultToUserParts(input: string): AgentPart[] {
119
- return [{ type: 'text', text: input }]
128
+ return [{ id: crypto.randomUUID(), type: 'text', text: input }]
120
129
  }
121
130
 
122
131
  /**
@@ -161,12 +170,13 @@ function buildDoneSnapshot<TPart>(
161
170
  * the resume path passes 'interrupted' (a failed resume falls back to the same terminal state a
162
171
  * non-resumable orphaned thread already lands in).
163
172
  */
164
- async function consumeAndFinalize<TPart>(args: {
173
+ async function consumeAndFinalize<TPart extends PartLike>(args: {
165
174
  threadId: string
166
175
  controller: AbortController
167
176
  generator: AsyncGenerator<TPart>
168
177
  userMessage: ChatMessage<TPart>
169
178
  controllersRef: MutableRefObject<Map<string, AbortController>>
179
+ appendedRef: MutableRefObject<Map<string, AbortController>>
170
180
  storeRef: MutableRefObject<ThreadsStore<TPart>>
171
181
  resolveOutcome: OutcomeResolver<TPart>
172
182
  setRuns: Dispatch<SetStateAction<Map<string, ThreadRunState<TPart>>>>
@@ -178,12 +188,13 @@ async function consumeAndFinalize<TPart>(args: {
178
188
  generator,
179
189
  userMessage,
180
190
  controllersRef,
191
+ appendedRef,
181
192
  storeRef,
182
193
  resolveOutcome,
183
194
  setRuns,
184
195
  onFailureStatus,
185
196
  } = args
186
- const parts: TPart[] = []
197
+ let parts: TPart[] = []
187
198
  try {
188
199
  for await (const part of generator) {
189
200
  // Guard: a newer call superseded this stream's controller for this thread, or it
@@ -194,10 +205,13 @@ async function consumeAndFinalize<TPart>(args: {
194
205
  storeRef.current.setResumeToken(threadId, part.resumeToken)
195
206
  continue
196
207
  }
197
- parts.push(part)
208
+ // mergePart REWRITES an existing id in place (never mutates `parts` — reassigns to the new
209
+ // array it returns), so the local accumulator and the rendered `runs` snapshot below stay
210
+ // identical: a replayed/resumed delta rewrites its part instead of duplicating it.
211
+ parts = mergePart(parts, part)
198
212
  setRuns((prev) => {
199
213
  const next = new Map(prev)
200
- next.set(threadId, { status: 'streaming', parts: [...parts] })
214
+ next.set(threadId, { status: 'streaming', parts })
201
215
  return next
202
216
  })
203
217
  }
@@ -211,17 +225,33 @@ async function consumeAndFinalize<TPart>(args: {
211
225
  role: 'assistant',
212
226
  parts,
213
227
  createdAt: Date.now(),
228
+ finish: 'complete',
214
229
  }
215
230
  storeRef.current.appendMessage(threadId, assistantMessage)
231
+ // Record the terminal-append fact for THIS run (identified by `controller`, mirroring
232
+ // controllersRef's own per-thread/per-run keying) — see appendedRef's doc on why this, and not
233
+ // array-reference equality, is what finalizeStop checks.
234
+ appendedRef.current.set(threadId, controller)
216
235
 
217
236
  const priorThread = storeRef.current.threads.find((thread) => thread.id === threadId)
218
237
  const snapshot = buildDoneSnapshot(priorThread, threadId, userMessage, assistantMessage)
219
238
  const outcome: AgentOutcome = await resolveOutcome(snapshot)
220
239
 
240
+ // Re-check: resolveOutcome above is the only await in this function's success path, and
241
+ // stop() may have run while it was pending — aborting the controller, appending its own
242
+ // 'stopped' message only if appendedRef didn't already carry this run's marker (see
243
+ // finalizeStop), and already forcing the thread to 'done'. Without this recheck, a slow
244
+ // resolveOutcome settling AFTER stop() has already finalized the thread would silently
245
+ // overwrite that outcome with whatever THIS call resolved to.
246
+ if (controllersRef.current.get(threadId) !== controller || controller.signal.aborted) {
247
+ return
248
+ }
249
+
221
250
  storeRef.current.setOutcome(threadId, outcome)
222
251
  storeRef.current.setStatus(threadId, outcome.status)
223
252
  storeRef.current.setResumeToken(threadId, undefined)
224
253
  controllersRef.current.delete(threadId)
254
+ appendedRef.current.delete(threadId)
225
255
  setRuns((prev) => {
226
256
  if (!prev.has(threadId)) return prev
227
257
  const next = new Map(prev)
@@ -237,6 +267,7 @@ async function consumeAndFinalize<TPart>(args: {
237
267
  if (controller.signal.aborted) return
238
268
  storeRef.current.setStatus(threadId, onFailureStatus)
239
269
  controllersRef.current.delete(threadId)
270
+ appendedRef.current.delete(threadId)
240
271
  setRuns((prev) => {
241
272
  if (!prev.has(threadId)) return prev
242
273
  const next = new Map(prev)
@@ -246,6 +277,56 @@ async function consumeAndFinalize<TPart>(args: {
246
277
  }
247
278
  }
248
279
 
280
+ /**
281
+ * Finalizes a stop() call for `threadId` — the counterpart to consumeAndFinalize's success path,
282
+ * reused so the persist/settle/resolve sequence exists exactly once. `parts` is the accumulated
283
+ * (possibly partial) content read out of the run's `ThreadRunState` at the moment stop() was
284
+ * called; the caller has already aborted the controller and cleared `controllersRef` before
285
+ * invoking this. `alreadyAppended` is precomputed by the caller (stop()) from `appendedRef` — see
286
+ * that ref's doc for why this is an explicit marker rather than an inferred comparison.
287
+ *
288
+ * Ordering: the append, forcing status to 'done', clearing the resume token, and tearing down the
289
+ * run entry ALL happen synchronously (this function's body runs synchronously up to its one
290
+ * `await`) — status is never derived from the resolved outcome, so it doesn't need to wait on one,
291
+ * matching useAgentStream's stop(), which settles 'done' immediately rather than blocking on async
292
+ * work. Only `setOutcome` — which genuinely needs the resolved value — happens after the await.
293
+ */
294
+ async function finalizeStop<TPart extends PartLike>(args: {
295
+ threadId: string
296
+ parts: TPart[]
297
+ alreadyAppended: boolean
298
+ storeRef: MutableRefObject<ThreadsStore<TPart>>
299
+ resolveOutcome: OutcomeResolver<TPart>
300
+ setRuns: Dispatch<SetStateAction<Map<string, ThreadRunState<TPart>>>>
301
+ }): Promise<void> {
302
+ const { threadId, parts, alreadyAppended, storeRef, resolveOutcome, setRuns } = args
303
+
304
+ if (!alreadyAppended && parts.length > 0) {
305
+ const stoppedMessage: ChatMessage<TPart> = {
306
+ id: crypto.randomUUID(),
307
+ role: 'assistant',
308
+ parts,
309
+ createdAt: Date.now(),
310
+ finish: 'stopped',
311
+ }
312
+ storeRef.current.appendMessage(threadId, stoppedMessage)
313
+ }
314
+
315
+ storeRef.current.setStatus(threadId, 'done')
316
+ storeRef.current.setResumeToken(threadId, undefined)
317
+ setRuns((prev) => {
318
+ if (!prev.has(threadId)) return prev
319
+ const next = new Map(prev)
320
+ next.delete(threadId)
321
+ return next
322
+ })
323
+
324
+ const snapshot = storeRef.current.threads.find((thread) => thread.id === threadId)
325
+ if (snapshot === undefined) return
326
+ const outcome: AgentOutcome = await resolveOutcome(snapshot)
327
+ storeRef.current.setOutcome(threadId, outcome)
328
+ }
329
+
249
330
  // ── useAgentThreadRuns ────────────────────────────────────────────────────────
250
331
 
251
332
  /**
@@ -259,16 +340,43 @@ async function consumeAndFinalize<TPart>(args: {
259
340
  * start(threadB, 'What changed in the last release?')
260
341
  * // both stream concurrently; runs.get(threadA) / runs.get(threadB) update independently
261
342
  */
262
- export function useAgentThreadRuns<TPart = AgentPart>({
343
+ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
263
344
  transport,
264
345
  store,
265
346
  resolveOutcome,
266
347
  toUserParts,
267
348
  }: UseAgentThreadRunsArgs<TPart>): UseAgentThreadRunsReturn<TPart> {
268
349
  const [runs, setRuns] = useState<Map<string, ThreadRunState<TPart>>>(new Map())
350
+ // Mirrors the latest `runs` state every render so stop() (a plain callback, not a render) can
351
+ // synchronously read a thread's accumulated parts without going through an async setState
352
+ // updater — same mirroring pattern as storeRef/transportRef below.
353
+ const runsRef = useRef(runs)
354
+ runsRef.current = runs
269
355
 
270
356
  // Refs: mutable per-render state that must not trigger re-renders.
271
357
  const controllersRef = useRef<Map<string, AbortController>>(new Map())
358
+ // Explicit single-writer marker: has THIS run's terminal assistant message already been
359
+ // appended? Keyed the same way as controllersRef (threadId -> the specific run's controller),
360
+ // set by consumeAndFinalize the instant it appends, and cleared on every path that ends a run
361
+ // (normal completion, the error path, stop(), and unmount) — same lifecycle discipline as
362
+ // controllersRef, deliberately, since that's the existing coordination channel between
363
+ // consumeAndFinalize and stop()/finalizeStop.
364
+ //
365
+ // This replaces an earlier reference-equality check (`lastMessage.parts === parts`) that
366
+ // compared the array runsRef mirrors against the array the message actually persisted. That
367
+ // comparison implicitly depended on THREE things staying true at once: the run-state mirror not
368
+ // re-copying `parts` on every delta, the accumulator reassigning (never mutating) `parts`, and —
369
+ // the one that actually broke it — React's flush timing: runsRef is only updated during a
370
+ // render (`runsRef.current = runs`, assigned unconditionally on every render, not in an effect),
371
+ // so a setRuns dispatch that hasn't yet flushed into a render leaves runsRef holding an EARLIER
372
+ // parts array. A stop() landing between consumeAndFinalize's post-loop appendMessage and its
373
+ // `await resolveOutcome` resolving could observe exactly that staleness and defeat the
374
+ // reference-equality guard, double-appending — see the regression test with "resolveOutcome"
375
+ // in its name. Keying by controller identity (an AbortController, freshly minted per run) makes
376
+ // a false-positive match across two different runs on the same thread structurally
377
+ // impossible — no render, no array-copy semantics, no accumulator-reassignment assumption is
378
+ // load-bearing anymore.
379
+ const appendedRef = useRef<Map<string, AbortController>>(new Map())
272
380
  // Caches the last user input per thread so retry() can replay a failed turn without the
273
381
  // caller having to hold onto (or re-collect) what was typed.
274
382
  const lastInputRef = useRef<Map<string, string>>(new Map())
@@ -315,6 +423,7 @@ export function useAgentThreadRuns<TPart = AgentPart>({
315
423
  () => () => {
316
424
  controllersRef.current.forEach((controller) => controller.abort())
317
425
  controllersRef.current.clear()
426
+ appendedRef.current.clear()
318
427
  },
319
428
  [],
320
429
  )
@@ -340,8 +449,10 @@ export function useAgentThreadRuns<TPart = AgentPart>({
340
449
  const lastUserMessage = thread.messages.filter((message) => message.role === 'user').at(-1)
341
450
  const resolvedTransport = resolveTransport(thread.id)
342
451
 
452
+ // isResumable requires BOTH `resume` and the literal `idempotentReplay: true` assertion —
453
+ // a transport with `resume` alone is not enough (see ResumableAgentTransport's doc).
343
454
  if (
344
- resolvedTransport.resume === undefined ||
455
+ !isResumable(resolvedTransport) ||
345
456
  resumeToken === undefined ||
346
457
  lastUserMessage === undefined
347
458
  ) {
@@ -361,9 +472,17 @@ export function useAgentThreadRuns<TPart = AgentPart>({
361
472
  void consumeAndFinalize({
362
473
  threadId: thread.id,
363
474
  controller,
364
- generator: resume(resumeToken, controller.signal),
475
+ // Namespaced by thread.id (stable across a thread's whole lifetime, including this
476
+ // resume) rather than per-call — see withPartIds' own doc for why this is NOT a replay-
477
+ // convergence guarantee on its own: it only protects against two id-less drafts within
478
+ // ONE generator colliding (the F1 bug). A transport whose resume() needs genuine replay
479
+ // safety must assert `idempotentReplay: true` AND mint its own content-stable ids (as
480
+ // aiSdkTransport does) — withPartIds is then a no-op passthrough for it, not the mechanism
481
+ // providing that safety.
482
+ generator: withPartIds(thread.id, resume(resumeToken, controller.signal)),
365
483
  userMessage: lastUserMessage,
366
484
  controllersRef,
485
+ appendedRef,
367
486
  storeRef,
368
487
  resolveOutcome,
369
488
  setRuns,
@@ -403,9 +522,11 @@ export function useAgentThreadRuns<TPart = AgentPart>({
403
522
  void consumeAndFinalize({
404
523
  threadId,
405
524
  controller,
406
- generator: resolvedTransport.stream(input, controller.signal),
525
+ // Namespaced by threadId — same choice, same caveat, as the mount-time resume call above.
526
+ generator: withPartIds(threadId, resolvedTransport.stream(input, controller.signal)),
407
527
  userMessage,
408
528
  controllersRef,
529
+ appendedRef,
409
530
  storeRef,
410
531
  resolveOutcome,
411
532
  setRuns,
@@ -424,23 +545,46 @@ export function useAgentThreadRuns<TPart = AgentPart>({
424
545
  [start],
425
546
  )
426
547
 
427
- const stop = useCallback((threadId: string): void => {
428
- controllersRef.current.get(threadId)?.abort()
429
- controllersRef.current.delete(threadId)
430
- storeRef.current.setStatus(threadId, 'done')
431
- setRuns((prev) => {
432
- if (!prev.has(threadId)) return prev
433
- const next = new Map(prev)
434
- next.delete(threadId)
435
- return next
436
- })
437
- }, [])
548
+ const stop = useCallback(
549
+ (threadId: string): void => {
550
+ const controller = controllersRef.current.get(threadId)
551
+ // No in-flight run for this thread — true no-op, matching this hook's stop() JSDoc. Decided
552
+ // from controllersRef, NOT runsRef: controllersRef is written synchronously by start() (line
553
+ // ~509, before consumeAndFinalize is even invoked), while runsRef only mirrors `runs` as of
554
+ // the last FLUSHED RENDER (`runsRef.current = runs`, assigned during render, above). A
555
+ // start() immediately followed by stop() in the same synchronous tick/act() batch — e.g.
556
+ // `onClick={() => { start(id, text); stop(id) }}` — has controllersRef already populated but
557
+ // runsRef still holding the PREVIOUS render's (empty) map. Gating on runsRef here would read
558
+ // `undefined`, wrongly treat this as a no-op, leave the controller registered and never
559
+ // abort it — stranding the run in 'streaming' (see the F4 regression test).
560
+ if (controller === undefined) return
561
+
562
+ controllersRef.current.delete(threadId)
563
+ // Read the accumulated parts straight out of the live run entry (runsRef mirrors `runs` as of
564
+ // the last flushed render) — falls back to [] for the same synchronous start()-then-stop()
565
+ // case above, where no render has flushed yet; finalizeStop already treats an empty `parts`
566
+ // as "nothing to persist".
567
+ const parts = runsRef.current.get(threadId)?.parts ?? []
568
+
569
+ controller.abort()
570
+
571
+ // Was THIS run's terminal message already appended by consumeAndFinalize's own success path
572
+ // (it can race this call — see appendedRef's doc)? Matched by controller identity, not by
573
+ // comparing `parts` against anything React-observed, so this is correct regardless of
574
+ // whether runsRef has flushed the latest delta yet.
575
+ const alreadyAppended = appendedRef.current.get(threadId) === controller
576
+ appendedRef.current.delete(threadId)
577
+
578
+ void finalizeStop({ threadId, parts, alreadyAppended, storeRef, resolveOutcome, setRuns })
579
+ },
580
+ [resolveOutcome],
581
+ )
438
582
 
439
583
  const stopAll = useCallback((): void => {
440
- controllersRef.current.forEach((controller) => controller.abort())
441
- controllersRef.current.clear()
442
- setRuns(new Map())
443
- }, [])
584
+ for (const threadId of Array.from(controllersRef.current.keys())) {
585
+ stop(threadId)
586
+ }
587
+ }, [stop])
444
588
 
445
589
  return { runs, start, retry, stop, stopAll }
446
590
  }
@@ -210,12 +210,13 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', ()
210
210
  const store = createTestThreadsStore([thread])
211
211
 
212
212
  let resumeCalls = 0
213
- const transport: AgentTransport<AgentPart, string> = {
213
+ const transport = {
214
214
  async *stream() {},
215
215
  async *resume() {
216
216
  resumeCalls++
217
217
  yield { type: 'text', text: 'resumed' }
218
218
  },
219
+ idempotentReplay: true as const,
219
220
  }
220
221
 
221
222
  renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }), {
@@ -250,13 +251,14 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', ()
250
251
  const gate = deferred<void>()
251
252
 
252
253
  let resumeCalls = 0
253
- const transport: AgentTransport<AgentPart, string> = {
254
+ const transport = {
254
255
  async *stream() {},
255
256
  async *resume() {
256
257
  resumeCalls++
257
258
  await gate.promise
258
259
  yield { type: 'text', text: 'resumed' }
259
260
  },
261
+ idempotentReplay: true as const,
260
262
  }
261
263
 
262
264
  let externalSetMode: ((mode: 'visible' | 'hidden') => void) | undefined
@@ -34,3 +34,7 @@ export type { ComposerProps } from './composer'
34
34
  // ── threadPartRenderers + ThreadTranscript ────────────────────────────────────
35
35
  export { threadPartRenderers, ThreadTranscript } from './thread-message'
36
36
  export type { ThreadTranscriptProps } from './thread-message'
37
+
38
+ // ── ToolChip ──────────────────────────────────────────────────────────────────
39
+ export { ToolChip } from './tool-chip'
40
+ export type { ToolChipProps } from './tool-chip'
@@ -0,0 +1,211 @@
1
+ /**
2
+ * ThreadTranscript — the open part-renderer registry's resolution order, the dev/prod fallback
3
+ * contract, and the `MessageBlock` memoization budget from AGENT-CHAT-SPEC.md §9.
4
+ *
5
+ * `messageBlockRenderCounter` is imported directly from `./thread-message` (bypassing the
6
+ * `agent-chat` barrel on purpose — it is not part of the public surface) to prove the memo
7
+ * boundary in a controlled render-count harness.
8
+ */
9
+ import { MantineProvider } from '@mantine/core'
10
+ import { cleanup, render, screen } from '@testing-library/react'
11
+ import { afterEach, describe, expect, test } from 'bun:test'
12
+ import { definePartRenderers } from '../agent'
13
+ import type { ChatMessage, TranscriptPart } from '../agent'
14
+ import { messageBlockRenderCounter, ThreadTranscript } from './thread-message'
15
+
16
+ afterEach(cleanup)
17
+
18
+ function withNodeEnv<T>(value: string, fn: () => T): T {
19
+ const original = process.env['NODE_ENV']
20
+ process.env['NODE_ENV'] = value
21
+ try {
22
+ return fn()
23
+ } finally {
24
+ process.env['NODE_ENV'] = original
25
+ }
26
+ }
27
+
28
+ function buildMessages(count: number): ChatMessage<TranscriptPart>[] {
29
+ return Array.from({ length: count }, (_, i) => {
30
+ const role: ChatMessage<TranscriptPart>['role'] = i % 2 === 0 ? 'user' : 'assistant'
31
+ return {
32
+ id: `m${i}`,
33
+ role,
34
+ parts: [{ id: `m${i}-p1`, type: 'text', text: `message ${i}` }],
35
+ createdAt: i,
36
+ }
37
+ })
38
+ }
39
+
40
+ describe('resolution order', () => {
41
+ test('a registered consumer renderer wins over the built-in union', () => {
42
+ const renderers = definePartRenderers({
43
+ 'data-chart': () => <div data-testid="chart" />,
44
+ })
45
+ const messages: ChatMessage<TranscriptPart>[] = [
46
+ {
47
+ id: 'm1',
48
+ role: 'assistant',
49
+ parts: [{ id: 'p1', type: 'data-chart', spec: {} }],
50
+ createdAt: 0,
51
+ },
52
+ ]
53
+
54
+ render(
55
+ <MantineProvider>
56
+ <ThreadTranscript messages={messages} renderers={renderers} />
57
+ </MantineProvider>,
58
+ )
59
+
60
+ expect(screen.getByTestId('chart')).toBeDefined()
61
+ })
62
+
63
+ test('a built-in AgentPart type still routes through PartList when unclaimed by renderers', () => {
64
+ const messages: ChatMessage<TranscriptPart>[] = [
65
+ {
66
+ id: 'm1',
67
+ role: 'assistant',
68
+ parts: [{ id: 'p1', type: 'text', text: 'hello' }],
69
+ createdAt: 0,
70
+ },
71
+ ]
72
+
73
+ render(
74
+ <MantineProvider>
75
+ <ThreadTranscript messages={messages} />
76
+ </MantineProvider>,
77
+ )
78
+
79
+ expect(screen.getByText('hello')).toBeDefined()
80
+ })
81
+ })
82
+
83
+ describe('unknown-part fallback — never throws, dev-visible, prod-silent', () => {
84
+ const messages: ChatMessage<TranscriptPart>[] = [
85
+ {
86
+ id: 'm1',
87
+ role: 'assistant',
88
+ parts: [{ id: 'p1', type: 'data-chart', spec: {} }],
89
+ createdAt: 0,
90
+ },
91
+ ]
92
+
93
+ test('renders a visible UnknownPartChip outside production', () => {
94
+ withNodeEnv('development', () => {
95
+ expect(() =>
96
+ render(
97
+ <MantineProvider>
98
+ <ThreadTranscript messages={messages} />
99
+ </MantineProvider>,
100
+ ),
101
+ ).not.toThrow()
102
+ expect(screen.getByText(/Unknown part: data-chart/)).toBeDefined()
103
+ })
104
+ })
105
+
106
+ test('renders nothing in production, and still never throws', () => {
107
+ withNodeEnv('production', () => {
108
+ expect(() =>
109
+ render(
110
+ <MantineProvider>
111
+ <ThreadTranscript messages={messages} />
112
+ </MantineProvider>,
113
+ ),
114
+ ).not.toThrow()
115
+ expect(screen.queryByText(/Unknown part/)).toBeNull()
116
+ })
117
+ })
118
+ })
119
+
120
+ function messageWithFinish(
121
+ finish: ChatMessage<TranscriptPart>['finish'],
122
+ ): ChatMessage<TranscriptPart>[] {
123
+ return [
124
+ {
125
+ id: 'm1',
126
+ role: 'assistant',
127
+ parts: [{ id: 'p1', type: 'text', text: 'hello' }],
128
+ createdAt: 0,
129
+ ...(finish === undefined ? {} : { finish }),
130
+ },
131
+ ]
132
+ }
133
+
134
+ describe('finish indicator (AGENT-CHAT-SPEC.md §10)', () => {
135
+ test('finish: stopped renders an indicator with accessible text', () => {
136
+ render(
137
+ <MantineProvider>
138
+ <ThreadTranscript messages={messageWithFinish('stopped')} />
139
+ </MantineProvider>,
140
+ )
141
+
142
+ expect(screen.getByText('Stopped')).toBeDefined()
143
+ })
144
+
145
+ test('finish: error renders an indicator with accessible text', () => {
146
+ render(
147
+ <MantineProvider>
148
+ <ThreadTranscript messages={messageWithFinish('error')} />
149
+ </MantineProvider>,
150
+ )
151
+
152
+ expect(screen.getByText('Error')).toBeDefined()
153
+ })
154
+
155
+ test('finish: complete renders no indicator', () => {
156
+ render(
157
+ <MantineProvider>
158
+ <ThreadTranscript messages={messageWithFinish('complete')} />
159
+ </MantineProvider>,
160
+ )
161
+
162
+ expect(screen.queryByText('Stopped')).toBeNull()
163
+ expect(screen.queryByText('Error')).toBeNull()
164
+ expect(screen.queryByText('Complete')).toBeNull()
165
+ })
166
+
167
+ test("finish absent renders no indicator, identical to today's output", () => {
168
+ render(
169
+ <MantineProvider>
170
+ <ThreadTranscript messages={messageWithFinish(undefined)} />
171
+ </MantineProvider>,
172
+ )
173
+
174
+ expect(screen.getByText('hello')).toBeDefined()
175
+ expect(screen.queryByText('Stopped')).toBeNull()
176
+ expect(screen.queryByText('Error')).toBeNull()
177
+ })
178
+ })
179
+
180
+ describe('MessageBlock memoization (AGENT-CHAT-SPEC.md §9)', () => {
181
+ test('a streamed delta re-renders exactly one MessageBlock on a 50-message thread', () => {
182
+ const messages = buildMessages(50)
183
+ const { rerender } = render(
184
+ <MantineProvider>
185
+ <ThreadTranscript
186
+ messages={messages}
187
+ liveParts={[{ id: 'live', type: 'text', text: 'a' }]}
188
+ liveStatus="streaming"
189
+ />
190
+ </MantineProvider>,
191
+ )
192
+
193
+ // Mount executes every MessageBlock once (nothing to bail out against yet) — that is not the
194
+ // measurement. Reset the counter once the initial render has settled.
195
+ messageBlockRenderCounter.count = 0
196
+
197
+ // Same `messages` array reference (unchanged settled history) + a NEW `liveParts` array
198
+ // reference (the streamed delta) — the one prop a delta actually changes.
199
+ rerender(
200
+ <MantineProvider>
201
+ <ThreadTranscript
202
+ messages={messages}
203
+ liveParts={[{ id: 'live', type: 'text', text: 'ab' }]}
204
+ liveStatus="streaming"
205
+ />
206
+ </MantineProvider>,
207
+ )
208
+
209
+ expect(messageBlockRenderCounter.count).toBe(1)
210
+ })
211
+ })