basalt-ui 1.12.0 → 1.13.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 (105) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +38 -35
  4. package/agent/rules/basalt-agent.md +14 -0
  5. package/configs/oxlint-plugin.js +7 -3
  6. package/configs/oxlint-plugin.test.ts +34 -5
  7. package/configs/oxlint.json +4 -4
  8. package/dist/agent/adapter.d.ts +5 -1
  9. package/dist/agent/adapter.d.ts.map +1 -1
  10. package/dist/agent/adapter.js +22 -13
  11. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  12. package/dist/agent/ai-sdk-transport.js +6 -2
  13. package/dist/agent/id.d.ts +67 -19
  14. package/dist/agent/id.d.ts.map +1 -1
  15. package/dist/agent/id.js +21 -7
  16. package/dist/agent/thread.d.ts +4 -0
  17. package/dist/agent/thread.d.ts.map +1 -1
  18. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  19. package/dist/agent/use-agent-stream.js +2 -2
  20. package/dist/agent/use-agent-thread-runs.d.ts +24 -3
  21. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  22. package/dist/agent/use-agent-thread-runs.js +55 -15
  23. package/dist/agent-chat/index.d.ts +4 -0
  24. package/dist/agent-chat/index.d.ts.map +1 -1
  25. package/dist/agent-chat/index.js +2 -0
  26. package/dist/agent-chat/message-affordances.d.ts +59 -0
  27. package/dist/agent-chat/message-affordances.d.ts.map +1 -0
  28. package/dist/agent-chat/message-affordances.js +7 -0
  29. package/dist/agent-chat/relative-time.d.ts +27 -0
  30. package/dist/agent-chat/relative-time.d.ts.map +1 -0
  31. package/dist/agent-chat/relative-time.js +27 -0
  32. package/dist/agent-chat/relative-time.test.d.ts +2 -0
  33. package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
  34. package/dist/agent-chat/thread-feed-row.d.ts +65 -0
  35. package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
  36. package/dist/agent-chat/thread-feed-row.js +156 -0
  37. package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
  38. package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
  39. package/dist/agent-chat/thread-feed.d.ts +34 -5
  40. package/dist/agent-chat/thread-feed.d.ts.map +1 -1
  41. package/dist/agent-chat/thread-feed.js +52 -19
  42. package/dist/agent-chat/thread-feed.test.d.ts +2 -0
  43. package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
  44. package/dist/agent-chat/thread-message.d.ts +183 -2
  45. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  46. package/dist/agent-chat/thread-message.js +315 -34
  47. package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
  48. package/dist/agent-chat/thread-outcome-card.js +1 -16
  49. package/dist/agent-chat/thread-workspace.d.ts +14 -2
  50. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  51. package/dist/agent-chat/thread-workspace.js +29 -3
  52. package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
  53. package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
  54. package/dist/agent-chat/virtualize.d.ts +123 -0
  55. package/dist/agent-chat/virtualize.d.ts.map +1 -0
  56. package/dist/agent-chat/virtualize.js +9 -0
  57. package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
  58. package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
  59. package/dist/data/index.d.ts +5 -1
  60. package/dist/data/index.d.ts.map +1 -1
  61. package/dist/data/virtual-list.d.ts.map +1 -1
  62. package/dist/data/virtual.d.ts +2 -1
  63. package/dist/data/virtual.d.ts.map +1 -1
  64. package/dist/index.d.ts +2 -2
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +2 -0
  67. package/dist/surfaces.d.ts +2 -2
  68. package/dist/surfaces.d.ts.map +1 -1
  69. package/dist/surfaces.js +3 -2
  70. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  71. package/llms.txt +6 -6
  72. package/package.json +2 -2
  73. package/src/agent/adapter.test.ts +237 -0
  74. package/src/agent/adapter.ts +72 -14
  75. package/src/agent/ai-sdk-transport.test.ts +64 -0
  76. package/src/agent/ai-sdk-transport.ts +8 -2
  77. package/src/agent/id.test.ts +95 -1
  78. package/src/agent/id.ts +98 -28
  79. package/src/agent/thread.ts +4 -0
  80. package/src/agent/use-agent-stream.test.tsx +22 -0
  81. package/src/agent/use-agent-stream.ts +6 -3
  82. package/src/agent/use-agent-thread-runs.test.tsx +326 -0
  83. package/src/agent/use-agent-thread-runs.ts +159 -23
  84. package/src/agent/use-agent-thread-runs.wedge.test.tsx +80 -0
  85. package/src/agent-chat/index.ts +12 -0
  86. package/src/agent-chat/message-affordances.ts +61 -0
  87. package/src/agent-chat/relative-time.test.ts +95 -0
  88. package/src/agent-chat/relative-time.ts +55 -0
  89. package/src/agent-chat/thread-feed-row.test.tsx +510 -0
  90. package/src/agent-chat/thread-feed-row.tsx +274 -0
  91. package/src/agent-chat/thread-feed.test.tsx +241 -0
  92. package/src/agent-chat/thread-feed.tsx +119 -18
  93. package/src/agent-chat/thread-message.test.tsx +937 -3
  94. package/src/agent-chat/thread-message.tsx +766 -38
  95. package/src/agent-chat/thread-outcome-card.tsx +1 -25
  96. package/src/agent-chat/thread-workspace.test.tsx +169 -0
  97. package/src/agent-chat/thread-workspace.tsx +69 -5
  98. package/src/agent-chat/virtualize.ts +132 -0
  99. package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
  100. package/src/data/index.ts +5 -1
  101. package/src/data/virtual-list.tsx +2 -1
  102. package/src/data/virtual.ts +2 -1
  103. package/src/index.ts +5 -0
  104. package/src/surfaces.ts +2 -1
  105. package/src/theme/shadow-surfaces.test.ts +9 -0
