basalt-ui 1.12.0 → 1.14.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 (123) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +24 -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/shell/app-sidebar.d.ts +19 -1
  68. package/dist/shell/app-sidebar.d.ts.map +1 -1
  69. package/dist/shell/app-sidebar.js +30 -26
  70. package/dist/shell/app-sidebar.module.css +11 -0
  71. package/dist/shell/app-sidebar.module.css.d.ts +1 -0
  72. package/dist/shell/app-sidebar.test.d.ts +2 -0
  73. package/dist/shell/app-sidebar.test.d.ts.map +1 -0
  74. package/dist/shell/index.d.ts +10 -1
  75. package/dist/shell/index.d.ts.map +1 -1
  76. package/dist/shell/index.js +2 -0
  77. package/dist/shell/index.test.d.ts +2 -0
  78. package/dist/shell/index.test.d.ts.map +1 -0
  79. package/dist/surfaces.d.ts +2 -2
  80. package/dist/surfaces.d.ts.map +1 -1
  81. package/dist/surfaces.js +3 -2
  82. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  83. package/llms.txt +6 -6
  84. package/package.json +2 -2
  85. package/src/agent/adapter.test.ts +237 -0
  86. package/src/agent/adapter.ts +72 -14
  87. package/src/agent/ai-sdk-transport.test.ts +64 -0
  88. package/src/agent/ai-sdk-transport.ts +8 -2
  89. package/src/agent/id.test.ts +95 -1
  90. package/src/agent/id.ts +98 -28
  91. package/src/agent/thread.ts +4 -0
  92. package/src/agent/use-agent-stream.test.tsx +22 -0
  93. package/src/agent/use-agent-stream.ts +6 -3
  94. package/src/agent/use-agent-thread-runs.test.tsx +326 -0
  95. package/src/agent/use-agent-thread-runs.ts +159 -23
  96. package/src/agent/use-agent-thread-runs.wedge.test.tsx +80 -0
  97. package/src/agent-chat/index.ts +12 -0
  98. package/src/agent-chat/message-affordances.ts +61 -0
  99. package/src/agent-chat/relative-time.test.ts +95 -0
  100. package/src/agent-chat/relative-time.ts +55 -0
  101. package/src/agent-chat/thread-feed-row.test.tsx +510 -0
  102. package/src/agent-chat/thread-feed-row.tsx +274 -0
  103. package/src/agent-chat/thread-feed.test.tsx +241 -0
  104. package/src/agent-chat/thread-feed.tsx +119 -18
  105. package/src/agent-chat/thread-message.test.tsx +937 -3
  106. package/src/agent-chat/thread-message.tsx +766 -38
  107. package/src/agent-chat/thread-outcome-card.tsx +1 -25
  108. package/src/agent-chat/thread-workspace.test.tsx +169 -0
  109. package/src/agent-chat/thread-workspace.tsx +69 -5
  110. package/src/agent-chat/virtualize.ts +132 -0
  111. package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
  112. package/src/data/index.ts +5 -1
  113. package/src/data/virtual-list.tsx +2 -1
  114. package/src/data/virtual.ts +2 -1
  115. package/src/index.ts +5 -0
  116. package/src/shell/app-sidebar.module.css +11 -0
  117. package/src/shell/app-sidebar.module.css.d.ts +1 -0
  118. package/src/shell/app-sidebar.test.tsx +98 -0
  119. package/src/shell/app-sidebar.tsx +22 -0
  120. package/src/shell/index.test.tsx +35 -0
  121. package/src/shell/index.tsx +11 -0
  122. package/src/surfaces.ts +2 -1
  123. package/src/theme/shadow-surfaces.test.ts +9 -0
