basalt-ui 1.11.0 → 1.12.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 (237) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +36 -27
  4. package/agent/rules/basalt-content.md +16 -3
  5. package/dist/agent/adapter.d.ts +146 -0
  6. package/dist/agent/adapter.d.ts.map +1 -0
  7. package/dist/agent/adapter.js +473 -0
  8. package/dist/agent/adapter.test.d.ts +2 -0
  9. package/dist/agent/adapter.test.d.ts.map +1 -0
  10. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  11. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  12. package/dist/agent/coalesce.d.ts +20 -4
  13. package/dist/agent/coalesce.d.ts.map +1 -1
  14. package/dist/agent/coalesce.js +76 -8
  15. package/dist/agent/coalesce.test.d.ts +2 -0
  16. package/dist/agent/coalesce.test.d.ts.map +1 -0
  17. package/dist/agent/foreign.test.d.ts +2 -0
  18. package/dist/agent/foreign.test.d.ts.map +1 -0
  19. package/dist/agent/id.d.ts +30 -0
  20. package/dist/agent/id.d.ts.map +1 -1
  21. package/dist/agent/id.js +16 -0
  22. package/dist/agent/id.test.d.ts +2 -0
  23. package/dist/agent/id.test.d.ts.map +1 -0
  24. package/dist/agent/index.d.ts +2 -0
  25. package/dist/agent/index.d.ts.map +1 -1
  26. package/dist/agent/index.js +3 -0
  27. package/dist/agent/merge.d.ts.map +1 -1
  28. package/dist/agent/merge.js +19 -7
  29. package/dist/agent/merge.test.d.ts +2 -0
  30. package/dist/agent/merge.test.d.ts.map +1 -0
  31. package/dist/agent/part-list.d.ts +49 -34
  32. package/dist/agent/part-list.d.ts.map +1 -1
  33. package/dist/agent/part-list.js +42 -6
  34. package/dist/agent/part-list.test.d.ts +2 -0
  35. package/dist/agent/part-list.test.d.ts.map +1 -0
  36. package/dist/agent/parts.d.ts +46 -10
  37. package/dist/agent/parts.d.ts.map +1 -1
  38. package/dist/agent/parts.js +16 -6
  39. package/dist/agent/parts.test.d.ts +2 -0
  40. package/dist/agent/parts.test.d.ts.map +1 -0
  41. package/dist/agent/thread.d.ts +14 -0
  42. package/dist/agent/thread.d.ts.map +1 -1
  43. package/dist/agent/thread.js +8 -2
  44. package/dist/agent/thread.test.d.ts +2 -0
  45. package/dist/agent/thread.test.d.ts.map +1 -0
  46. package/dist/agent/transport.test.d.ts +2 -0
  47. package/dist/agent/transport.test.d.ts.map +1 -0
  48. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  49. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  50. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  51. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  52. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  53. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  54. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  55. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  56. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  57. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  58. package/dist/agent-chat/composer.d.ts +130 -8
  59. package/dist/agent-chat/composer.d.ts.map +1 -1
  60. package/dist/agent-chat/composer.js +189 -23
  61. package/dist/agent-chat/composer.test.d.ts +2 -0
  62. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  63. package/dist/agent-chat/index.d.ts +1 -1
  64. package/dist/agent-chat/index.d.ts.map +1 -1
  65. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  66. package/dist/agent-chat/thread-detail-panel.js +1 -1
  67. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  68. package/dist/agent-chat/thread-message.js +14 -3
  69. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  70. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  71. package/dist/agent-chat/thread-workspace.js +1 -1
  72. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  73. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  74. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  75. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  76. package/dist/charts/utils/domain.test.d.ts +2 -0
  77. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  78. package/dist/cli/check-theme.test.d.ts +2 -0
  79. package/dist/cli/check-theme.test.d.ts.map +1 -0
  80. package/dist/cli/help.test.d.ts +2 -0
  81. package/dist/cli/help.test.d.ts.map +1 -0
  82. package/dist/cli/index.d.ts.map +1 -1
  83. package/dist/cli/init-sync.test.d.ts +2 -0
  84. package/dist/cli/init-sync.test.d.ts.map +1 -0
  85. package/dist/cli/placement-engine.test.d.ts +2 -0
  86. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  87. package/dist/cli/tarball-parity.test.d.ts +2 -0
  88. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  89. package/dist/cli/template-format.test.d.ts +2 -0
  90. package/dist/cli/template-format.test.d.ts.map +1 -0
  91. package/dist/cli/template-ledger.test.d.ts +2 -0
  92. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  93. package/dist/cli/tokens-css.test.d.ts +2 -0
  94. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  95. package/dist/content/article-actions.test.d.ts +2 -0
  96. package/dist/content/article-actions.test.d.ts.map +1 -0
  97. package/dist/content/article-model.test.d.ts +2 -0
  98. package/dist/content/article-model.test.d.ts.map +1 -0
  99. package/dist/content/block-split.test.d.ts +2 -0
  100. package/dist/content/block-split.test.d.ts.map +1 -0
  101. package/dist/content/fence-block.d.ts +37 -10
  102. package/dist/content/fence-block.d.ts.map +1 -1
  103. package/dist/content/fence-block.js +80 -14
  104. package/dist/content/heading-components.d.ts.map +1 -1
  105. package/dist/content/heading-components.js +7 -4
  106. package/dist/content/heading-components.test.d.ts +2 -0
  107. package/dist/content/heading-components.test.d.ts.map +1 -0
  108. package/dist/content/index.d.ts +6 -3
  109. package/dist/content/index.d.ts.map +1 -1
  110. package/dist/content/index.js +5 -1
  111. package/dist/content/markdown.d.ts +101 -14
  112. package/dist/content/markdown.d.ts.map +1 -1
  113. package/dist/content/markdown.js +141 -28
  114. package/dist/content/markdown.test.d.ts +2 -0
  115. package/dist/content/markdown.test.d.ts.map +1 -0
  116. package/dist/content/prose.module.css.test.d.ts +2 -0
  117. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  118. package/dist/content/sanitize.d.ts +170 -0
  119. package/dist/content/sanitize.d.ts.map +1 -0
  120. package/dist/content/sanitize.js +178 -0
  121. package/dist/content/sanitize.test.d.ts +2 -0
  122. package/dist/content/sanitize.test.d.ts.map +1 -0
  123. package/dist/content/slug.test.d.ts +2 -0
  124. package/dist/content/slug.test.d.ts.map +1 -0
  125. package/dist/dashboard/stat-card.test.d.ts +2 -0
  126. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  127. package/dist/guard/check-source.test.d.ts +2 -0
  128. package/dist/guard/check-source.test.d.ts.map +1 -0
  129. package/dist/guard/guard-hook.test.d.ts +2 -0
  130. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  131. package/dist/index.d.ts +3 -3
  132. package/dist/index.d.ts.map +1 -1
  133. package/dist/index.js +9 -1
  134. package/dist/provider/index.test.d.ts +2 -0
  135. package/dist/provider/index.test.d.ts.map +1 -0
  136. package/dist/provider/lab-theme.test.d.ts +2 -0
  137. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  138. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  139. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  140. package/dist/styles.floor.test.d.ts +2 -0
  141. package/dist/styles.floor.test.d.ts.map +1 -0
  142. package/dist/surfaces.d.ts +4 -4
  143. package/dist/surfaces.d.ts.map +1 -1
  144. package/dist/surfaces.js +12 -3
  145. package/dist/theme/border-coverage.test.d.ts +18 -0
  146. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  147. package/dist/theme/contrast.test.d.ts +2 -0
  148. package/dist/theme/contrast.test.d.ts.map +1 -0
  149. package/dist/theme/density-option.test.d.ts +2 -0
  150. package/dist/theme/density-option.test.d.ts.map +1 -0
  151. package/dist/theme/density-relations.test.d.ts +2 -0
  152. package/dist/theme/density-relations.test.d.ts.map +1 -0
  153. package/dist/theme/derive-theme.test.d.ts +2 -0
  154. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  155. package/dist/theme/fonts-option.test.d.ts +2 -0
  156. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  157. package/dist/theme/radius-option.test.d.ts +2 -0
  158. package/dist/theme/radius-option.test.d.ts.map +1 -0
  159. package/dist/theme/radius.test.d.ts +2 -0
  160. package/dist/theme/radius.test.d.ts.map +1 -0
  161. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  162. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  163. package/dist/theme/spacing.test.d.ts +2 -0
  164. package/dist/theme/spacing.test.d.ts.map +1 -0
  165. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  166. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  167. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  168. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  169. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  170. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  171. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  172. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  173. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  174. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  175. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  176. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  177. package/dist/tokens/density.test.d.ts +2 -0
  178. package/dist/tokens/density.test.d.ts.map +1 -0
  179. package/dist/tokens/derive-radius.test.d.ts +2 -0
  180. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  181. package/dist/tokens/derive.test.d.ts +2 -0
  182. package/dist/tokens/derive.test.d.ts.map +1 -0
  183. package/dist/tokens/palette.test.d.ts +2 -0
  184. package/dist/tokens/palette.test.d.ts.map +1 -0
  185. package/dist/vite-color-mix.test.d.ts +2 -0
  186. package/dist/vite-color-mix.test.d.ts.map +1 -0
  187. package/dist/vite.test.d.ts +2 -0
  188. package/dist/vite.test.d.ts.map +1 -0
  189. package/llms.txt +6 -6
  190. package/package.json +6 -1
  191. package/src/agent/adapter.test.ts +1403 -0
  192. package/src/agent/adapter.ts +887 -0
  193. package/src/agent/ai-sdk-transport.test.ts +53 -26
  194. package/src/agent/coalesce.test.ts +202 -9
  195. package/src/agent/coalesce.ts +126 -15
  196. package/src/agent/foreign.test.ts +5 -1
  197. package/src/agent/id.test.ts +6 -2
  198. package/src/agent/id.ts +48 -0
  199. package/src/agent/index.ts +8 -0
  200. package/src/agent/merge.test.ts +87 -0
  201. package/src/agent/merge.ts +57 -10
  202. package/src/agent/part-list.test.tsx +31 -1
  203. package/src/agent/part-list.tsx +77 -29
  204. package/src/agent/parts.test.ts +121 -4
  205. package/src/agent/parts.ts +78 -17
  206. package/src/agent/thread.test.ts +75 -2
  207. package/src/agent/thread.ts +21 -1
  208. package/src/agent/use-agent-stream.test.tsx +9 -5
  209. package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
  210. package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
  211. package/src/agent/use-agent-thread-runs.test.tsx +30 -17
  212. package/src/agent/use-agent-thread-runs.wedge.test.tsx +13 -7
  213. package/src/agent-chat/composer.test.tsx +924 -0
  214. package/src/agent-chat/composer.tsx +521 -52
  215. package/src/agent-chat/index.ts +1 -1
  216. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  217. package/src/agent-chat/thread-message.test.tsx +141 -1
  218. package/src/agent-chat/thread-message.tsx +29 -4
  219. package/src/agent-chat/thread-workspace.tsx +1 -1
  220. package/src/cli/index.ts +0 -1
  221. package/src/cli/template-format.test.ts +2 -2
  222. package/src/content/fence-block.tsx +242 -15
  223. package/src/content/heading-components.test.tsx +65 -0
  224. package/src/content/heading-components.tsx +26 -5
  225. package/src/content/index.ts +19 -3
  226. package/src/content/markdown.test.tsx +603 -0
  227. package/src/content/markdown.tsx +355 -35
  228. package/src/content/sanitize.test.ts +659 -0
  229. package/src/content/sanitize.ts +510 -0
  230. package/src/dashboard/stat-card.test.tsx +6 -1
  231. package/src/index.ts +13 -1
  232. package/src/surfaces.ts +12 -3
  233. package/src/theme/contrast.test.ts +1 -4
  234. package/src/theme/density-relations.test.ts +4 -2
  235. package/src/theme/fonts-option.test.ts +7 -1
  236. package/src/theme/shadow-surfaces.test.ts +12 -6
  237. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -3,14 +3,16 @@