@@ -134,6 +134,21 @@ describe('threadsStoreAdapterContract', () => {
134
134
  expect(new Set(cases.map((c) => c.name)).size).toBe(cases.length)
135
135
  })
136
136
 
137
+ test("the contract suite itself does not depend on crypto.randomUUID — it still runs to completion via the getRandomValues fallback (mintThreadId, since every id it mints here is a THREAD id, never appendMessage's idempotency key)", async () => {
138
+ const originalCrypto = globalThis.crypto
139
+ Object.defineProperty(globalThis, 'crypto', {
140
+ value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
141
+ configurable: true,
142
+ })
143
+ try {
144
+ for (const conformanceCase of threadsStoreAdapterContract(() => createMemoryAdapter())) {
145
+ await conformanceCase.run()
146
+ }
147
+ } finally {
148
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
149
+ }
150
+ })
151
+
137
152
  test('FAILS an adapter whose appendMessage is not idempotent on message.id', async () => {
138
153
  const cases = threadsStoreAdapterContract(() => {
139
154
  const memory = createMemoryAdapter()
@@ -417,6 +432,228 @@ describe('createAdapterThreadsStore', () => {
417
432
  expect(result.current.threads[0]?.read).toBe(true)
418
433
  })
419
434
 
435
+ // ── rolled-back create() and its dependent writes ───────────────────────────
436
+
437
+ test('a rolled-back create surfaces its own error, not a cascade from the writes queued behind it', async () => {
438
+ const calls: string[] = []
439
+ const memory = createMemoryAdapter()
440
+ const useThreads = createAdapterThreadsStore<AgentPart>({
441
+ ...memory,
442
+ createThread: async () => {
443
+ calls.push('createThread')
444
+ throw new Error('create-boom')
445
+ },
446
+ // Mirrors the real finding: a real backend REJECTS a write against a row that was never
447
+ // created, it does not silently no-op the way the permissive reference `memory` adapter
448
+ // does — so these reproduce the exact "unknown thread" cascade from the console trace.
449
+ markRead: async (id) => {
450
+ calls.push('markRead')
451
+ if ((await memory.loadThread(id)) === null)
452
+ throw new Error(`markRead: unknown thread ${id}`)
453
+ await memory.markRead(id)
454
+ },
455
+ appendMessage: async (i) => {
456
+ calls.push('appendMessage')
457
+ if ((await memory.loadThread(i.threadId)) === null) {
458
+ throw new Error(`appendMessage: unknown thread ${i.threadId}`)
459
+ }
460
+ await memory.appendMessage(i)
461
+ },
462
+ setStatus: async (i) => {
463
+ calls.push('setStatus')
464
+ if ((await memory.loadThread(i.threadId)) === null) {
465
+ throw new Error(`setStatus: unknown thread ${i.threadId}`)
466
+ }
467
+ await memory.setStatus(i)
468
+ },
469
+ })
470
+
471
+ const { result } = renderHook(() => useThreads())
472
+ await waitFor(() => {
473
+ expect(result.current.hydrated).toBe(true)
474
+ })
475
+
476
+ // Exactly the observed sequence: create() -> markRead() -> appendMessage() -> setStatus() in
477
+ // one synchronous block, every dependent write chained behind the same rejected createThread.
478
+ let id = ''
479
+ act(() => {
480
+ id = result.current.create()
481
+ result.current.markRead(id)
482
+ result.current.appendMessage(id, makeMessage('m-first'))
483
+ result.current.setStatus(id, 'streaming')
484
+ })
485
+
486
+ await waitFor(() => {
487
+ expect(result.current.error).toBeInstanceOf(Error)
488
+ })
489
+ await settle()
490
+
491
+ // The surfaced error names the create, not one of the three cascade rejections that would
492
+ // otherwise overwrite it in issue order.
493
+ expect((result.current.error as Error).message).toBe('create-boom')
494
+ // The dependent writes never reached the adapter at all — dropped, not run-and-failed.
495
+ expect(calls).toEqual(['createThread'])
496
+ // Rollback semantics are untouched: the thread is gone, both locally and server-side.
497
+ expect(result.current.threads).toHaveLength(0)
498
+ expect(await memory.loadThread(id)).toBeNull()
499
+ })
500
+
501
+ test('a rolled-back create does not stall or spoil the write queue for a different thread', async () => {
502
+ const memory = createMemoryAdapter()
503
+ const calls: string[] = []
504
+ let createCalls = 0
505
+ const useThreads = createAdapterThreadsStore<AgentPart>({
506
+ ...memory,
507
+ createThread: async (i) => {
508
+ createCalls += 1
509
+ if (createCalls === 1) {
510
+ calls.push('createThread:doomed')
511
+ throw new Error('create-boom')
512
+ }
513
+ calls.push('createThread:healthy')
514
+ await memory.createThread(i)
515
+ },
516
+ markRead: async (id) => {
517
+ calls.push(`markRead:${id}`)
518
+ await memory.markRead(id)
519
+ },
520
+ })
521
+
522
+ const { result } = renderHook(() => useThreads())
523
+ await waitFor(() => {
524
+ expect(result.current.hydrated).toBe(true)
525
+ })
526
+
527
+ let doomed = ''
528
+ let healthy = ''
529
+ act(() => {
530
+ doomed = result.current.create()
531
+ result.current.markRead(doomed)
532
+ healthy = result.current.create()
533
+ result.current.markRead(healthy)
534
+ })
535
+
536
+ await waitFor(() => {
537
+ expect(result.current.error).toBeInstanceOf(Error)
538
+ })
539
+ await waitFor(async () => {
540
+ expect(await memory.loadThread(healthy)).not.toBeNull()
541
+ })
542
+ await settle()
543
+
544
+ expect((result.current.error as Error).message).toBe('create-boom')
545
+ // doomed's markRead was dropped; healthy's own write for a DIFFERENT thread ran normally.
546
+ expect(calls).toEqual(['createThread:doomed', 'createThread:healthy', `markRead:${healthy}`])
547
+ expect((await memory.loadThread(healthy))?.read).toBe(true)
548
+ expect(await memory.loadThread(doomed)).toBeNull()
549
+ })
550
+
551
+ test('a genuine failure that arrives after a rolled-back create still surfaces', async () => {
552
+ const memory = createMemoryAdapter()
553
+ let createCalls = 0
554
+ const useThreads = createAdapterThreadsStore<AgentPart>({
555
+ ...memory,
556
+ createThread: async (i) => {
557
+ createCalls += 1
558
+ if (createCalls === 1) throw new Error('create-boom')
559
+ await memory.createThread(i)
560
+ },
561
+ markRead: async () => {
562
+ // A genuine, unrelated failure against a HEALTHY thread — not a cascade of the rollback.
563
+ throw new Error('markRead-boom')
564
+ },
565
+ })
566
+
567
+ const { result } = renderHook(() => useThreads())
568
+ await waitFor(() => {
569
+ expect(result.current.hydrated).toBe(true)
570
+ })
571
+
572
+ act(() => {
573
+ result.current.create()
574
+ })
575
+ await waitFor(() => {
576
+ expect(result.current.error).toBeInstanceOf(Error)
577
+ })
578
+ expect((result.current.error as Error).message).toBe('create-boom')
579
+
580
+ let healthy = ''
581
+ act(() => {
582
+ healthy = result.current.create()
583
+ })
584
+ await waitFor(async () => {
585
+ expect(await memory.loadThread(healthy)).not.toBeNull()
586
+ })
587
+
588
+ act(() => {
589
+ result.current.markRead(healthy)
590
+ })
591
+
592
+ await waitFor(() => {
593
+ expect((result.current.error as Error).message).toBe('markRead-boom')
594
+ })
595
+ })
596
+
597
+ test('a rolled-back create keeps dropping its thread’s writes after the queue drains', async () => {
598
+ // The run's COMPLETION path (useAgentThreadRuns: appendMessage(assistant) -> setOutcome ->
599
+ // setStatus -> setResumeToken) lands when the stream ends, long after the create's own
600
+ // rollback has drained the per-thread chain. Those writes target the same rolled-back row, so
601
+ // they are the same cascade as the synchronous send-path trio — just later. If the failed-id
602
+ // record is cleared when the chain drains, the fix above covers the first ~200ms and then the
603
+ // cascade returns at stream end, which is precisely when the user is looking at the thread.
604
+ const calls: string[] = []
605
+ const memory = createMemoryAdapter()
606
+ const useThreads = createAdapterThreadsStore<AgentPart>({
607
+ ...memory,
608
+ createThread: async () => {
609
+ calls.push('createThread')
610
+ throw new Error('create-boom')
611
+ },
612
+ appendMessage: async (i) => {
613
+ calls.push('appendMessage')
614
+ if ((await memory.loadThread(i.threadId)) === null) {
615
+ throw new Error(`appendMessage: unknown thread ${i.threadId}`)
616
+ }
617
+ await memory.appendMessage(i)
618
+ },
619
+ setStatus: async (i) => {
620
+ calls.push('setStatus')
621
+ if ((await memory.loadThread(i.threadId)) === null) {
622
+ throw new Error(`setStatus: unknown thread ${i.threadId}`)
623
+ }
624
+ await memory.setStatus(i)
625
+ },
626
+ })
627
+
628
+ const { result } = renderHook(() => useThreads())
629
+ await waitFor(() => {
630
+ expect(result.current.hydrated).toBe(true)
631
+ })
632
+
633
+ let id = ''
634
+ act(() => {
635
+ id = result.current.create()
636
+ })
637
+ await waitFor(() => {
638
+ expect(result.current.error).toBeInstanceOf(Error)
639
+ })
640
+ // Drain fully: the create's chain entry is gone by the time the completion writes arrive.
641
+ await settle()
642
+ await settle()
643
+ expect((result.current.error as Error).message).toBe('create-boom')
644
+
645
+ // The completion path fires now, against the row that was never materialized.
646
+ act(() => {
647
+ result.current.appendMessage(id, makeMessage('m-assistant'))
648
+ result.current.setStatus(id, 'done')
649
+ })
650
+ await settle()
651
+ await settle()
652
+
653
+ expect(calls).toEqual(['createThread'])
654
+ expect((result.current.error as Error).message).toBe('create-boom')
655
+ })
656
+
420
657
  test('setResumeToken(undefined) clears the key through the adapter', async () => {
421
658
  const memory = createMemoryAdapter()
422
659
  const useThreads = createAdapterThreadsStore<AgentPart>(memory)
@@ -168,7 +168,11 @@ type ThreadPatch<TPart> = {
168
168
  * - **Roll back on rejection.** A rejected write's patch is discarded, the rejection is surfaced
169
169
  * on `error`, and the store re-lists to converge on whatever the server really did. Patches are
170
170
  * tracked individually, so a failing write rolls back only itself and not whatever else was in
171
- * flight beside it.
171
+ * flight beside it. A rejected `create()` additionally drops every later single-thread write for
172
+ * that id (`markRead`/`appendMessage`/`setStatus`/… — both the ones chained directly behind it and
173
+ * the run's completion writes at stream end) instead of letting each fail independently against a
174
+ * row that no longer exists — see `failedCreateIds`. Without that, the create's own error is
175
+ * overwritten by a run of "unknown thread" rejections that name nothing a caller can act on.
172
176
  * - **`hydrated` means "a `listThreads` has succeeded"**, not "a load has been attempted". A
173
177
  * store that is `!hydrated` with a non-undefined `error` failed to load; check both.
174
178
  * - **`error` latches the most recent failure until something disproves it.** A successful
@@ -412,6 +416,31 @@ export function createAdapterThreadsStore<TPart = AgentPart>(
412
416
  */
413
417
  const chains = new Map<string, Promise<void>>()
414
418
 
419
+ /**
420
+ * Thread ids whose `createThread` has rejected and been rolled back. The send path issues
421
+ * `create()` → `markRead()` → `appendMessage()` → `setStatus()` in one synchronous block, all
422
+ * chained behind the same per-thread queue above — so when `createThread` rejects, the three
423
+ * dependent writes queued right behind it are guaranteed to reach the adapter next, each against
424
+ * a row that was never materialized. Left alone, each of those independently rejects with its own
425
+ * "unknown thread" error and overwrites the one that actually explains what happened (the
426
+ * create's), so the user ends up staring at the last cascade error instead of the root cause.
427
+ *
428
+ * Recording the id here lets `mutate` recognize a single-thread write against a rolled-back
429
+ * create and drop it silently instead of running a doomed commit — see `mutate`'s `isCreate`
430
+ * guard.
431
+ *
432
+ * Entries are NEVER removed, deliberately. The cascade is not confined to the synchronous block:
433
+ * `useAgentThreadRuns`' COMPLETION path (`appendMessage(assistant)` → `setOutcome` → `setStatus`
434
+ * → `setResumeToken`) fires when the stream ends, long after this thread's write chain has
435
+ * drained — same rolled-back row, same "unknown thread" rejections, and landing at exactly the
436
+ * moment the user is watching the thread. Clearing the entry when the chain drains would fix the
437
+ * first few hundred milliseconds and hand the cascade straight back at stream end. Retaining it
438
+ * is safe and bounded: `create()` always mints its own id (`mintThreadId`, never caller-supplied),
439
+ * so an id in here can never be re-created and the set only grows by one per FAILED create in a
440
+ * session.
441
+ */
442
+ const failedCreateIds = new Set<string>()
443
+
415
444
  /** `task` must never reject, so a failed write cannot poison the chain behind it. */
416
445
  function enqueue(threadIds: readonly string[], task: () => Promise<void>): void {
417
446
  const ids = [...new Set(threadIds)]
@@ -421,6 +450,7 @@ export function createAdapterThreadsStore<TPart = AgentPart>(
421
450
  for (const id of ids) chains.set(id, run)
422
451
  void run.finally(() => {
423
452
  // Only the tail clears its own entry, so a later write chained behind it is not orphaned.
453
+ // `failedCreateIds` is deliberately NOT cleared here — see its doc.
424
454
  for (const id of ids) if (chains.get(id) === run) chains.delete(id)
425
455
  })
426
456
  }
@@ -438,6 +468,12 @@ export function createAdapterThreadsStore<TPart = AgentPart>(
438
468
  readonly apply: ThreadPatch<TPart>['apply']
439
469
  readonly applyActive?: (activeId: string | null) => string | null
440
470
  readonly commit: () => Promise<void>
471
+ /**
472
+ * True only for `create()`'s own write. On rejection, marks its thread id as failed so the
473
+ * dependent writes chained behind it in the same synchronous send-path block recognize the row
474
+ * was rolled back — see `failedCreateIds`.
475
+ */
476
+ readonly isCreate?: boolean
441
477
  }): void {
442
478
  const patch: ThreadPatch<TPart> = {
443
479
  apply: memoizeApply(i.apply),
@@ -448,9 +484,30 @@ export function createAdapterThreadsStore<TPart = AgentPart>(
448
484
  const tokenAtIssue = errorToken
449
485
  recompute()
450
486
  enqueue(i.threadIds, async (): Promise<void> => {
487
+ // A single-thread write against a create that already rolled back cannot succeed — the row
488
+ // was never materialized, and running it only manufactures a second "unknown thread" error
489
+ // that would bury the create's own. This covers both the writes chained directly behind the
490
+ // create and the run's later completion writes (see `failedCreateIds`). Scoped to
491
+ // single-thread writes deliberately:
492
+ // `clear()`'s multi-id commit already has its own per-id fan-out (`Promise.allSettled`) and
493
+ // `removeThread` is contractually a no-op on an unknown id anyway, so folding this check into
494
+ // a multi-id write would risk dropping legitimate deletes for OTHER, healthy threads in the
495
+ // same batch just because one id in it happened to be orphaned.
496
+ const [soleThreadId] = i.threadIds
497
+ if (
498
+ !i.isCreate &&
499
+ i.threadIds.length === 1 &&
500
+ soleThreadId !== undefined &&
501
+ failedCreateIds.has(soleThreadId)
502
+ ) {
503
+ drop(patch)
504
+ recompute()
505
+ return
506
+ }
451
507
  try {
452
508
  await i.commit()
453
509
  } catch (cause) {
510
+ if (i.isCreate) for (const threadId of i.threadIds) failedCreateIds.add(threadId)
454
511
  // Roll back: discard THIS patch only, leaving any other in-flight patch applied.
455
512
  drop(patch)
456
513
  error = cause
@@ -540,6 +597,7 @@ export function createAdapterThreadsStore<TPart = AgentPart>(
540
597
  mutate({
541
598
  threadIds: [id],
542
599
  apply: (threads) => (threads.some((t) => t.id === id) ? [...threads] : [thread, ...threads]),
600
+ isCreate: true,
543
601
  commit: () =>
544
602
  adapter.createThread({
545
603
  id,
@@ -738,7 +796,7 @@ export function threadsStoreAdapterContract<TPart>(
738
796
 
739
797
  return [
740
798
  define('createThread then listThreads returns the thread with empty defaults', async (a) => {
741
- const id = crypto.randomUUID()
799
+ const id = mintThreadId()
742
800
  await a.createThread({ id })
743
801
  const threads = await a.listThreads()
744
802
  const found = threads.filter((t) => t.id === id)
@@ -754,19 +812,19 @@ export function threadsStoreAdapterContract<TPart>(
754
812
  }),
755
813
 
756
814
  define('createThread persists meta', async (a) => {
757
- const id = crypto.randomUUID()
815
+ const id = mintThreadId()
758
816
  await a.createThread({ id, meta: { source: 'contract' } })
759
817
  const thread = await loadOrThrow(a, id)
760
818
  assert(thread.meta?.['source'] === 'contract', 'meta did not round-trip')
761
819
  }),
762
820
 
763
821
  define('loadThread returns null for an unknown id', async (a) => {
764
- const thread = await a.loadThread(crypto.randomUUID())
822
+ const thread = await a.loadThread(mintThreadId())
765
823
  assert(thread === null, 'loadThread must return null (not throw, not undefined) when absent')
766
824
  }),
767
825
 
768
826
  define('appendMessage is idempotent on message.id', async (a) => {
769
- const id = crypto.randomUUID()
827
+ const id = mintThreadId()
770
828
  await a.createThread({ id })
771
829
  const msg = message<TPart>('contract-msg-1')
772
830
  await a.appendMessage({ threadId: id, message: msg })
@@ -780,7 +838,7 @@ export function threadsStoreAdapterContract<TPart>(
780
838
  }),
781
839
 
782
840
  define('appendMessage preserves order for distinct ids', async (a) => {
783
- const id = crypto.randomUUID()
841
+ const id = mintThreadId()
784
842
  await a.createThread({ id })
785
843
  await a.appendMessage({ threadId: id, message: message<TPart>('contract-msg-1') })
786
844
  await a.appendMessage({ threadId: id, message: message<TPart>('contract-msg-2') })
@@ -792,7 +850,7 @@ export function threadsStoreAdapterContract<TPart>(
792
850
  }),
793
851
 
794
852
  define('setStatus round-trips', async (a) => {
795
- const id = crypto.randomUUID()
853
+ const id = mintThreadId()
796
854
  await a.createThread({ id })
797
855
  await a.setStatus({ threadId: id, status: 'streaming' })
798
856
  assert((await loadOrThrow(a, id)).status === 'streaming', "status did not become 'streaming'")
@@ -801,7 +859,7 @@ export function threadsStoreAdapterContract<TPart>(
801
859
  }),
802
860
 
803
861
  define('setOutcome round-trips', async (a) => {
804
- const id = crypto.randomUUID()
862
+ const id = mintThreadId()
805
863
  await a.createThread({ id })
806
864
  const outcome: AgentOutcome = { title: 'Title', summary: 'Summary', status: 'done' }
807
865
  await a.setOutcome({ threadId: id, outcome })
@@ -812,7 +870,7 @@ export function threadsStoreAdapterContract<TPart>(
812
870
  }),
813
871
 
814
872
  define('setResumeToken round-trips and clears with undefined', async (a) => {
815
- const id = crypto.randomUUID()
873
+ const id = mintThreadId()
816
874
  await a.createThread({ id })
817
875
  await a.setResumeToken({ threadId: id, token: 'tok-1' })
818
876
  assert((await loadOrThrow(a, id)).resumeToken === 'tok-1', 'resumeToken did not round-trip')
@@ -824,7 +882,7 @@ export function threadsStoreAdapterContract<TPart>(
824
882
  }),
825
883
 
826
884
  define('markRead marks the thread read', async (a) => {
827
- const id = crypto.randomUUID()
885
+ const id = mintThreadId()
828
886
  await a.createThread({ id })
829
887
  assert((await loadOrThrow(a, id)).read === false, 'a new thread must start unread')
830
888
  await a.markRead(id)
@@ -835,7 +893,7 @@ export function threadsStoreAdapterContract<TPart>(
835
893
  // The store keeps a write's optimistic patch applied until a listThreads STARTED after that
836
894
  // write resolved lands in the confirmed base. An eventually-consistent list (a stale read
837
895
  // replica, a cached response) breaks that and flashes the pre-write state back into the UI.
838
- const id = crypto.randomUUID()
896
+ const id = mintThreadId()
839
897
  await a.createThread({ id })
840
898
  await a.appendMessage({ threadId: id, message: message<TPart>('contract-msg-raw') })
841
899
  await a.setStatus({ threadId: id, status: 'streaming' })
@@ -856,7 +914,7 @@ export function threadsStoreAdapterContract<TPart>(
856
914
  async (a) => {
857
915
  // The store serializes writes per thread id, so this exact sequence — the send path's
858
916
  // create-then-first-message — is what a real adapter receives. Each write must find the row.
859
- const id = crypto.randomUUID()
917
+ const id = mintThreadId()
860
918
  await a.createThread({ id })
861
919
  await a.markRead(id)
862
920
  await a.appendMessage({ threadId: id, message: message<TPart>('contract-msg-dep') })
@@ -872,7 +930,7 @@ export function threadsStoreAdapterContract<TPart>(
872
930
  ),
873
931
 
874
932
  define('removeThread removes the thread', async (a) => {
875
- const id = crypto.randomUUID()
933
+ const id = mintThreadId()
876
934
  await a.createThread({ id })
877
935
  await a.removeThread(id)
878
936
  assert(await a.loadThread(id).then((t) => t === null), 'loadThread must be null after remove')
@@ -881,7 +939,7 @@ export function threadsStoreAdapterContract<TPart>(
881
939
  }),
882
940
 
883
941
  define('removeThread of an unknown id does not throw', async (a) => {
884
- await a.removeThread(crypto.randomUUID())
942
+ await a.removeThread(mintThreadId())
885
943
  }),
886
944
  ]
887
945
  }
@@ -592,3 +592,67 @@ describe('aiSdkTransport — deterministic ids and replay idempotency', () => {
592
592
  )
593
593
  })
594
594
  })
595
+
596
+ describe('aiSdkTransport — id minting under degraded crypto', () => {
597
+ test('the fixed chatId (mintThreadId, low collision cost) mints fine with no usable crypto at all — construction never throws', () => {
598
+ const originalCrypto = globalThis.crypto
599
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
600
+ try {
601
+ expect(() =>
602
+ aiSdkTransport({
603
+ api: '/api/chat',
604
+ fetch: mockFetch(() => {
605
+ throw new Error('must not be called')
606
+ }),
607
+ }),
608
+ ).not.toThrow()
609
+ } finally {
610
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
611
+ }
612
+ })
613
+
614
+ test('the outbound user message id (mintMessageId, an idempotency key on the AI SDK backend) works via getRandomValues when randomUUID is unavailable', async () => {
615
+ const chunks: UIMessageChunk[] = [
616
+ { type: 'text-start', id: 't1' },
617
+ { type: 'text-end', id: 't1' },
618
+ ]
619
+ const transport = aiSdkTransport({
620
+ api: '/api/chat',
621
+ fetch: mockFetch(async () => scriptedResponse(chunks)),
622
+ })
623
+
624
+ const originalCrypto = globalThis.crypto
625
+ Object.defineProperty(globalThis, 'crypto', {
626
+ value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
627
+ configurable: true,
628
+ })
629
+ try {
630
+ const parts = await collect(transport.stream('hi'))
631
+ expect(parts[0]?.type).toBe('start')
632
+ } finally {
633
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
634
+ }
635
+ })
636
+
637
+ test('the outbound user message id THROWS on a host with no usable crypto at all, rather than silently colliding', async () => {
638
+ const transport = aiSdkTransport({
639
+ api: '/api/chat',
640
+ fetch: mockFetch(() => {
641
+ throw new Error('must not be called — must throw before any network call')
642
+ }),
643
+ })
644
+ const gen = transport.stream('hi')
645
+
646
+ // The synthesized StartPart is yielded first, before mintMessageId is ever reached.
647
+ const start = await gen.next()
648
+ expect(start.value).toMatchObject({ type: 'start' })
649
+
650
+ const originalCrypto = globalThis.crypto
651
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
652
+ try {
653
+ await expect(gen.next()).rejects.toThrow(/idempotency key/)
654
+ } finally {
655
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
656
+ }
657
+ })
658
+ })
@@ -48,6 +48,7 @@
48
48
  * })
49
49
  */
50
50
  import { assertNever } from '../register'
51
+ import { mintMessageId, mintThreadId } from './id'
51
52
  import { TERMINAL_TOOL_STATES } from './parts'
52
53
  import type { AgentPart, AgentPartDraft } from './parts'
53
54
  import type { ResumableAgentTransport } from './transport'
@@ -401,7 +402,10 @@ export function aiSdkTransport<TPart = AgentPart>(
401
402
 
402
403
  const { httpTransport, readUIMessageStream: readStream } = await resolveAiSdk()
403
404
  const userMessage: UIMessage = {
404
- id: crypto.randomUUID(),
405
+ // This id is what AI SDK's own backend persistence/dedup keys turns hang off — the same
406
+ // idempotency-key cost as basalt's own ChatMessage.id (see mintMessageId's doc), so
407
+ // mintMessageId, not mintThreadId, is the right helper here.
408
+ id: mintMessageId(),
405
409
  role: 'user',
406
410
  parts: [{ type: 'text', text: input }],
407
411
  }
@@ -425,7 +429,9 @@ export function aiSdkTransport<TPart = AgentPart>(
425
429
  }
426
430
  }
427
431
 
428
- const fixedChatId = crypto.randomUUID()
432
+ // Client-side conversation namespace, not an idempotency key — a collision here would merge two
433
+ // conversations' index-addressed part ids, the same low collision cost `mintThreadId` covers.
434
+ const fixedChatId = mintThreadId()
429
435
  return {
430
436
  ...makeTransport(fixedChatId),
431
437
  forThread: (chatId: string) => makeTransport(chatId),
@@ -1,9 +1,15 @@
1
1
  /**
2
2
  * withPartIds — stamps `${runId}#${n}` onto any draft part arriving without an id; idempotent for
3
3
  * drafts that already have one (untouched, and doesn't advance the counter).
4
+ *
5
+ * mintThreadId/mintMessageId — the two id-minting helpers behind every unguarded
6
+ * `crypto.randomUUID()` call site this file's siblings used to have. Both share rungs 1
7
+ * (`crypto.randomUUID`) and 2 (`crypto.getRandomValues`, hand-assembled into a UUIDv4); they
8
+ * deliberately diverge on rung 3 (no crypto at all) — `mintThreadId` degrades, `mintMessageId`
9
+ * throws. See each function's own doc for why.
4
10
  */
5
11
  import { describe, expect, test } from 'bun:test'
6
- import { withPartIds } from './id'
12
+ import { mintMessageId, mintThreadId, withPartIds } from './id'
7
13
  import type { AgentPartDraft } from './parts'
8
14
 
9
15
  async function* gen<T>(values: T[]): AsyncGenerator<T> {
@@ -16,6 +22,33 @@ async function collect<T>(source: AsyncGenerator<T>): Promise<T[]> {
16
22
  return out
17
23
  }
18
24
 
25
+ const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
26
+
27
+ /** Rung 2: randomUUID absent, getRandomValues present (a real, non-secure-context host). */
28
+ function withRung2Crypto<T>(run: () => T): T {
29
+ const originalCrypto = globalThis.crypto
30
+ Object.defineProperty(globalThis, 'crypto', {
31
+ value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
32
+ configurable: true,
33
+ })
34
+ try {
35
+ return run()
36
+ } finally {
37
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
38
+ }
39
+ }
40
+
41
+ /** Rung 3: no usable crypto at all. */
42
+ function withRung3Crypto<T>(run: () => T): T {
43
+ const originalCrypto = globalThis.crypto
44
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
45
+ try {
46
+ return run()
47
+ } finally {
48
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
49
+ }
50
+ }
51
+
19
52
  describe('withPartIds', () => {
20
53
  test('stamps a sequential id on every draft missing one', async () => {
21
54
  // Annotated: all three literals uniformly omit `id`, so without this TS infers an `id`-less
@@ -73,3 +106,64 @@ describe('withPartIds', () => {
73
106
  ])
74
107
  })
75
108
  })
109
+
110
+ describe('mintThreadId', () => {
111
+ test('rung 1 (crypto.randomUUID): returns a valid UUIDv4 string', () => {
112
+ const id = mintThreadId()
113
+ expect(id).toMatch(UUID_V4_RE)
114
+ })
115
+
116
+ test('rung 1: many mints are distinct', () => {
117
+ const ids = Array.from({ length: 1000 }, () => mintThreadId())
118
+ expect(new Set(ids).size).toBe(1000)
119
+ })
120
+
121
+ test('rung 2 (getRandomValues only): assembles a valid UUIDv4 — version/variant bits set', () => {
122
+ const id = withRung2Crypto(() => mintThreadId())
123
+ expect(id).toMatch(UUID_V4_RE)
124
+ })
125
+
126
+ test('rung 2: many mints are distinct', () => {
127
+ const ids = withRung2Crypto(() => Array.from({ length: 1000 }, () => mintThreadId()))
128
+ expect(new Set(ids).size).toBe(1000)
129
+ })
130
+
131
+ test('rung 3 (no usable crypto at all): does NOT throw, still returns distinct non-empty strings', () => {
132
+ const ids = withRung3Crypto(() => Array.from({ length: 50 }, () => mintThreadId()))
133
+ for (const id of ids) {
134
+ expect(typeof id).toBe('string')
135
+ expect(id.length).toBeGreaterThan(0)
136
+ }
137
+ // Weak (Date.now() + Math.random()), not cryptographically random — but still distinct across
138
+ // this many mints in one tick, since Math.random() varies even when Date.now() doesn't.
139
+ expect(new Set(ids).size).toBe(50)
140
+ })
141
+ })
142
+
143
+ describe('mintMessageId', () => {
144
+ test('rung 1 (crypto.randomUUID): returns a valid UUIDv4 string', () => {
145
+ const id = mintMessageId()
146
+ expect(id).toMatch(UUID_V4_RE)
147
+ })
148
+
149
+ test('rung 1: many mints are distinct', () => {
150
+ const ids = Array.from({ length: 1000 }, () => mintMessageId())
151
+ expect(new Set(ids).size).toBe(1000)
152
+ })
153
+
154
+ test('rung 2 (getRandomValues only): assembles a valid UUIDv4 — version/variant bits set', () => {
155
+ const id = withRung2Crypto(() => mintMessageId())
156
+ expect(id).toMatch(UUID_V4_RE)
157
+ })
158
+
159
+ test('rung 2: many mints are distinct', () => {
160
+ const ids = withRung2Crypto(() => Array.from({ length: 1000 }, () => mintMessageId()))
161
+ expect(new Set(ids).size).toBe(1000)
162
+ })
163
+
164
+ test('rung 3 (no usable crypto at all): THROWS rather than minting a non-random id', () => {
165
+ // Diverges from mintThreadId's rung 3 on purpose — appendMessage's idempotency key must never
166
+ // silently degrade to a non-collision-resistant id. See mintMessageId's own doc.
167
+ expect(() => withRung3Crypto(() => mintMessageId())).toThrow(/idempotency key/)
168
+ })
169
+ })