@@ -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
+ })
package/src/agent/id.ts CHANGED
@@ -1,51 +1,121 @@
1
1
  /**
2
- * Id-minting helpers shared by the thread stores: `mintThreadId` (a client-side thread id, used by
3
- * both `./thread`'s `createThreadsStore` and `./adapter`'s `createAdapterThreadsStore`) and
4
- * `withPartIds` (stamps sequence ids onto streamed parts). Related in theme — both mint an id a
5
- * store needs — unrelated in mechanism; grouped here because they're the two id-minting leaves the
6
- * two thread stores both sit above.
2
+ * Id-minting helpers shared across the agent layer: `mintThreadId` (a low-stakes client-side id —
3
+ * threads, run/chat namespaces — used by `./thread`'s `createThreadsStore`, `./adapter`'s
4
+ * `createAdapterThreadsStore`/`threadsStoreAdapterContract`, `./use-agent-stream`'s per-send run
5
+ * id, and `aiSdkTransport`'s fixed chat id), `mintMessageId` (the higher-stakes id `appendMessage`
6
+ * treats as its only idempotency key), and `withPartIds` (stamps sequence ids onto streamed
7
+ * parts). Grouped here because they're the id-minting leaves every store/hook/transport in this
8
+ * layer sits above — not because they share a mechanism (`mintMessageId` deliberately diverges
9
+ * from `mintThreadId` on rung 3; see its own doc).
7
10
  */
8
11
 
9
12
  /**
10
- * Mints a client-side thread id. `crypto.randomUUID()` exists only in SECURE CONTEXTS (HTTPS or
11
- * localhost) — a consumer served over plain HTTP on a LAN hostname (a homelab dashboard on a bare
12
- * IP, a staging box) would otherwise have `create()`, about the most basic user action a thread
13
- * store has, throw a plain TypeError. A store used from a browser must not throw on a user gesture
14
- * — the same principle already applied twice this release (`spliceText` clamps rather than throws,
15
- * `coalesceParts` degrades rather than throws).
13
+ * Hand-assembles a UUIDv4 string from 16 random bytes — the shared rung-2 mechanism both
14
+ * `mintThreadId` and `mintMessageId` use once `crypto.randomUUID` is unavailable but
15
+ * `crypto.getRandomValues` still is. The version/variant bits are set per RFC 4122, since this
16
+ * rung hands back raw bytes, not a formatted UUID.
17
+ */
18
+ function assembleUuidV4(bytes: Uint8Array): string {
19
+ const hex = Array.from({ length: 16 }, (_, i) => {
20
+ const raw = bytes.at(i) ?? 0
21
+ // Version 4 (bits 12-15 of time_hi_and_version) and variant 10 (bits 6-7 of
22
+ // clock_seq_hi_and_reserved) — the only structure a UUIDv4 promises over raw random bytes.
23
+ const byte = i === 6 ? (raw & 0x0f) | 0x40 : i === 8 ? (raw & 0x3f) | 0x80 : raw
24
+ return byte.toString(16).padStart(2, '0')
25
+ }).join('')
26
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
27
+ }
28
+
29
+ /**
30
+ * Mints a client-side id for a LOW-COLLISION-COST use: a thread id, a per-send run-id namespace, a
31
+ * fixed chat id — anything minted client-side that is NOT the idempotency key some write depends
32
+ * on. `crypto.randomUUID()` exists only in SECURE CONTEXTS (HTTPS or localhost) — a consumer served
33
+ * over plain HTTP on a LAN hostname (a homelab dashboard on a bare IP, a staging box) would
34
+ * otherwise have `create()`, about the most basic user action a thread store has, throw a plain
35
+ * TypeError. A store used from a browser must not throw on a user gesture — the same principle
36
+ * already applied twice this release (`spliceText` clamps rather than throws, `coalesceParts`
37
+ * degrades rather than throws).
16
38
  *
17
39
  * Fallback chain, each rung reached only when the one above it is unavailable:
18
40
  * 1. `crypto.randomUUID()` — cryptographically random, RFC 4122 UUID. The normal path.
19
41
  * 2. `crypto.getRandomValues()` — still cryptographically random; hand-assembled into a UUIDv4
20
- * (the version/variant bits are set per RFC 4122, since this rung hands back raw bytes, not a
21
- * formatted UUID).
42
+ * via `assembleUuidV4`.
22
43
  * 3. No usable `crypto` at all (missing entirely, or missing both methods above — an old WebView,
23
44
  * an SSR shim). NOT cryptographically random and NOT collision-resistant in general. This rung
24
- * exists solely so `create()` never throws on such a host. It is acceptable ONLY because this
25
- * id is minted a handful of times per client session and is not the idempotency key any write
26
- * here depends on — `ThreadsStoreAdapter.appendMessage`'s idempotency key is the MESSAGE id,
27
- * not this thread id, so a collision on this rung costs a locally merged/duplicated thread, not
28
- * silent data loss on the append path. Do not reuse this rung for anything with a higher
29
- * collision cost than that.
45
+ * exists solely so a caller never throws on such a host. It is acceptable ONLY because an id
46
+ * minted here is minted a handful of times per client session and is not the idempotency key
47
+ * any write depends on — `ThreadsStoreAdapter.appendMessage`'s idempotency key is the MESSAGE
48
+ * id (see `mintMessageId`), not this one, so a collision on this rung costs a locally
49
+ * merged/duplicated thread (or two runs sharing a part-id namespace), not silent data loss on
50
+ * the append path. Do not reuse this rung for anything with a higher collision cost than that
51
+ * — see `mintMessageId` for the id that needs one.
30
52
  */