3
3
  * AgentPart wire format, post-1.11.0 part identity + the seven-state ToolCallPart union.
4
4
  *
5
5
  * Every AgentPart variant below is validated against parts.ts's `parseAgentPart`, `isStartPart`,
6
- * and `isToolCallSettled` as they read right now: `id` is required on every variant, and `tool`
7
- * parts are validated against the seven-state discriminated union mirroring AI SDK v7's
6
+ * and `isToolCallSettled` as they read right now: `id` is required on every variant, `toolCallId`
7
+ * is required on the three in-flight tool states and optional on the four settled ones (see the
8
+ * per-state identity block below), and `tool` parts are validated against the seven-state
9
+ * discriminated union mirroring AI SDK v7's
8
10
  * `UIToolInvocation` (see parts.ts's ToolCallPart doc for the ground-truth corrections against the
9
11
  * design spec).
10
12
  */
11
13
  import { describe, expect, test } from 'bun:test'
12
14
  import { isStartPart, isToolCallSettled, parseAgentPart } from './parts'
13
- import type { AgentPart, ToolCallPart } from './parts'
15
+ import type { AgentPart, ToolCallPart, ToolCallState } from './parts'
14
16
 
15
17
  /** JSON round-trip a value through JSON.stringify → JSON.parse, mirroring wire transport. */
