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
@@ -1,10 +1,12 @@
1
1
  /**
2
- * useAgentThreadRuns — concurrency, busy-thread no-op, finalize ordering, failure status, retry.
2
+ * useAgentThreadRuns — concurrency, busy-thread no-op, finalize ordering, failure status, retry,
3
+ * and (as of B2) the stop()/stopAll() lifecycle (partial-turn preservation, the idle no-op
4
+ * guard, and the resolveOutcome-race fix).
3
5
  *
4
- * Scope: the multi-thread run manager AS THE PUBLIC API EXISTS TODAY (B1). No mergePart/part ids/
6
+ * Scope: the multi-thread run manager AS THE PUBLIC API EXISTS TODAY. No mergePart/part ids/
5
7
  * offsets, no seven-state ToolCallPart, no ResumableAgentTransport/isResumable/idempotentReplay
6
- * beyond the plain optional `resume` already on AgentTransport, no stop-preserves-partial-turn, no
7
- * ThreadsStoreAdapter — all later releases, not exercised here.
8
+ * beyond the plain optional `resume` already on AgentTransport, no ThreadsStoreAdapter — later
9
+ * releases, not exercised here.
8
10
  *
9
11
  * The mount-reconcile/resume path (StartPart handling, orphaned-thread resumption on mount) has
10
12
  * its own file, `use-agent-thread-runs.resume.test.tsx`; the F3 wedge has its own file,
@@ -345,15 +347,12 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
345
347
  expect(store.threads.find((t) => t.id === threadId)?.messages).toHaveLength(4)
346
348
  })
347
349
 
348
- // Flagged for scheduling, not fixed here (out of this task's scope — see the brief's "while you
349
- // are in there, report, do not fix" note (i)): consumeAndFinalize's success path awaits
350
- // resolveOutcome (:219) and then writes setOutcome/setStatus (:221-222) WITHOUT re-checking the
351
- // supersede/abort guards it checked right before the await (:205-207). A slow resolveOutcome
352
- // racing a stop() call is reachable: stop() sets the thread to 'done' immediately, but once the
353
- // delayed resolveOutcome finally resolves, the unconditional setStatus(threadId, outcome.status)
354
- // silently overwrites that 'done' with whatever the outcome resolved to. Verified failing against
355
- // the current implementation before being marked .skip here.
356
- test.skip('stop() during a slow resolveOutcome must not be clobbered by the outcome settling later', async () => {
350
+ // Pins the fix for a race in consumeAndFinalize's success path: it awaits resolveOutcome and
351
+ // then writes setOutcome/setStatus, and now RE-CHECKS the supersede/abort guards after that
352
+ // await (not just before it) before performing those writes. Without the recheck, a slow
353
+ // resolveOutcome racing a stop() call would clobber stop()'s synchronous 'done' with whatever
354
+ // the outcome resolved to once it finally settled.
355
+ test('stop() during a slow resolveOutcome must not be clobbered by the outcome settling later', async () => {
357
356
  const store = createTestThreadsStore()
358
357
  const threadId = store.create()
359
358
 
@@ -391,8 +390,392 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
391
390
  releaseOutcome?.()
392
391
 
393
392
  await waitFor(() => {
394
- // Currently fails: the late setStatus overwrites 'done' with 'attention'.
393
+ // The late resolveOutcome settling must not resurrect the thread past the 'done' stop()
394
+ // already committed.
395
395
  expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
396
396
  })
397
397
  })
398
398
  })
399
+
400
+ describe('useAgentThreadRuns — stop()/stopAll() lifecycle', () => {
401
+ test('stop() mid-stream persists the accumulated parts as a finish: "stopped" assistant message', async () => {
402
+ const store = createTestThreadsStore()
403
+ const threadId = store.create()
404
+
405
+ const gate = deferred<void>()
406
+ const transport: AgentTransport<AgentPart, string> = {
407
+ async *stream() {
408
+ yield { type: 'text', text: 'partial' }
409
+ await gate.promise
410
+ yield { type: 'text', text: 'never arrives' }
411
+ },
412
+ }
413
+
414
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
415
+
416
+ act(() => {
417
+ result.current.start(threadId, 'hi')
418
+ })
419
+
420
+ await waitFor(() => {
421
+ expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
422
+ })
423
+
424
+ act(() => {
425
+ result.current.stop(threadId)
426
+ })
427
+
428
+ const thread = store.threads.find((t) => t.id === threadId)
429
+ expect(thread?.status).toBe('done')
430
+ expect(thread?.messages).toHaveLength(2)
431
+ const assistantMessage = thread?.messages[1]
432
+ expect(assistantMessage?.role).toBe('assistant')
433
+ expect(assistantMessage?.finish).toBe('stopped')
434
+ // withPartIds stamps the missing id as `${threadId}#0` — the first (and only) id-less part
435
+ // this run's generator yielded.
436
+ expect(assistantMessage?.parts).toEqual([
437
+ { id: `${threadId}#0`, type: 'text', text: 'partial' },
438
+ ])
439
+ // The run entry is torn down synchronously — no lingering 'streaming' indicator.
440
+ expect(result.current.runs.has(threadId)).toBe(false)
441
+
442
+ // Release the still-suspended (aborted, guard-caught) generator so it doesn't leak into the
443
+ // next test.
444
+ gate.resolve(undefined)
445
+ })
446
+
447
+ test('stop() does not double-append when the stream already fully arrived before stop() was called', async () => {
448
+ const store = createTestThreadsStore()
449
+ const threadId = store.create()
450
+
451
+ let releaseOutcome: (() => void) | undefined
452
+ const slowResolveOutcome: OutcomeResolver<AgentPart> = () =>
453
+ new Promise<AgentOutcome>((resolve) => {
454
+ releaseOutcome = () => resolve({ title: 't', summary: 's', status: 'done' })
455
+ })
456
+
457
+ const transport: AgentTransport<AgentPart, string> = {
458
+ async *stream() {
459
+ yield { type: 'text', text: 'hi' }
460
+ },
461
+ }
462
+
463
+ const { result } = renderHook(() =>
464
+ useAgentThreadRuns({ transport, store, resolveOutcome: slowResolveOutcome }),
465
+ )
466
+
467
+ act(() => {
468
+ result.current.start(threadId, 'hi')
469
+ })
470
+
471
+ // Wait until consumeAndFinalize's own appendMessage has already landed (it happens BEFORE
472
+ // its await on resolveOutcome) — this is the narrow window stop() can race without ever
473
+ // being able to observe "not yet appended" (see finalizeStop's doc comment).
474
+ await waitFor(() => {
475
+ expect(store.threads.find((t) => t.id === threadId)?.messages.length).toBe(2)
476
+ })
477
+
478
+ act(() => {
479
+ result.current.stop(threadId)
480
+ })
481
+
482
+ // Still exactly 2 messages (user + the ORIGINAL 'complete' assistant message) — stop() must
483
+ // recognize the terminal message already landed and skip its own append rather than
484
+ // duplicating it as a second 'stopped' message.
485
+ const thread = store.threads.find((t) => t.id === threadId)
486
+ expect(thread?.messages).toHaveLength(2)
487
+ expect(thread?.messages[1]?.finish).toBe('complete')
488
+
489
+ releaseOutcome?.()
490
+ })
491
+
492
+ test('stop() is a true no-op when threadId has no in-flight run', () => {
493
+ const store = createTestThreadsStore()
494
+ // No store.create() here on purpose — mirrors the "retry() is a no-op" test above: isolates
495
+ // stop()'s own "nothing in flight" guard from the mount-reconcile effect's unrelated handling
496
+ // of a freshly-created 'pending' thread.
497
+ const threadId = crypto.randomUUID()
498
+
499
+ const transport: AgentTransport<AgentPart, string> = {
500
+ async *stream() {
501
+ yield { type: 'text', text: 'x' }
502
+ },
503
+ }
504
+
505
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
506
+
507
+ act(() => {
508
+ result.current.stop(threadId)
509
+ })
510
+
511
+ expect(store.threads).toEqual([])
512
+ })
513
+
514
+ // Pins the fix for the reference-equality hole: runsRef mirrors `runs` only as of the last
515
+ // FLUSHED render (assigned during render, `:~355`), so a stop() landing inside the narrow
516
+ // window between consumeAndFinalize's post-loop appendMessage and its `await resolveOutcome`
517
+ // resolving could observe a STALE parts array (one delta behind) via runsRef — defeating the
518
+ // old `lastMessage.parts === parts` guard and double-appending. This test lands stop()
519
+ // deterministically inside that exact window (fired synchronously from inside the injected
520
+ // resolveOutcome, itself called synchronously right after appendMessage, before any of the
521
+ // hook's pending state updates can flush — flushing needs a macrotask, and nothing here crosses
522
+ // one) rather than relying on timing luck, so it is a reliable regression guard for the fixed
523
+ // marker-based mechanism, not just a one-off repro.
524
+ test("stop() fired synchronously from inside resolveOutcome (before the final delta's render flushes) must not double-append", async () => {
525
+ const store = createTestThreadsStore()
526
+ const threadId = store.create()
527
+
528
+ const gate = deferred<void>()
529
+ const transport: AgentTransport<AgentPart, string> = {
530
+ async *stream() {
531
+ // Distinct ids: mergePart addresses by id, and two parts sharing the (undefined) id would
532
+ // splice into ONE entry instead of accumulating two — this test needs the SECOND delta to
533
+ // produce a genuinely NEW array entry (and therefore a new `parts` array reference) to
534
+ // exercise the race at all.
535
+ yield { id: 'p1', type: 'text', text: 'first' }
536
+ await gate.promise
537
+ yield { id: 'p2', type: 'text', text: 'second' }
538
+ },
539
+ }
540
+
541
+ const hookRef: { stop?: (id: string) => void } = {}
542
+ let resolveCalls = 0
543
+ const raceResolveOutcome: OutcomeResolver<AgentPart> = async () => {
544
+ resolveCalls += 1
545
+ // Fire stop() from inside the FIRST resolveOutcome call — synchronously, before this
546
+ // function even returns its promise. Everything from the second delta's setRuns dispatch to
547
+ // this point runs purely through microtasks (async-generator machinery + this direct call),
548
+ // so no macrotask has elapsed and React's scheduler cannot have flushed that dispatch into
549
+ // runsRef yet — this reliably lands stop() inside the race window.
550
+ if (resolveCalls === 1) {
551
+ hookRef.stop?.(threadId)
552
+ }
553
+ return { title: 't', summary: 's', status: 'done' }
554
+ }
555
+
556
+ const { result } = renderHook(() =>
557
+ useAgentThreadRuns({ transport, store, resolveOutcome: raceResolveOutcome }),
558
+ )
559
+ hookRef.stop = result.current.stop
560
+
561
+ act(() => {
562
+ result.current.start(threadId, 'hi')
563
+ })
564
+
565
+ // Let the FIRST delta's render flush — runsRef now mirrors a non-empty parts array
566
+ // (['first']), which is what makes the eventual mismatch observable rather than vacuously
567
+ // guarded by parts.length === 0.
568
+ await waitFor(() => {
569
+ expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
570
+ })
571
+
572
+ // Resolve the gate outside act() on purpose: this must not synchronously flush React state —
573
+ // the whole point is to let the second delta, the loop exit, and appendMessage run purely as
574
+ // microtask continuations, with stop() firing from inside resolveOutcome before any render for
575
+ // the second delta occurs.
576
+ gate.resolve(undefined)
577
+
578
+ await waitFor(() => {
579
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
580
+ })
581
+
582
+ const thread = store.threads.find((t) => t.id === threadId)
583
+ const assistantMessages = thread?.messages.filter((m) => m.role === 'assistant') ?? []
584
+ expect(assistantMessages).toHaveLength(1)
585
+ // The surviving message must be the ORIGINAL 'complete' one with the full (['first','second'])
586
+ // content — not a truncated 'stopped' message built from the stale ['first']-only parts.
587
+ expect(assistantMessages[0]?.finish).toBe('complete')
588
+ expect(assistantMessages[0]?.parts).toEqual([
589
+ { id: 'p1', type: 'text', text: 'first' },
590
+ { id: 'p2', type: 'text', text: 'second' },
591
+ ])
592
+ })
593
+
594
+ // Pins the flip side of the marker fix: the marker must be cleared once a run concludes, or a
595
+ // stale entry from an EARLIER finished run on the same thread would wrongly suppress a
596
+ // legitimate append on a LATER run — a worse bug than the double-append this mechanism exists to
597
+ // prevent.
598
+ test('a stale marker from an earlier finished run must not suppress a legitimate later stop() append', async () => {
599
+ const store = createTestThreadsStore()
600
+ const threadId = store.create()
601
+
602
+ const inputs: string[] = []
603
+ let gate = deferred<void>()
604
+ const transport: AgentTransport<AgentPart, string> = {
605
+ async *stream(input) {
606
+ inputs.push(input)
607
+ // Distinct ids: two id-less parts sharing the (undefined) id would silently splice into
608
+ // ONE entry (the F1 bug) — turn 1's own "did these two deltas stay separate?" assertion
609
+ // below is invisible otherwise, since it only checked assistant MESSAGE count, not parts.
610
+ yield { id: `${input}-partial`, type: 'text', text: `${input}-partial` }
611
+ await gate.promise
612
+ yield { id: `${input}-final`, type: 'text', text: `${input}-final` }
613
+ },
614
+ }
615
+
616
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
617
+
618
+ // Turn 1: runs to full completion (no stop()) so consumeAndFinalize's own success path sets
619
+ // and then clears the marker for this thread.
620
+ act(() => {
621
+ result.current.start(threadId, 'first-turn')
622
+ })
623
+ gate.resolve(undefined)
624
+ await waitFor(() => {
625
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
626
+ })
627
+ const turn1Assistant = store.threads
628
+ .find((t) => t.id === threadId)
629
+ ?.messages.filter((m) => m.role === 'assistant')
630
+ expect(turn1Assistant).toHaveLength(1)
631
+ // The two deltas landed as two SEPARATE parts, not spliced into one.
632
+ expect(turn1Assistant?.[0]?.parts).toEqual([
633
+ { id: 'first-turn-partial', type: 'text', text: 'first-turn-partial' },
634
+ { id: 'first-turn-final', type: 'text', text: 'first-turn-final' },
635
+ ])
636
+
637
+ // Turn 2: a genuinely new run on the SAME thread, stopped mid-stream. If a stale marker from
638
+ // turn 1 leaked, this stop() would be wrongly treated as "already persisted" and silently drop
639
+ // the partial content instead of appending it.
640
+ gate = deferred<void>()
641
+ act(() => {
642
+ result.current.retry(threadId)
643
+ })
644
+ expect(inputs).toEqual(['first-turn', 'first-turn'])
645
+
646
+ await waitFor(() => {
647
+ expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
648
+ })
649
+
650
+ act(() => {
651
+ result.current.stop(threadId)
652
+ })
653
+
654
+ const thread = store.threads.find((t) => t.id === threadId)
655
+ const assistantMessages = thread?.messages.filter((m) => m.role === 'assistant') ?? []
656
+ expect(assistantMessages).toHaveLength(2)
657
+ expect(assistantMessages[1]?.finish).toBe('stopped')
658
+ expect(assistantMessages[1]?.parts).toEqual([
659
+ { id: 'first-turn-partial', type: 'text', text: 'first-turn-partial' },
660
+ ])
661
+
662
+ gate.resolve(undefined)
663
+ })
664
+
665
+ test('stopAll() resolves every aborted thread instead of stranding them in "streaming"', async () => {
666
+ const store = createTestThreadsStore()
667
+ const threadA = store.create()
668
+ const threadB = store.create()
669
+
670
+ const gateA = deferred<void>()
671
+ const gateB = deferred<void>()
672
+ const transport: AgentTransport<AgentPart, string> = {
673
+ async *stream(input) {
674
+ if (input === 'a') {
675
+ yield { type: 'text', text: 'partial-a' }
676
+ await gateA.promise
677
+ } else {
678
+ yield { type: 'text', text: 'partial-b' }
679
+ await gateB.promise
680
+ }
681
+ },
682
+ }
683
+
684
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
685
+
686
+ act(() => {
687
+ result.current.start(threadA, 'a')
688
+ result.current.start(threadB, 'b')
689
+ })
690
+
691
+ await waitFor(() => {
692
+ expect(result.current.runs.get(threadA)?.parts).toHaveLength(1)
693
+ expect(result.current.runs.get(threadB)?.parts).toHaveLength(1)
694
+ })
695
+
696
+ act(() => {
697
+ result.current.stopAll()
698
+ })
699
+
700
+ expect(store.threads.find((t) => t.id === threadA)?.status).toBe('done')
701
+ expect(store.threads.find((t) => t.id === threadB)?.status).toBe('done')
702
+ expect(result.current.runs.size).toBe(0)
703
+
704
+ // Release the still-suspended (aborted, guard-caught) generators so they don't leak into the
705
+ // next test.
706
+ gateA.resolve(undefined)
707
+ gateB.resolve(undefined)
708
+ })
709
+
710
+ // F4 regression: stop()'s no-op guard used to read runsRef, which only mirrors `runs` as of the
711
+ // last FLUSHED RENDER. start() then stop() called in the SAME synchronous act() batch — e.g.
712
+ // `onClick={() => { start(id, text); stop(id) }}` — never lets a render land in between, so
713
+ // runsRef was still empty when stop() ran and it wrongly no-op'd: the controller was never
714
+ // aborted, and a transport that only resolves on its AbortSignal firing would strand the thread
715
+ // in 'streaming' forever (this is exactly what `stopAll()`'s own regression test above cannot
716
+ // see — it deliberately `waitFor`s a render to flush before calling stopAll()).
717
+ test('F4 regression: start() then stop() in the same synchronous tick must actually abort, not strand the thread in "streaming"', async () => {
718
+ const store = createTestThreadsStore()
719
+ const threadId = store.create()
720
+
721
+ // Hangs until its AbortSignal fires — never resolves on its own. If stop()'s no-op guard skips
722
+ // the abort, this generator never settles and the thread is stuck 'streaming' forever.
723
+ const transport: AgentTransport<AgentPart, string> = {
724
+ // oxlint-disable-next-line require-yield -- hangs until aborted; never has anything to yield
725
+ async *stream(_input, signal) {
726
+ await new Promise((resolve) => {
727
+ signal?.addEventListener('abort', () => resolve(undefined))
728
+ })
729
+ },
730
+ }
731
+
732
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
733
+
734
+ act(() => {
735
+ result.current.start(threadId, 'hi')
736
+ result.current.stop(threadId)
737
+ })
738
+
739
+ await waitFor(() => {
740
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
741
+ })
742
+ expect(result.current.runs.has(threadId)).toBe(false)
743
+ })
744
+ })
745
+
746
+ describe('useAgentThreadRuns — F1 regression: id-less drafts within one run', () => {
747
+ test('drafts of different types do not destroy each other — mergePart no longer sees undefined === undefined', async () => {
748
+ const store = createTestThreadsStore()
749
+ const threadId = store.create()
750
+
751
+ const transport: AgentTransport<AgentPart, string> = {
752
+ async *stream() {
753
+ yield { type: 'text', text: 'Hi' }
754
+ yield {
755
+ type: 'tool',
756
+ toolCallId: 'c1',
757
+ toolName: 'search',
758
+ state: 'input-available',
759
+ input: { q: 'x' },
760
+ }
761
+ yield { type: 'text', text: 'Bye' }
762
+ },
763
+ }
764
+
765
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
766
+
767
+ act(() => {
768
+ result.current.start(threadId, 'go')
769
+ })
770
+
771
+ await waitFor(() => {
772
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
773
+ })
774
+
775
+ const assistantMessage = store.threads
776
+ .find((t) => t.id === threadId)
777
+ ?.messages.find((m) => m.role === 'assistant')
778
+ expect(assistantMessage?.parts).toHaveLength(3)
779
+ expect(assistantMessage?.parts.map((part) => part.type)).toEqual(['text', 'tool', 'text'])
780
+ })
781
+ })