31
53
  export function mintThreadId(): string {
32
54
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
33
55
  return crypto.randomUUID()
34
56
  }
35
57
  if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
36
- const bytes = crypto.getRandomValues(new Uint8Array(16))
37
- const hex = Array.from({ length: 16 }, (_, i) => {
38
- const raw = bytes.at(i) ?? 0
39
- // Version 4 (bits 12-15 of time_hi_and_version) and variant 10 (bits 6-7 of
40
- // clock_seq_hi_and_reserved) — the only structure a UUIDv4 promises over raw random bytes.
41
- const byte = i === 6 ? (raw & 0x0f) | 0x40 : i === 8 ? (raw & 0x3f) | 0x80 : raw
42
- return byte.toString(16).padStart(2, '0')
43
- }).join('')
44
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
58
+ return assembleUuidV4(crypto.getRandomValues(new Uint8Array(16)))
45
59
  }
46
60
  return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
47
61
  }
48
62
 
63
+ /**
64
+ * Mints a message id — the value `ThreadsStoreAdapter.appendMessage` treats as its ONLY
65
+ * idempotency key (see that method's contract doc in `./adapter`). Shares `mintThreadId`'s rungs
66
+ * 1-2 (`crypto.randomUUID()`, then `crypto.getRandomValues()` assembled into a UUIDv4) — both are
67
+ * cryptographically random and collision-resistant, and rung 2 is the fix that actually matters in
68
+ * practice (a plain-HTTP/LAN host still has `getRandomValues`; only `randomUUID` is secure-context-
69
+ * gated).
70
+ *
71
+ * Rung 3 deliberately DIVERGES from `mintThreadId`: it THROWS instead of degrading to a
72
+ * non-random fallback. A thread id's rung-3 collision costs a locally merged/duplicated thread —
73
+ * annoying, recoverable, visible. A message id's collision cost is different IN KIND, not degree:
74
+ * `appendMessage` is idempotent on this id, so two messages that collide are not two rows, they
75
+ * are ONE — the second write silently no-ops and its content is gone, with nothing downstream able
76
+ * to tell. Degrading here would trade a loud, debuggable failure for silent, permanent data loss.
77
+ *
78
+ * This deliberately diverges from this layer's other standing rule — render-path code degrades,
79
+ * never throws (`spliceText` clamps, `coalesceParts` degrades, a consumer fence renderer must not
80
+ * take the message down) — because minting a message id is NOT render-path code. It runs once, in
81
+ * the write path that constructs a message before handing it to a store (`start()`,
82
+ * `consumeAndFinalize`'s finalize step, `finalizeStop`, `aiSdkTransport`'s per-turn user message),
83
+ * never inside the per-chunk accumulation/render loop those functions guard. A host with no usable
84
+ * `crypto` at all (missing both `randomUUID` and `getRandomValues`) is vanishingly rare — no
85
+ * runtime with a DOM/fetch surface worth targeting ships without a Crypto object — and an uncaught
86
+ * throw here is a loud, debuggable failure rather than a message silently vanishing with no trace,
87
+ * but WHERE it surfaces differs by call site (audited, not assumed — this paragraph previously
88
+ * claimed a single shared catch handled all of them, which was false):
89
+ * - `consumeAndFinalize`'s own assistantMessage mint runs inside that function's try/catch, so a
90
+ * throw there is correctly turned into `onFailureStatus` ('error' from `start()`, 'interrupted'
91
+ * from the mount-time resume) — a visibly stuck-then-settled run, as originally claimed.
92
+ * - `useAgentThreadRuns.start()`'s userMessage mint runs BEFORE any state is touched
93
+ * (`appendMessage`/`setStatus`/the controller registration all come after it), so a throw there
94
+ * is a true no-op that propagates synchronously out of `start()` itself — not a stuck run at
95
+ * all, since nothing ever started. See `UseAgentThreadRunsReturn.start`'s `@throws` doc.
96
+ * - `finalizeStop`'s stoppedMessage mint is wrapped in its own try/catch precisely so a throw
97
+ * here cannot wedge the thread at 'streaming' forever (stop() has already torn down this run's
98
+ * bookkeeping by the time finalizeStop runs, so a second stop() is always a no-op) — see
99
+ * `finalizeStop`'s doc for the guarded behavior and the resulting 'error' status.
100
+ * A stuck spinner is debuggable; silently discarded chat history is not — that half of the
101
+ * original claim stands regardless of which of the three paths above catches it.
102
+ *
103
+ * @throws {Error} when neither `crypto.randomUUID` nor `crypto.getRandomValues` is available.
104
+ */
105
+ export function mintMessageId(): string {
106
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
107
+ return crypto.randomUUID()
108
+ }
109
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
110
+ return assembleUuidV4(crypto.getRandomValues(new Uint8Array(16)))
111
+ }
112
+ throw new Error(
113
+ 'mintMessageId: no usable crypto (both randomUUID and getRandomValues are unavailable) — ' +
114
+ 'refusing to mint a non-random message id, since ThreadsStoreAdapter.appendMessage treats ' +
115
+ 'this id as its only idempotency key and a collision would silently drop a message.',
116
+ )
117
+ }
118
+
49
119
  /**
50
120
  * withPartIds — stamps a stable id onto every draft part arriving without one.
51
121
  *
@@ -145,6 +145,10 @@ export type ThreadsStore<TPart = AgentPart> = {
145
145
  * store built with `createAdapterThreadsStore` (./adapter), where it stays `false` until the
146
146
  * first `listThreads` SUCCEEDS. Pair it with `error` — `!hydrated && error !== undefined` is a
147
147
  * failed load, `!hydrated && error === undefined` is still loading.
148
+ *
149
+ * Gating an empty state on this is the CALLER's job when hand-assembling `ThreadFeed` /
150
+ * `ThreadDetailPanel`; the shipped `ThreadWorkspace` already does it internally, so an
151
+ * async store never flashes "no threads yet" through it before it actually knows.
148
152
  */