16
18
  function roundTrip(value: unknown): unknown {
@@ -109,10 +111,11 @@ describe('parseAgentPart', () => {
109
111
  })
110
112
 
111
113
  describe('tool', () => {
112
- test('rejects a missing toolCallId', () => {
114
+ test('rejects a non-string toolCallId', () => {
113
115
  const raw = {
114
116
  id: 'p1',
115
117
  type: 'tool',
118
+ toolCallId: 42,
116
119
  toolName: 'search',
117
120
  state: 'input-available',
118
121
  input: {},
@@ -120,6 +123,120 @@ describe('parseAgentPart', () => {
120
123
  expect(parseAgentPart(raw)).toBeNull()
121
124
  })
122
125
 
126
+ // ── toolCallId identity, decided per state ──────────────────────────────
127
+ // An empty string is not a value any state accepts: '' normalizes to absent right here, so the
128
+ // pre-1.12.0 sentinel can never reach the type or a consumer of it. What an ABSENT id then
129
+ // means is a per-state decision (see Identified / MaybeIdentified in parts.ts):
130
+ // in-flight (input-streaming, input-available, approval-requested) → REJECTED. The part
131
+ // exists only to be closed by a later state, which re-supplies its toolName and input;
132
+ // unidentified it could never be closed and would render as a forever-pending chip.
133
+ // settled (approval-responded + the three terminal states) → KEPT, unidentified.
134
+ // It carries the OUTCOME; dropping it would erase how the call ended (silent data loss),
135
+ // while keeping it costs only the fold — coalesceParts renders it as its own block.
136
+ //
137
+ // The `satisfies Record<ToolCallState, …>` below is an authoring-time exhaustiveness gate: add
138
+ // a state to the union and this fixture stops typechecking until its identity behaviour is
139
+ // decided here too. It is an EDITOR/tsc guard only — the package tsconfig excludes `*.test.ts`
140
+ // from `bun run typecheck`, and `bun test` type-strips rather than typechecks — so the
141
+ // "covers every state exactly once" test below backs it with a runtime check of the same claim.
142
+
143
+ const UNIDENTIFIED_RAW = {
144
+ 'input-streaming': { toolName: 'search', state: 'input-streaming' },
145
+ 'input-available': { toolName: 'search', state: 'input-available', input: { q: 'x' } },
146
+ 'approval-requested': {
147
+ toolName: 'search',
148
+ state: 'approval-requested',
149
+ input: { q: 'x' },
150
+ approval: { id: 'appr-1' },
151
+ },
152
+ 'approval-responded': {
153
+ toolName: 'search',
154
+ state: 'approval-responded',
155
+ input: { q: 'x' },
156
+ approval: { id: 'appr-1', approved: true },
157
+ },
158
+ 'output-available': {
159
+ toolName: 'search',
160
+ state: 'output-available',
161
+ input: { q: 'x' },
162
+ output: { hits: 3 },
163
+ },
164
+ 'output-error': { toolName: 'search', state: 'output-error', errorText: 'boom' },
165
+ 'output-denied': {
166
+ toolName: 'search',
167
+ state: 'output-denied',
168
+ input: { q: 'x' },
169
+ approval: { id: 'appr-1', approved: false },
170
+ },
171
+ } satisfies Record<ToolCallState, Record<string, unknown>>
172
+
173
+ const IN_FLIGHT_STATES = [
174
+ 'input-streaming',
175
+ 'input-available',
176
+ 'approval-requested',
177
+ ] as const satisfies readonly ToolCallState[]
178
+
179
+ const SETTLED_STATES = [
180
+ 'approval-responded',
181
+ 'output-available',
182
+ 'output-error',
183
+ 'output-denied',
184
+ ] as const satisfies readonly ToolCallState[]
185
+
186
+ test('the in-flight/settled split covers every tool state exactly once', () => {
187
+ // Object.keys() is always typed string[] regardless of the object's key type — cast back to
188
+ // ToolCallState[], which every key of UNIDENTIFIED_RAW genuinely is (used as
189
+ // UNIDENTIFIED_RAW[state] elsewhere in this file).
190
+ expect([...IN_FLIGHT_STATES, ...SETTLED_STATES].toSorted()).toEqual(
191
+ (Object.keys(UNIDENTIFIED_RAW) as ToolCallState[]).toSorted(),
192
+ )
193
+ })
194
+
195
+ for (const state of IN_FLIGHT_STATES) {
196
+ test(`${state}: rejects a missing toolCallId — an in-flight part that could never be closed`, () => {
197
+ expect(parseAgentPart({ id: 'p1', type: 'tool', ...UNIDENTIFIED_RAW[state] })).toBeNull()
198
+ })
199
+
200
+ test(`${state}: rejects an empty-string toolCallId identically (it means absent)`, () => {
201
+ const raw = { id: 'p1', type: 'tool', toolCallId: '', ...UNIDENTIFIED_RAW[state] }
202
+ expect(parseAgentPart(raw)).toBeNull()
203
+ })
204
+ }
205
+
206
+ for (const state of SETTLED_STATES) {
207
+ test(`${state}: a missing toolCallId keeps the part UNIDENTIFIED — it is never dropped`, () => {
208
+ const parsed = parseAgentPart({ id: 'p1', type: 'tool', ...UNIDENTIFIED_RAW[state] })
209
+ expect(parsed).not.toBeNull()
210
+ expect((parsed as ToolCallPart).state).toBe(state)
211
+ expect(Object.hasOwn(parsed as object, 'toolCallId')).toBe(false)
212
+ })
213
+
214
+ test(`${state}: an empty-string toolCallId normalizes to absent, never to ""`, () => {
215
+ const raw = { id: 'p1', type: 'tool', toolCallId: '', ...UNIDENTIFIED_RAW[state] }
216
+ const parsed = parseAgentPart(raw)
217
+ expect(parsed).not.toBeNull()
218
+ expect(Object.hasOwn(parsed as object, 'toolCallId')).toBe(false)
219
+ })
220
+
221
+ test(`${state}: a real toolCallId still carries through`, () => {
222
+ const raw = { id: 'p1', type: 'tool', toolCallId: 'call-1', ...UNIDENTIFIED_RAW[state] }
223
+ expect((parseAgentPart(raw) as ToolCallPart | null)?.toolCallId).toBe('call-1')
224
+ })
225
+ }
226
+
227
+ test('approval-responded: a real toolCallId round-trips through JSON', () => {
228
+ const part: ToolCallPart = {
229
+ id: 'p1',
230
+ type: 'tool',
231
+ toolCallId: 'call-1',
232
+ toolName: 'delete_file',
233
+ state: 'approval-responded',
234
+ input: { path: '/tmp/x' },
235
+ approval: { id: 'appr-1', approved: true },
236
+ }
237
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
238
+ })
239
+
123
240
  test('rejects a missing toolName', () => {
124
241
  const raw = {
125
242
  id: 'p1',
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * Exhaustiveness is enforced at every switch via assertNever (imported from '../register').
5
5
  * Adding a new variant without a matching case is a tsc error — see the fixture in
6
- * apps/playground/src/demo/agent-part.type-guard.ts.
6
+ * apps/playground/src/demo/agent-part.type-guard.ts. One caveat for code that folds WIRE data on
7
+ * the render path (`coalesceParts`): keep the compile-time guarantee — bind the value at `never` —
8
+ * but do NOT let the runtime throw, or one malformed chunk kills the whole transcript. See
9
+ * `mergeToolPart`'s default branch for that shape.
7
10
  *
8
11
  * Eden #231 doctrine: the server-side Elysia route MUST declare `: AsyncGenerator<AgentPart>`
9
12
  * explicitly and carry NO t.Object/t.Union response schema. Validate at yield-time. See
@@ -78,6 +81,10 @@ export type ReasoningPart = PartBase & {
78
81
  // discriminator `tool-${NAME}`. It IS a required field here (ours, not the SDK's) because the chip
79
82
  // needs it; a transport mapping from the SDK must DERIVE it from the discriminator, not read it off
80
83
  // the invocation.
84
+ //
85
+ // `toolCallId` is required on the three IN-FLIGHT states and OPTIONAL on the four SETTLED ones —
86
+ // see `Identified` / `MaybeIdentified` below. The absence is modelled in the type, never as a
87
+ // sentinel value.
81
88
 
82
89
  /** The nested approval envelope — mirrors the SDK's shape verbatim, never flattened. */
83
90
  type ToolApproval = {
@@ -90,35 +97,66 @@ type ToolApproval = {
90
97
 
91
98
  type ToolCallBase = PartBase & {
92
99
  readonly type: 'tool'
93
- /** Required on all seven states (was optional — see parts.ts history). */
94
- readonly toolCallId: string
95
100
  readonly toolName: string
96
101
  /** Wall-clock time from first sighting of this toolCallId to its terminal state. */
97
102
  readonly durationMs?: number
98
103
  readonly providerExecuted?: boolean
99
104
  }
100
105
 
106
+ /**
107
+ * The three IN-FLIGHT states (`input-streaming`, `input-available`, `approval-requested`) carry an
108
+ * authoritative `toolCallId` — the merge key, and for them it is structural. An in-flight part
109
+ * exists only to be superseded by the settled part for the same call; with no merge key it can
110
+ * never be closed, so it would render as a forever-pending chip standing beside the real result.
111
+ * Nothing is lost by rejecting an unidentified one either: the `toolName` and `input` it carries
112
+ * are re-supplied on the settled part. `parseToolCallPart` therefore rejects these when the id is
113
+ * missing or empty.
114
+ */
115
+ type Identified = ToolCallBase & { readonly toolCallId: string }
116
+
117
+ /**
118
+ * The four SETTLED states (`approval-responded` plus the three terminal ones) may legitimately
119
+ * arrive without a `toolCallId`, for two different reasons.
120
+ *
121
+ * `approval-responded` is where the WIRE omits it: the SDK's `tool-approval-response` chunk
122
+ * carries none, and it is resolved by reverse lookup on `approval.id` (coalesce.ts keeps the
123
+ * approvalId -> toolCallId side index that does it).
124
+ *
125
+ * The three terminal states admit the absence for a different reason — tolerance, not wire shape.
126
+ * They are self-contained: each carries the OUTCOME of the call, so a missing id costs only the
127
+ * fold (`coalesceParts` keeps an unresolvable part as its own block and never discards it).
128
+ * Rejecting one instead would delete the only record of how the call ended — silent data loss in a
129
+ * transcript, strictly worse than an unfolded block.
130
+ *
131
+ * The absence is modelled here rather than smuggled through an empty-string sentinel: the union
132
+ * previously declared an always-meaningful `string` that the `approval-responded` wire shape
133
+ * cannot supply, which forced a `''` sentinel into the type and an `as unknown as` cast into every
134
+ * test that built one. A `''` toolCallId is not a value any state accepts; `parseToolCallPart`
135
+ * normalizes one to absent at the wire boundary, so it never reaches the type or a consumer.
136
+ */
137
+ type MaybeIdentified = ToolCallBase & { readonly toolCallId?: string }
138
+
101
139
  export type ToolCallPart =
102
- | (ToolCallBase & { readonly state: 'input-streaming'; readonly input?: unknown })
103
- | (ToolCallBase & { readonly state: 'input-available'; readonly input: unknown })
104
- | (ToolCallBase & {
140
+ | (Identified & { readonly state: 'input-streaming'; readonly input?: unknown })
141
+ | (Identified & { readonly state: 'input-available'; readonly input: unknown })
142
+ | (Identified & {
105
143
  readonly state: 'approval-requested'
106
144
  readonly input: unknown
107
145
  readonly approval: ToolApproval & { readonly approved?: never; readonly reason?: never }
108
146
  })
109
- | (ToolCallBase & {
147
+ | (MaybeIdentified & {
110
148
  readonly state: 'approval-responded'
111
149
  readonly input: unknown
112
150
  readonly approval: ToolApproval & { readonly approved: boolean }
113
151
  })
114
- | (ToolCallBase & {
152
+ | (MaybeIdentified & {
115
153
  readonly state: 'output-available'
116
154
  readonly input: unknown
117
155
  readonly output: unknown
118
156
  readonly preliminary?: boolean
119
157
  readonly approval?: ToolApproval & { readonly approved?: true }
120
158
  })
121
- | (ToolCallBase & {
159
+ | (MaybeIdentified & {
122
160
  readonly state: 'output-error'
123
161
  readonly input?: unknown
124
162
  /** The SDK's field name — there is no field named `error` anywhere in the union. */
@@ -127,7 +165,7 @@ export type ToolCallPart =
127
165
  readonly rawInput?: unknown
128
166
  readonly approval?: ToolApproval & { readonly approved?: true }
129
167
  })
130
- | (ToolCallBase & {
168
+ | (MaybeIdentified & {
131
169
  readonly state: 'output-denied'
132
170
  readonly input: unknown
133
171
  readonly approval: ToolApproval & { readonly approved: false }
@@ -236,35 +274,53 @@ export function parseAgentPart(raw: unknown): AgentPart | null {
236
274
  }
237
275
 
238
276
  /** Validates the state-discriminated `tool` part shape. Rejects the pre-1.11.0 flat shape
239
- * (`{type:'tool', output}` with no `state`) and any unrecognized `state` value. */
277
+ * (`{type:'tool', output}` with no `state`) and any unrecognized `state` value.
278
+ *
279
+ * An empty-string `toolCallId` is normalized to absent here — the one place the wire's degenerate
280
+ * shape is decoded — so `''` never reaches the type or any consumer of it. What an ABSENT id then
281
+ * means is decided per state, exactly as the union declares it (`Identified` /
282
+ * `MaybeIdentified`): fatal on the three in-flight states, which exist only to be closed by a
283
+ * later one and are re-supplied by it; survivable on the four settled states, which carry the
284
+ * outcome and are kept UNIDENTIFIED rather than dropped. Dropping a settled part would be silent
285
+ * data loss — the transcript would simply forget how the call ended. */
240
286
  function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPart | null {
241
- if (typeof obj['toolCallId'] !== 'string') return null
242
287
  if (typeof obj['toolName'] !== 'string') return null
243
288
  if (typeof obj['state'] !== 'string') return null
244
289
 
245
- const { toolCallId, toolName } = obj as { toolCallId: string; toolName: string }
290
+ const rawToolCallId = obj['toolCallId']
291
+ if (rawToolCallId !== undefined && typeof rawToolCallId !== 'string') return null
292
+ const toolCallId =
293
+ typeof rawToolCallId === 'string' && rawToolCallId.length > 0 ? rawToolCallId : undefined
294
+
295
+ const toolName = obj['toolName']
246
296
  const common = {
247
297
  id,
248
298
  type: 'tool' as const,
249
- toolCallId,
250
299
  toolName,
251
300
  ...(typeof obj['durationMs'] === 'number' ? { durationMs: obj['durationMs'] } : {}),
252
301
  ...(typeof obj['providerExecuted'] === 'boolean'
253
302
  ? { providerExecuted: obj['providerExecuted'] }
254
303
  : {}),
255
304
  }
305
+ // The three in-flight states below require an identified call; the four settled ones spread
306
+ // `identity` instead, which is simply empty when the wire supplied no usable id.
307
+ const identified = toolCallId !== undefined ? { ...common, toolCallId } : null
308
+ const identity = toolCallId !== undefined ? { toolCallId } : {}
256
309
 
257
310
  switch (obj['state']) {
258
311
  case 'input-streaming':
312
+ if (identified === null) return null
259
313
  return {
260
- ...common,
314
+ ...identified,
261
315
  state: 'input-streaming',
262
316
  ...('input' in obj ? { input: obj['input'] } : {}),
263
317
  }
264
318
  case 'input-available':
319
+ if (identified === null) return null
265
320
  if (!('input' in obj)) return null
266
- return { ...common, state: 'input-available', input: obj['input'] }
321
+ return { ...identified, state: 'input-available', input: obj['input'] }
267
322
  case 'approval-requested': {
323
+ if (identified === null) return null
268
324
  if (!('input' in obj)) return null
269
325
  const approval = parseApproval(obj['approval'])
270
326
  // The type declares this state's approval as `{ approved?: never; reason?: never }` — a
@@ -274,7 +330,7 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
274
330
  return null
275
331
  }
276
332
  return {
277
- ...common,
333
+ ...identified,
278
334
  state: 'approval-requested',
279
335
  input: obj['input'],
280
336
  approval: approval as ToolApproval & { approved?: never; reason?: never },
@@ -284,8 +340,10 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
284
340
  if (!('input' in obj)) return null
285
341
  const approval = parseApproval(obj['approval'])
286
342
  if (approval === null || typeof approval.approved !== 'boolean') return null
343
+ // The state the WIRE ships unidentified — `approval.id` is then its only addressing key.
287
344
  return {
288
345
  ...common,
346
+ ...identity,
289
347
  state: 'approval-responded',
290
348
  input: obj['input'],
291
349
  approval: approval as ToolApproval & { approved: boolean },
@@ -297,6 +355,7 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
297
355
  if (approval === null || approval.approved !== false) return null
298
356
  return {
299
357
  ...common,
358
+ ...identity,
300
359
  state: 'output-denied',
301
360
  input: obj['input'],
302
361
  approval: approval as ToolApproval & { approved: false },
@@ -315,6 +374,7 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
315
374
  }
316
375
  return {
317
376
  ...common,
377
+ ...identity,
318
378
  state: 'output-available',
319
379
  input: obj['input'],
320
380
  output: obj['output'],
@@ -332,6 +392,7 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
332
392
  }
333
393
  return {
334
394
  ...common,
395
+ ...identity,
335
396
  state: 'output-error',
336
397
  errorText: obj['errorText'],
337
398
  ...('input' in obj ? { input: obj['input'] } : {}),
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * createThreadsStore — ring-buffer caps (maxThreads/maxMessagesPerThread), newest-first ordering,
3
- * setResumeToken's delete-the-key-on-undefined semantics, and same-tick action accumulation.
3
+ * setResumeToken's delete-the-key-on-undefined semantics, same-tick action accumulation, and the
4
+ * constant hydrated/error pair the async sibling store (./adapter) actually varies.
4
5
  *
5
6
  * Uses the REAL localStorage-backed store (happy-dom provides `window.localStorage`), so every
6
7
  * test uses its own unique `key` — the same convention as
@@ -13,7 +14,12 @@ import { createThreadsStore } from './thread'
13
14
  import type { ChatMessage } from './history'
14
15
 
15
16
  function makeMessage(id: string): ChatMessage {
16
- return { id, role: 'user', parts: [{ type: 'text', text: id }], createdAt: Date.now() }
17
+ return {
18
+ id,
19
+ role: 'user',
20
+ parts: [{ id: `${id}-p0`, type: 'text', text: id }],
21
+ createdAt: Date.now(),
22
+ }
17
23
  }
18
24
 
19
25
  describe('createThreadsStore', () => {
@@ -144,4 +150,71 @@ describe('createThreadsStore', () => {
144
150
  expect(thread?.status).toBe('streaming')
145
151
  expect(thread?.resumeToken).toBe('tok-accum')
146
152
  })
153
+
154
+ test('hydrated is always true and error always undefined — localStorage resolves synchronously', () => {
155
+ const useThreads = createThreadsStore({ key: 'thread-hydrated-a', version: 1 })
156
+ const { result } = renderHook(() => useThreads())
157
+
158
+ // Pinned because these two exist for the async adapter store (./adapter); the localStorage
159
+ // store must fill them with the "nothing to wait for, nothing to report" constants on the
160
+ // very first render, before any action has run.
161
+ expect(result.current.hydrated).toBe(true)
162
+ expect(result.current.error).toBeUndefined()
163
+
164
+ act(() => {
165
+ result.current.create()
166
+ })
167
+
168
+ expect(result.current.hydrated).toBe(true)
169
+ expect(result.current.error).toBeUndefined()
170
+ })
171
+
172
+ test('create() mints distinct ids via crypto.getRandomValues when randomUUID is unavailable', () => {
173
+ const originalCrypto = globalThis.crypto
174
+ Object.defineProperty(globalThis, 'crypto', {
175
+ value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
176
+ configurable: true,
177
+ })
178
+ try {
179
+ const useThreads = createThreadsStore({ key: 'thread-crypto-a', version: 1 })
180
+ const { result } = renderHook(() => useThreads())
181
+
182
+ let first = ''
183
+ let second = ''
184
+ act(() => {
185
+ first = result.current.create()
186
+ second = result.current.create()
187
+ })
188
+
189
+ expect(first).not.toBe('')
190
+ expect(second).not.toBe('')
191
+ expect(first).not.toBe(second)
192
+ } finally {
193
+ // Restore the real crypto so no later test in the process is affected.
194
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
195
+ }
196
+ })
197
+
198
+ test('create() still mints distinct ids with no usable crypto at all (randomUUID and getRandomValues both absent)', () => {
199
+ const originalCrypto = globalThis.crypto
200
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
201
+ try {
202
+ const useThreads = createThreadsStore({ key: 'thread-crypto-b', version: 1 })
203
+ const { result } = renderHook(() => useThreads())
204
+
205
+ let first = ''
206
+ let second = ''
207
+ act(() => {
208
+ first = result.current.create()
209
+ second = result.current.create()
210
+ })
211
+
212
+ expect(first).not.toBe('')
213
+ expect(second).not.toBe('')
214
+ expect(first).not.toBe(second)
215
+ } finally {
216
+ // Restore the real crypto so no later test in the process is affected.
217
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
218
+ }
219
+ })
147
220
  })
@@ -24,6 +24,7 @@
24
24
  import { useCallback, useRef } from 'react'
25
25
  import { createPersistedState } from '../state'
26
26
  import type { ChatMessage } from './history'
27
+ import { mintThreadId } from './id'
27
28
  import type { AgentOutcome } from './outcome'
28
29
  import type { AgentPart } from './parts'
29
30
 
@@ -138,6 +139,20 @@ export type ThreadsStore<TPart = AgentPart> = {
138
139
  readonly remove: (id: string) => void
139
140
  /** Remove all threads and clear the active selection. */
140
141
  readonly clear: () => void
142
+ /**
143
+ * Whether `threads` reflects the backing store yet. Always `true` for the localStorage store
144
+ * (createPersistedState resolves synchronously); meaningful only for an async, server-backed
145
+ * store built with `createAdapterThreadsStore` (./adapter), where it stays `false` until the
146
+ * first `listThreads` SUCCEEDS. Pair it with `error` — `!hydrated && error !== undefined` is a
147
+ * failed load, `!hydrated && error === undefined` is still loading.
148
+ */
149
+ readonly hydrated: boolean
150
+ /**
151
+ * The most recent persistence failure, or `undefined`. Always `undefined` for the localStorage
152
+ * store, which has no failure the caller can act on. An async store surfaces the rejected
153
+ * load/write here after rolling its optimistic edit back.
154
+ */
155
+ readonly error: unknown
141
156
  }
142
157
 
143
158
  // ── createThreadsStore ────────────────────────────────────────────────────────
@@ -198,7 +213,7 @@ export function createThreadsStore<TPart = AgentPart>(
198
213
 
199
214
  const create = useCallback(
200
215
  (createOpts?: { readonly meta?: Record<string, unknown> }): string => {
201
- const id = crypto.randomUUID()
216
+ const id = mintThreadId()
202
217
  const now = Date.now()
203
218
  const thread: AgentThread<TPart> = {
204
219
  id,
@@ -307,6 +322,11 @@ export function createThreadsStore<TPart = AgentPart>(
307
322
  markRead,
308
323
  remove,
309
324
  clear,
325
+ // localStorage resolves synchronously and swallows its own quota errors, so there is never
326
+ // a pending load to await nor a failure a caller could act on. See ./adapter for the async
327
+ // store where these two carry real information.
328
+ hydrated: true,
329
+ error: undefined,
310
330
  }
311
331
  }
312
332
  }
@@ -11,7 +11,7 @@ import { StrictMode, useEffect } from 'react'
11
11
  import { act, render, renderHook, waitFor } from '@testing-library/react'
12
12
  import { useAgentStream } from './use-agent-stream'
13
13
  import type { AgentTransport } from './transport'
14
- import type { AgentPart } from './parts'
14
+ import type { AgentPart, AgentPartDraft } from './parts'
15
15
 
16
16
  function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
17
17
  let resolve!: (value: T) => void
@@ -96,17 +96,21 @@ describe('useAgentStream', () => {
96
96
  })
97
97
 
98
98
  test('F1 regression: id-less drafts of different types within one send() do not destroy each other', async () => {
99
+ // Yields id-less drafts on purpose — the whole point of this regression is that TWO drafts
100
+ // (of different types, so a real bug wouldn't be "same id" but "no id at all") must not
101
+ // collide via `undefined === undefined`. The cast documents that the type says AgentPart
102
+ // (identified) while a transport is always permitted to omit id (AgentPartDraft).
99
103
  const transport: AgentTransport<AgentPart, string> = {
100
104
  async *stream() {
101
- yield { type: 'text', text: 'Hi' }
105
+ yield { type: 'text', text: 'Hi' } as AgentPartDraft as AgentPart
102
106
  yield {
103
107
  type: 'tool',
104
108
  toolCallId: 'c1',
105
109
  toolName: 'search',
106
110
  state: 'input-available',
107
111
  input: { q: 'x' },
108
- }
109
- yield { type: 'text', text: 'Bye' }
112
+ } as AgentPartDraft as AgentPart
113
+ yield { type: 'text', text: 'Bye' } as AgentPartDraft as AgentPart
110
114
  },
111
115
  }
112
116
 
@@ -131,7 +135,7 @@ describe('useAgentStream', () => {
131
135
  async *stream() {
132
136
  throw boom
133
137
  // eslint-disable-next-line no-unreachable
134
- yield { type: 'text', text: 'unreachable' }
138
+ yield { id: 'unreachable', type: 'text', text: 'unreachable' }
135
139
  },
136
140
  }
137
141
 
@@ -86,6 +86,11 @@ function createTestThreadsStore(): ThreadsStore<TranscriptPart> {
86
86
  threads = []
87
87
  activeId = null
88
88
  },
89
+ // Always-hydrated, never-erroring — mirrors the localStorage-backed store's real values
90
+ // (see ThreadsStore.hydrated/.error doc comments); this double is a synchronous in-memory
91
+ // stand-in with no async load path to fail.
92
+ hydrated: true,
93
+ error: undefined,
89
94
  }
90
95
  }
91
96
 
@@ -16,7 +16,7 @@ import { describe, expect, test } from 'bun:test'
16
16
  import { renderHook, waitFor } from '@testing-library/react'
17
17
  import { useAgentThreadRuns } from './use-agent-thread-runs'
18
18
  import type { AgentThread, ThreadsStore } from './thread'
19
- import type { AgentTransport } from './transport'
19
+ import type { AgentTransport, ResumableAgentTransport } from './transport'
20
20
  import type { AgentPart } from './parts'
21
21
  import type { ChatMessage } from './history'
22
22
  import type { AgentOutcome } from './outcome'
@@ -90,6 +90,11 @@ function createTestThreadsStore(initial: AgentThread<AgentPart>[]): ThreadsStore
90
90
  threads = []
91
91
  activeId = null
92
92
  },
93
+ // Always-hydrated, never-erroring — mirrors the localStorage-backed store's real values
94
+ // (see ThreadsStore.hydrated/.error doc comments); this double is a synchronous in-memory
95
+ // stand-in with no async load path to fail.
96
+ hydrated: true,
97
+ error: undefined,
93
98
  }
94
99
  }
95
100
 
@@ -100,7 +105,7 @@ function makeStreamingThread(
100
105
  const userMessage: ChatMessage<AgentPart> = {
101
106
  id: crypto.randomUUID(),
102
107
  role: 'user',
103
- parts: [{ type: 'text', text: 'hello' }],
108
+ parts: [{ id: 'seed-user-part', type: 'text', text: 'hello' }],
104
109
  createdAt: now,
105
110
  }
106
111
  return {
@@ -142,7 +147,7 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
142
147
  const userMessage: ChatMessage<AgentPart> = {
143
148
  id: crypto.randomUUID(),
144
149
  role: 'user',
145
- parts: [{ type: 'text', text: 'hello' }],
150
+ parts: [{ id: 'seed-user-part', type: 'text', text: 'hello' }],
146
151
  createdAt: now,
147
152
  }
148
153
  const thread: AgentThread<AgentPart> = {
@@ -157,11 +162,11 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
157
162
  const store = createTestThreadsStore([thread])
158
163
 
159
164
  let resumeCalls = 0
160
- const transport = {
165
+ const transport: ResumableAgentTransport<AgentPart, string> = {
161
166
  async *stream() {},
162
167
  async *resume() {
163
168
  resumeCalls++
164
- yield { type: 'text', text: 'resumed' }
169
+ yield { id: 'p1', type: 'text', text: 'resumed' }
165
170
  },
166
171
  idempotentReplay: true as const,
167
172
  }
@@ -179,11 +184,11 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
179
184
  const store = createTestThreadsStore([thread])
180
185
 
181
186
  let resumeCalls = 0
182
- const transport = {
187
+ const transport: ResumableAgentTransport<AgentPart, string> = {
183
188
  async *stream() {},
184
189
  async *resume() {
185
190
  resumeCalls++
186
- yield { type: 'text', text: 'resumed' }
191
+ yield { id: 'p1', type: 'text', text: 'resumed' }
187
192
  },
188
193
  idempotentReplay: true as const,
189
194
  }
@@ -205,7 +210,7 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
205
210
  async *stream() {},
206
211
  async *resume() {
207
212
  resumeCalls++
208
- yield { type: 'text', text: 'resumed' }
213
+ yield { id: 'p1', type: 'text', text: 'resumed' }
209
214
  },
210
215
  // deliberately NO idempotentReplay: true
211
216
  }
@@ -223,11 +228,11 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
223
228
  const store = createTestThreadsStore([thread])
224
229
 
225
230
  const resumeArgs: string[] = []
226
- const transport = {
231
+ const transport: ResumableAgentTransport<AgentPart, string> = {
227
232
  async *stream() {},
228
- async *resume(token: string) {
233
+ async *resume(token) {
229
234
  resumeArgs.push(token)
230
- yield { type: 'text', text: 'resumed' }
235
+ yield { id: 'p1', type: 'text', text: 'resumed' }
231
236
  },
232
237
  idempotentReplay: true as const,
233
238
  }