149
153
  readonly hydrated: boolean
150
154
  /**
@@ -262,4 +262,26 @@ describe('useAgentStream', () => {
262
262
  // Only the SECOND (surviving) call's part made it into final state — the first is discarded.
263
263
  expect(capturedParts).toEqual([{ id: 'call-2', type: 'text', text: 'call-2' }])
264
264
  })
265
+
266
+ test('send() still streams without throwing on a host with no usable crypto at all — its runId is a low-collision-cost mint (mintThreadId), never the message-id one', async () => {
267
+ const originalCrypto = globalThis.crypto
268
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
269
+ try {
270
+ const transport: AgentTransport<AgentPart, string> = {
271
+ async *stream() {
272
+ yield { type: 'text', text: 'a' } as AgentPartDraft as AgentPart
273
+ },
274
+ }
275
+ const { result } = renderHook(() => useAgentStream({ transport }))
276
+
277
+ await act(async () => {
278
+ await result.current.send('hi')
279
+ })
280
+
281
+ expect(result.current.status).toBe('done')
282
+ expect(result.current.parts).toHaveLength(1)
283
+ } finally {
284
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
285
+ }
286
+ })
265
287
  })
@@ -27,7 +27,7 @@
27
27
  * }
28
28
  */
29
29
  import { useCallback, useEffect, useRef, useState } from 'react'
30
- import { withPartIds } from './id'
30
+ import { mintThreadId, withPartIds } from './id'
31
31
  import { isStartPart } from './parts'
32
32
  import { mergePart } from './merge'
33
33
  import type { PartLike } from './merge'
@@ -107,8 +107,11 @@ export function useAgentStream<TPart extends PartLike = AgentPart, TInput = stri
107
107
  // fully resets `parts` anyway, so a fresh id per call is enough to keep two id-less drafts
108
108
  // ARRIVING WITHIN THE SAME SEND from colliding on `undefined === undefined` in mergePart (the
109
109
  // F1 bug — text→tool→text destroying each other). Not a replay mechanism — this hook has no
110
- // resume() path — see withPartIds' own doc for why it can't be one anyway.
111
- const runId = crypto.randomUUID()
110
+ // resume() path — see withPartIds' own doc for why it can't be one anyway. Low collision
111
+ // cost (this hook persists nothing — `parts` is discarded on the next send()/unmount), so
112
+ // `mintThreadId` — never `mintMessageId` — is the right rung-3 behavior here: this must not
113
+ // throw on a host with no usable crypto at all.
114
+ const runId = mintThreadId()
112
115
 
113
116
  try {
114
117
  for await (const part of withPartIds(runId, transport.stream(input, controller.signal))) {
@@ -792,3 +792,329 @@ describe('useAgentThreadRuns — F1 regression: id-less drafts within one run',
792
792
  expect(assistantMessage?.parts.map((part) => part.type)).toEqual(['text', 'tool', 'text'])
793
793
  })
794
794
  })
795
+
796
+ describe('useAgentThreadRuns — message-id minting under degraded crypto', () => {
797
+ test('start() mints distinct message ids via crypto.getRandomValues when randomUUID is unavailable', async () => {
798
+ const store = createTestThreadsStore()
799
+ const threadA = store.create()
800
+ const threadB = store.create()
801
+
802
+ const transport: AgentTransport<AgentPart, string> = {
803
+ async *stream(input) {
804
+ yield { id: 'p1', type: 'text', text: `done:${input}` }
805
+ },
806
+ }
807
+
808
+ const originalCrypto = globalThis.crypto
809
+ Object.defineProperty(globalThis, 'crypto', {
810
+ value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
811
+ configurable: true,
812
+ })
813
+ try {
814
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
815
+
816
+ act(() => {
817
+ result.current.start(threadA, 'input-a')
818
+ result.current.start(threadB, 'input-b')
819
+ })
820
+
821
+ await waitFor(() => {
822
+ expect(store.threads.find((t) => t.id === threadA)?.status).toBe('done')
823
+ expect(store.threads.find((t) => t.id === threadB)?.status).toBe('done')
824
+ })
825
+
826
+ const allMessageIds = store.threads.flatMap((t) => t.messages.map((m) => m.id))
827
+ expect(allMessageIds.length).toBeGreaterThan(0)
828
+ expect(new Set(allMessageIds).size).toBe(allMessageIds.length)
829
+ } finally {
830
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
831
+ }
832
+ })
833
+
834
+ test('start() THROWS on a host with no usable crypto at all, rather than silently minting a colliding message id', () => {
835
+ const store = createTestThreadsStore()
836
+ const threadId = store.create()
837
+
838
+ const transport: AgentTransport<AgentPart, string> = {
839
+ async *stream() {
840
+ yield { id: 'p1', type: 'text', text: 'unreachable' }
841
+ },
842
+ }
843
+
844
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
845
+
846
+ const originalCrypto = globalThis.crypto
847
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
848
+ try {
849
+ expect(() => {
850
+ act(() => {
851
+ result.current.start(threadId, 'hi')
852
+ })
853
+ }).toThrow(/idempotency key/)
854
+ } finally {
855
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
856
+ }
857
+
858
+ // The deliberate divergence documented on mintMessageId: throwing means the write never
859
+ // happened at all (no half-appended message, no orphaned run entry) rather than silently
860
+ // dropping content behind a colliding id.
861
+ expect(store.threads.find((t) => t.id === threadId)?.messages).toHaveLength(0)
862
+ expect(result.current.runs.has(threadId)).toBe(false)
863
+ })
864
+ })
865
+
866
+ // A ThreadsStore double that delegates everything to a real test store EXCEPT appendMessage,
867
+ // which throws for messages matching `shouldThrow` — lets a test simulate consumer-adapter code
868
+ // (appendMessage) throwing without touching the shared createTestThreadsStore factory used by
869
+ // every other test in this file.
870
+ function wrapStoreWithThrowingAppend(
871
+ store: ThreadsStore<AgentPart>,
872
+ shouldThrow: (message: AgentThread<AgentPart>['messages'][number]) => boolean,
873
+ ): ThreadsStore<AgentPart> {
874
+ return {
875
+ get threads() {
876
+ return store.threads
877
+ },
878
+ get activeId() {
879
+ return store.activeId
880
+ },
881
+ select: store.select,
882
+ create: store.create,
883
+ appendMessage(id, message) {
884
+ if (shouldThrow(message)) {
885
+ throw new Error('consumer appendMessage boom')
886
+ }
887
+ store.appendMessage(id, message)
888
+ },
889
+ setOutcome: store.setOutcome,
890
+ setStatus: store.setStatus,
891
+ setResumeToken: store.setResumeToken,
892
+ markRead: store.markRead,
893
+ remove: store.remove,
894
+ clear: store.clear,
895
+ hydrated: store.hydrated,
896
+ error: store.error,
897
+ }
898
+ }
899
+
900
+ /** As above, but for the settle half of `finalizeStop` — a consumer store whose `setStatus` throws
901
+ * (a remote-backed adapter rejecting a thread removed mid-stream is the realistic shape). Narrowed
902
+ * by `shouldThrow` because the hook ALSO calls `setStatus` from its mount-time orphan sweep, which
903
+ * is a different call site with a different (non-wedge) failure mode. */
904
+ function wrapStoreWithThrowingSetStatus(
905
+ store: ThreadsStore<AgentPart>,
906
+ shouldThrow: (status: AgentThread<AgentPart>['status']) => boolean,
907
+ ): ThreadsStore<AgentPart> {
908
+ return {
909
+ get threads() {
910
+ return store.threads
911
+ },
912
+ get activeId() {
913
+ return store.activeId
914
+ },
915
+ select: store.select,
916
+ create: store.create,
917
+ appendMessage: store.appendMessage,
918
+ setOutcome: store.setOutcome,
919
+ setStatus(id, status) {
920
+ if (shouldThrow(status)) throw new Error('consumer setStatus boom')
921
+ store.setStatus(id, status)
922
+ },
923
+ setResumeToken: store.setResumeToken,
924
+ markRead: store.markRead,
925
+ remove: store.remove,
926
+ clear: store.clear,
927
+ hydrated: store.hydrated,
928
+ error: store.error,
929
+ }
930
+ }
931
+
932
+ describe('useAgentThreadRuns — finalizeStop must reach a terminal state no matter what', () => {
933
+ test('stop() on a host with no usable crypto still reaches a terminal status and clears the run entry (was: wedged at "streaming" forever)', async () => {
934
+ const store = createTestThreadsStore()
935
+ const threadId = store.create()
936
+
937
+ const gate = deferred<void>()
938
+ const transport: AgentTransport<AgentPart, string> = {
939
+ async *stream() {
940
+ yield { id: 'p1', type: 'text', text: 'partial' }
941
+ await gate.promise
942
+ yield { id: 'p2', type: 'text', text: 'never arrives' }
943
+ },
944
+ }
945
+
946
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
947
+
948
+ act(() => {
949
+ result.current.start(threadId, 'hi')
950
+ })
951
+
952
+ await waitFor(() => {
953
+ expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
954
+ })
955
+
956
+ const originalCrypto = globalThis.crypto
957
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
958
+ try {
959
+ act(() => {
960
+ result.current.stop(threadId)
961
+ })
962
+
963
+ const thread = store.threads.find((t) => t.id === threadId)
964
+ // Before the fix: mintMessageId's throw escaped finalizeStop entirely, so setStatus/setRuns
965
+ // never ran and this stayed 'streaming' forever.
966
+ expect(thread?.status).toBe('error')
967
+ // The stopped message never minted an id, so appendMessage was never even attempted —
968
+ // only the original user message is present.
969
+ expect(thread?.messages).toHaveLength(1)
970
+ expect(result.current.runs.has(threadId)).toBe(false)
971
+
972
+ // A second stop() must not be the user's only recourse — and it isn't: controllersRef
973
+ // already had this threadId deleted before finalizeStop ever ran, so this is an ordinary
974
+ // no-op, not a retry of the failed append.
975
+ act(() => {
976
+ result.current.stop(threadId)
977
+ })
978
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('error')
979
+ } finally {
980
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
981
+ }
982
+
983
+ gate.resolve(undefined)
984
+ })
985
+
986
+ test("stop() reaches a terminal status and clears the run entry even when the consumer's appendMessage throws (was: wedged at 'streaming' forever)", async () => {
987
+ const baseStore = createTestThreadsStore()
988
+ const threadId = baseStore.create()
989
+ const store = wrapStoreWithThrowingAppend(
990
+ baseStore,
991
+ (message) => message.role === 'assistant' && message.finish === 'stopped',
992
+ )
993
+
994
+ const gate = deferred<void>()
995
+ const transport: AgentTransport<AgentPart, string> = {
996
+ async *stream() {
997
+ yield { id: 'p1', type: 'text', text: 'partial' }
998
+ await gate.promise
999
+ yield { id: 'p2', type: 'text', text: 'never arrives' }
1000
+ },
1001
+ }
1002
+
1003
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
1004
+
1005
+ act(() => {
1006
+ result.current.start(threadId, 'hi')
1007
+ })
1008
+
1009
+ await waitFor(() => {
1010
+ expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
1011
+ })
1012
+
1013
+ act(() => {
1014
+ result.current.stop(threadId)
1015
+ })
1016
+
1017
+ const thread = store.threads.find((t) => t.id === threadId)
1018
+ expect(thread?.status).toBe('error')
1019
+ // Only the user message — the consumer's appendMessage rejected the stopped message.
1020
+ expect(thread?.messages).toHaveLength(1)
1021
+ expect(result.current.runs.has(threadId)).toBe(false)
1022
+
1023
+ // A second stop() remains a true no-op — not the user's only recourse.
1024
+ act(() => {
1025
+ result.current.stop(threadId)
1026
+ })
1027
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('error')
1028
+
1029
+ gate.resolve(undefined)
1030
+ })
1031
+
1032
+ test("stop() clears the hook's own run entry even when the consumer's setStatus throws (the second half of the same wedge)", async () => {
1033
+ const baseStore = createTestThreadsStore()
1034
+ const threadId = baseStore.create()
1035
+ const store = wrapStoreWithThrowingSetStatus(baseStore, (status) => status === 'done')
1036
+
1037
+ const gate = deferred<void>()
1038
+ const transport: AgentTransport<AgentPart, string> = {
1039
+ async *stream() {
1040
+ yield { id: 'p1', type: 'text', text: 'partial' }
1041
+ await gate.promise
1042
+ yield { id: 'p2', type: 'text', text: 'never arrives' }
1043
+ },
1044
+ }
1045
+
1046
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
1047
+
1048
+ act(() => {
1049
+ result.current.start(threadId, 'hi')
1050
+ })
1051
+
1052
+ await waitFor(() => {
1053
+ expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
1054
+ })
1055
+
1056
+ act(() => {
1057
+ result.current.stop(threadId)
1058
+ })
1059
+
1060
+ // The store's own status is whatever the throwing adapter left it as — unrecoverable from
1061
+ // here. What IS this hook's to guarantee is that `runs` no longer reports an in-flight turn:
1062
+ // that entry is the hook's own state and nothing later can ever clear it (stop() already
1063
+ // deleted the thread from controllersRef), so a throw above the teardown stranded it forever.
1064
+ expect(result.current.runs.has(threadId)).toBe(false)
1065
+ // The partial content still landed — the append runs before the settle.
1066
+ expect(store.threads.find((t) => t.id === threadId)?.messages).toHaveLength(2)
1067
+
1068
+ act(() => {
1069
+ result.current.stop(threadId)
1070
+ })
1071
+ expect(result.current.runs.has(threadId)).toBe(false)
1072
+
1073
+ gate.resolve(undefined)
1074
+ })
1075
+
1076
+ test('stop() still succeeds via crypto.getRandomValues when randomUUID is unavailable (rung 2 — the rung that matters in practice)', async () => {
1077
+ const store = createTestThreadsStore()
1078
+ const threadId = store.create()
1079
+
1080
+ const gate = deferred<void>()
1081
+ const transport: AgentTransport<AgentPart, string> = {
1082
+ async *stream() {
1083
+ yield { id: 'p1', type: 'text', text: 'partial' }
1084
+ await gate.promise
1085
+ yield { id: 'p2', type: 'text', text: 'never arrives' }
1086
+ },
1087
+ }
1088
+
1089
+ const originalCrypto = globalThis.crypto
1090
+ Object.defineProperty(globalThis, 'crypto', {
1091
+ value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
1092
+ configurable: true,
1093
+ })
1094
+ try {
1095
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
1096
+
1097
+ act(() => {
1098
+ result.current.start(threadId, 'hi')
1099
+ })
1100
+
1101
+ await waitFor(() => {
1102
+ expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
1103
+ })
1104
+
1105
+ act(() => {
1106
+ result.current.stop(threadId)
1107
+ })
1108
+
1109
+ const thread = store.threads.find((t) => t.id === threadId)
1110
+ expect(thread?.status).toBe('done')
1111
+ expect(thread?.messages).toHaveLength(2)
1112
+ expect(thread?.messages[1]?.finish).toBe('stopped')
1113
+ expect(result.current.runs.has(threadId)).toBe(false)
1114
+ } finally {
1115
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
1116
+ }
1117
+
1118
+ gate.resolve(undefined)
1119
+ })
1120
+ })