basalt-ui 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +64 -34
  4. package/agent/rules/basalt-agent.md +49 -6
  5. package/configs/oxlint-plugin.js +279 -0
  6. package/configs/oxlint-plugin.test.ts +216 -1
  7. package/configs/oxlint.json +5 -2
  8. package/dist/agent/ai-sdk-transport.d.ts +3 -52
  9. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  10. package/dist/agent/ai-sdk-transport.js +108 -24
  11. package/dist/agent/coalesce.d.ts +26 -0
  12. package/dist/agent/coalesce.d.ts.map +1 -0
  13. package/dist/agent/coalesce.js +69 -0
  14. package/dist/agent/foreign.d.ts +97 -0
  15. package/dist/agent/foreign.d.ts.map +1 -0
  16. package/dist/agent/foreign.js +18 -0
  17. package/dist/agent/history.d.ts +2 -0
  18. package/dist/agent/history.d.ts.map +1 -1
  19. package/dist/agent/id.d.ts +35 -0
  20. package/dist/agent/id.d.ts.map +1 -0
  21. package/dist/agent/id.js +14 -0
  22. package/dist/agent/index.d.ts +9 -4
  23. package/dist/agent/index.d.ts.map +1 -1
  24. package/dist/agent/index.js +15 -3
  25. package/dist/agent/merge.d.ts +8 -0
  26. package/dist/agent/merge.d.ts.map +1 -0
  27. package/dist/agent/merge.js +23 -0
  28. package/dist/agent/part-list.d.ts.map +1 -1
  29. package/dist/agent/part-list.js +16 -6
  30. package/dist/agent/parts.d.ts +100 -21
  31. package/dist/agent/parts.d.ts.map +1 -1
  32. package/dist/agent/parts.js +128 -11
  33. package/dist/agent/transport.d.ts +39 -3
  34. package/dist/agent/transport.d.ts.map +1 -1
  35. package/dist/agent/transport.js +20 -17
  36. package/dist/agent/use-agent-stream.d.ts +2 -1
  37. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  38. package/dist/agent/use-agent-stream.js +5 -2
  39. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  40. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  41. package/dist/agent/use-agent-thread-runs.js +76 -23
  42. package/dist/agent-chat/index.d.ts +6 -3
  43. package/dist/agent-chat/index.d.ts.map +1 -1
  44. package/dist/agent-chat/index.js +2 -0
  45. package/dist/agent-chat/thread-message.d.ts +24 -7
  46. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  47. package/dist/agent-chat/thread-message.js +102 -43
  48. package/dist/agent-chat/tool-chip.d.ts +21 -0
  49. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  50. package/dist/agent-chat/tool-chip.js +120 -0
  51. package/dist/cli/index.d.ts +33 -0
  52. package/dist/cli/index.d.ts.map +1 -1
  53. package/dist/cli/index.js +131 -0
  54. package/dist/guard/index.d.ts.map +1 -1
  55. package/dist/guard/index.js +1 -3
  56. package/dist/index.d.ts +2 -2
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +3 -1
  59. package/dist/surfaces.d.ts +11 -1
  60. package/dist/surfaces.d.ts.map +1 -1
  61. package/dist/surfaces.js +22 -1
  62. package/llms.txt +10 -3
  63. package/package.json +6 -1
  64. package/src/agent/ai-sdk-transport.test.ts +567 -0
  65. package/src/agent/ai-sdk-transport.ts +206 -53
  66. package/src/agent/coalesce.test.ts +194 -0
  67. package/src/agent/coalesce.ts +118 -0
  68. package/src/agent/foreign.test.ts +47 -0
  69. package/src/agent/foreign.ts +140 -0
  70. package/src/agent/history.ts +2 -0
  71. package/src/agent/id.test.ts +71 -0
  72. package/src/agent/id.ts +43 -0
  73. package/src/agent/index.ts +26 -3
  74. package/src/agent/merge.test.ts +119 -0
  75. package/src/agent/merge.ts +67 -0
  76. package/src/agent/part-list.test.tsx +82 -0
  77. package/src/agent/part-list.tsx +31 -6
  78. package/src/agent/parts.test.ts +603 -0
  79. package/src/agent/parts.ts +262 -32
  80. package/src/agent/thread.test.ts +147 -0
  81. package/src/agent/transport.test.ts +70 -0
  82. package/src/agent/transport.ts +72 -20
  83. package/src/agent/use-agent-stream.test.tsx +261 -0
  84. package/src/agent/use-agent-stream.ts +13 -3
  85. package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
  86. package/src/agent/use-agent-thread-runs.resume.test.tsx +289 -0
  87. package/src/agent/use-agent-thread-runs.test.tsx +781 -0
  88. package/src/agent/use-agent-thread-runs.ts +177 -27
  89. package/src/agent/use-agent-thread-runs.wedge.test.tsx +301 -0
  90. package/src/agent-chat/index.ts +8 -3
  91. package/src/agent-chat/thread-message.test.tsx +211 -0
  92. package/src/agent-chat/thread-message.tsx +235 -62
  93. package/src/agent-chat/tool-chip.test.tsx +139 -0
  94. package/src/agent-chat/tool-chip.tsx +219 -0
  95. package/src/charts/primitives/ChartFrame.test.tsx +8 -5
  96. package/src/cli/index.ts +285 -0
  97. package/src/dashboard/stat-card.test.tsx +5 -3
  98. package/src/guard/check-source.test.ts +8 -4
  99. package/src/guard/index.ts +1 -3
  100. package/src/index.ts +2 -0
  101. package/src/provider/index.test.tsx +6 -5
  102. package/src/surfaces.ts +23 -1
  103. package/src/theme/use-basalt-spacing.test.tsx +7 -4
  104. package/src/tokens/build-fonts-css.test.ts +3 -2
@@ -9,6 +9,11 @@
9
9
  * explicitly and carry NO t.Object/t.Union response schema. Validate at yield-time. See
10
10
  * agent/rules/basalt-agent.md for the full doctrine.
11
11
  *
12
+ * Part identity: every variant carries a stable `id` (see `PartBase`). Transports are permitted to
13
+ * yield a DRAFT — `id` omitted — via `AgentPartDraft`; the consuming hooks (or `withPartIds`)
14
+ * normalize a draft into a fully-identified part before it is ever accumulated. `parseAgentPart`
15
+ * validates the FULL (identified) shape — a draft is a separate, narrower concern.
16
+ *
12
17
  * @example
13
18
  * import type { AgentPart } from 'basalt-ui/agent'
14
19
  * function describe(p: AgentPart): string {
@@ -24,6 +29,11 @@
24
29
  * }
25
30
  */
26
31
 
32
+ // ── Part identity ─────────────────────────────────────────────────────────────
33
+
34
+ /** Every AgentPart variant carries a stable id — the addressing key for `mergePart`/`coalesceParts`. */
35
+ type PartBase = { readonly id: string }
36
+
27
37
  // ── AgentPart discriminated union ────────────────────────────────────────────
28
38
 
29
39
  /**
@@ -31,53 +41,144 @@
31
41
  * resume this run after a disconnect. Not renderable content — UI code should treat it as a
32
42
  * no-op signal, not conversation content.
33
43
  */
34
- export type StartPart = {
44
+ export type StartPart = PartBase & {
35
45
  readonly type: 'start'
36
46
  readonly runId: string
37
47
  readonly resumeToken?: string
38
48
  }
39
49
 
40
- /** A streamed text fragment from the assistant. */
41
- export type TextPart = { readonly type: 'text'; readonly text: string }
42
-
43
- /** An internal reasoning/thinking fragment (may be hidden in the UI). */
44
- export type ReasoningPart = { readonly type: 'reasoning'; readonly text: string }
45
-
46
50
  /**
47
- * A tool invocation — input is the parameters sent, output (optional) is the result once
48
- * the tool has completed. Streams arrive with output undefined; it may be appended later.
49
- *
50
- * `toolCallId` (optional) identifies the specific invocation when a transport's wire format
51
- * carries one (e.g. AI SDK's `ToolUIPart`) — useful for consumer-side coalescing of a tool call's
52
- * input/output pair by id rather than by array position.
51
+ * A streamed text fragment from the assistant. `offset` is the character position of this delta
52
+ * inside its part (undefined = append at the tail) — the mechanism `mergePart` uses to splice a
53
+ * replayed delta in place rather than duplicating it.
53
54
  */
54
- export type ToolCallPart = {
55
+ export type TextPart = PartBase & {
56
+ readonly type: 'text'
57
+ readonly text: string
58
+ readonly offset?: number
59
+ }
60
+
61
+ /** An internal reasoning/thinking fragment (may be hidden in the UI). Same `offset` mechanism as TextPart. */
62
+ export type ReasoningPart = PartBase & {
63
+ readonly type: 'reasoning'
64
+ readonly text: string
65
+ readonly offset?: number
66
+ }
67
+
68
+ // ── ToolCallPart — mirrors AI SDK v7's UIToolInvocation ─────────────────────
69
+ //
70
+ // Verified against installed ai@7.0.16 / 7.0.18 (byte-identical .d.ts). Seven states, no
71
+ // 'running' ('input-available' IS the running state — input complete, no output yet).
72
+ //
73
+ // The nested `approval` shape mirrors the SDK exactly (`{ id, approved?, reason?, isAutomatic?,
74
+ // signature? }`) rather than flattening to `approvalId`/`approved`/`reason` — flattening would
75
+ // silently drop `isAutomatic`/`signature`.
76
+ //
77
+ // `toolName` is NOT a field on the SDK's UIToolInvocation — for static tools it lives in the part
78
+ // discriminator `tool-${NAME}`. It IS a required field here (ours, not the SDK's) because the chip
79
+ // needs it; a transport mapping from the SDK must DERIVE it from the discriminator, not read it off
80
+ // the invocation.
81
+
82
+ /** The nested approval envelope — mirrors the SDK's shape verbatim, never flattened. */
83
+ type ToolApproval = {
84
+ readonly id: string
85
+ readonly approved?: boolean
86
+ readonly reason?: string
87
+ readonly isAutomatic?: boolean
88
+ readonly signature?: string
89
+ }
90
+
91
+ type ToolCallBase = PartBase & {
55
92
  readonly type: 'tool'
93
+ /** Required on all seven states (was optional — see parts.ts history). */
94
+ readonly toolCallId: string
56
95
  readonly toolName: string
57
- readonly input: unknown
58
- readonly output?: unknown
59
- readonly toolCallId?: string
96
+ /** Wall-clock time from first sighting of this toolCallId to its terminal state. */
97
+ readonly durationMs?: number
98
+ readonly providerExecuted?: boolean
99
+ }
100
+
101
+ export type ToolCallPart =
102
+ | (ToolCallBase & { readonly state: 'input-streaming'; readonly input?: unknown })
103
+ | (ToolCallBase & { readonly state: 'input-available'; readonly input: unknown })
104
+ | (ToolCallBase & {
105
+ readonly state: 'approval-requested'
106
+ readonly input: unknown
107
+ readonly approval: ToolApproval & { readonly approved?: never; readonly reason?: never }
108
+ })
109
+ | (ToolCallBase & {
110
+ readonly state: 'approval-responded'
111
+ readonly input: unknown
112
+ readonly approval: ToolApproval & { readonly approved: boolean }
113
+ })
114
+ | (ToolCallBase & {
115
+ readonly state: 'output-available'
116
+ readonly input: unknown
117
+ readonly output: unknown
118
+ readonly preliminary?: boolean
119
+ readonly approval?: ToolApproval & { readonly approved?: true }
120
+ })
121
+ | (ToolCallBase & {
122
+ readonly state: 'output-error'
123
+ readonly input?: unknown
124
+ /** The SDK's field name — there is no field named `error` anywhere in the union. */
125
+ readonly errorText: string
126
+ /** The only place the raw, un-parsed input survives — what lets a UI render a call whose input never validated. */
127
+ readonly rawInput?: unknown
128
+ readonly approval?: ToolApproval & { readonly approved?: true }
129
+ })
130
+ | (ToolCallBase & {
131
+ readonly state: 'output-denied'
132
+ readonly input: unknown
133
+ readonly approval: ToolApproval & { readonly approved: false }
134
+ })
135
+
136
+ export type ToolCallState = ToolCallPart['state']
137
+
138
+ /** The three states a tool call can never leave — see `isToolCallSettled`. */
139
+ export const TERMINAL_TOOL_STATES = ['output-available', 'output-error', 'output-denied'] as const
140
+
141
+ /** True once a tool call has reached one of `TERMINAL_TOOL_STATES` — no further state transition. */
142
+ export function isToolCallSettled(part: ToolCallPart): boolean {
143
+ return (TERMINAL_TOOL_STATES as readonly ToolCallState[]).includes(part.state)
60
144
  }
61
145
 
62
146
  /** A cited source URL referenced by the assistant. */
63
- export type SourcePart = { readonly type: 'source'; readonly url: string; readonly title?: string }
147
+ export type SourcePart = PartBase & {
148
+ readonly type: 'source'
149
+ readonly url: string
150
+ readonly title?: string
151
+ }
64
152
 
65
153
  /** A fatal or recoverable error emitted in-stream by the agent. */
66
- export type ErrorPart = { readonly type: 'error'; readonly message: string }
154
+ export type ErrorPart = PartBase & { readonly type: 'error'; readonly message: string }
67
155
 
68
156
  /**
69
157
  * The complete discriminated union of all structured agent stream parts.
70
158
  *
71
159
  * @example
72
- * const part: AgentPart = { type: 'text', text: 'Hello' }
160
+ * const part: AgentPart = { id: 'p1', type: 'text', text: 'Hello' }
73
161
  */
74
162
  export type AgentPart = StartPart | TextPart | ReasoningPart | ToolCallPart | SourcePart | ErrorPart
75
163
 
164
+ // ── AgentPartDraft — what a transport is allowed to yield ────────────────────
165
+
166
+ /**
167
+ * An AgentPart with `id` optional — the shape a transport is allowed to yield. The naked type
168
+ * parameter (not `AgentPart extends unknown ? ... : never` written some other way) is deliberate:
169
+ * it makes the conditional type distribute over the AgentPart union member-by-member, so
170
+ * `Drafted<AgentPart>` is `Drafted<StartPart> | Drafted<TextPart> | ...` rather than one flattened
171
+ * (and wrong) shape.
172
+ */
173
+ export type Drafted<T> = T extends unknown ? Omit<T, 'id'> & { readonly id?: string } : never
174
+ export type AgentPartDraft = Drafted<AgentPart>
175
+
76
176
  // ── parseAgentPart ───────────────────────────────────────────────────────────
77
177
 
78
178
  /**
79
179
  * Runtime type-narrowing guard: validates an unknown value against the AgentPart discriminated
80
- * union and returns the narrowed type or null when validation fails.
180
+ * union and returns the narrowed type or null when validation fails. Validates the FULL
181
+ * (identified) shape — an `id`-less draft is a separate concern handled by `withPartIds`.
81
182
  *
82
183
  * @example
83
184
  * const raw = JSON.parse(line)
@@ -88,46 +189,175 @@ export function parseAgentPart(raw: unknown): AgentPart | null {
88
189
  if (raw === null || typeof raw !== 'object') return null
89
190
  const obj = raw as Record<string, unknown>
90
191
  if (typeof obj['type'] !== 'string') return null
192
+ if (typeof obj['id'] !== 'string') return null
193
+ const id = obj['id']
91
194
 
92
195
  switch (obj['type']) {
93
196
  case 'start':
94
197
  if (typeof obj['runId'] !== 'string') return null
95
198
  return {
199
+ id,
96
200
  type: 'start',
97
201
  runId: obj['runId'],
98
202
  ...(typeof obj['resumeToken'] === 'string' ? { resumeToken: obj['resumeToken'] } : {}),
99
203
  }
100
204
  case 'text':
101
205
  if (typeof obj['text'] !== 'string') return null
102
- return { type: 'text', text: obj['text'] }
206
+ return {
207
+ id,
208
+ type: 'text',
209
+ text: obj['text'],
210
+ ...(typeof obj['offset'] === 'number' ? { offset: obj['offset'] } : {}),
211
+ }
103
212
  case 'reasoning':
104
213
  if (typeof obj['text'] !== 'string') return null
105
- return { type: 'reasoning', text: obj['text'] }
106
- case 'tool':
107
- if (typeof obj['toolName'] !== 'string') return null
108
- if (!('input' in obj)) return null
109
214
  return {
110
- type: 'tool',
111
- toolName: obj['toolName'],
112
- input: obj['input'],
113
- ...(obj['output'] !== undefined ? { output: obj['output'] } : {}),
114
- ...(typeof obj['toolCallId'] === 'string' ? { toolCallId: obj['toolCallId'] } : {}),
215
+ id,
216
+ type: 'reasoning',
217
+ text: obj['text'],
218
+ ...(typeof obj['offset'] === 'number' ? { offset: obj['offset'] } : {}),
115
219
  }
220
+ case 'tool':
221
+ return parseToolCallPart(id, obj)
116
222
  case 'source':
117
223
  if (typeof obj['url'] !== 'string') return null
118
224
  return {
225
+ id,
119
226
  type: 'source',
120
227
  url: obj['url'],
121
228
  ...(typeof obj['title'] === 'string' ? { title: obj['title'] } : {}),
122
229
  }
123
230
  case 'error':
124
231
  if (typeof obj['message'] !== 'string') return null
125
- return { type: 'error', message: obj['message'] }
232
+ return { id, type: 'error', message: obj['message'] }
233
+ default:
234
+ return null
235
+ }
236
+ }
237
+
238
+ /** 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. */
240
+ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPart | null {
241
+ if (typeof obj['toolCallId'] !== 'string') return null
242
+ if (typeof obj['toolName'] !== 'string') return null
243
+ if (typeof obj['state'] !== 'string') return null
244
+
245
+ const { toolCallId, toolName } = obj as { toolCallId: string; toolName: string }
246
+ const common = {
247
+ id,
248
+ type: 'tool' as const,
249
+ toolCallId,
250
+ toolName,
251
+ ...(typeof obj['durationMs'] === 'number' ? { durationMs: obj['durationMs'] } : {}),
252
+ ...(typeof obj['providerExecuted'] === 'boolean'
253
+ ? { providerExecuted: obj['providerExecuted'] }
254
+ : {}),
255
+ }
256
+
257
+ switch (obj['state']) {
258
+ case 'input-streaming':
259
+ return {
260
+ ...common,
261
+ state: 'input-streaming',
262
+ ...('input' in obj ? { input: obj['input'] } : {}),
263
+ }
264
+ case 'input-available':
265
+ if (!('input' in obj)) return null
266
+ return { ...common, state: 'input-available', input: obj['input'] }
267
+ case 'approval-requested': {
268
+ if (!('input' in obj)) return null
269
+ const approval = parseApproval(obj['approval'])
270
+ // The type declares this state's approval as `{ approved?: never; reason?: never }` — a
271
+ // request that already carries a verdict or a reason is a contradiction, not a pending
272
+ // request, so reject it rather than silently casting past the invariant.
273
+ if (approval === null || approval.approved !== undefined || approval.reason !== undefined) {
274
+ return null
275
+ }
276
+ return {
277
+ ...common,
278
+ state: 'approval-requested',
279
+ input: obj['input'],
280
+ approval: approval as ToolApproval & { approved?: never; reason?: never },
281
+ }
282
+ }
283
+ case 'approval-responded': {
284
+ if (!('input' in obj)) return null
285
+ const approval = parseApproval(obj['approval'])
286
+ if (approval === null || typeof approval.approved !== 'boolean') return null
287
+ return {
288
+ ...common,
289
+ state: 'approval-responded',
290
+ input: obj['input'],
291
+ approval: approval as ToolApproval & { approved: boolean },
292
+ }
293
+ }
294
+ case 'output-denied': {
295
+ if (!('input' in obj)) return null
296
+ const approval = parseApproval(obj['approval'])
297
+ if (approval === null || approval.approved !== false) return null
298
+ return {
299
+ ...common,
300
+ state: 'output-denied',
301
+ input: obj['input'],
302
+ approval: approval as ToolApproval & { approved: false },
303
+ }
304
+ }
305
+ case 'output-available': {
306
+ if (!('input' in obj)) return null
307
+ if (!('output' in obj)) return null
308
+ const approval = obj['approval'] === undefined ? null : parseApproval(obj['approval'])
309
+ // The type declares this state's approval as `{ approved?: true }` — output present
310
+ // alongside `approved: false` is a denial masquerading as a result, not a valid "approved"
311
+ // carry-through. A present-but-malformed approval is rejected the same way (not silently
312
+ // dropped), matching output-denied/approval-responded's precedent above.
313
+ if (obj['approval'] !== undefined && (approval === null || approval.approved === false)) {
314
+ return null
315
+ }
316
+ return {
317
+ ...common,
318
+ state: 'output-available',
319
+ input: obj['input'],
320
+ output: obj['output'],
321
+ ...(typeof obj['preliminary'] === 'boolean' ? { preliminary: obj['preliminary'] } : {}),
322
+ ...(approval !== null ? { approval: approval as ToolApproval & { approved?: true } } : {}),
323
+ }
324
+ }
325
+ case 'output-error': {
326
+ if (typeof obj['errorText'] !== 'string') return null
327
+ const approval = obj['approval'] === undefined ? null : parseApproval(obj['approval'])
328
+ // Same invariant as output-available above: an error carrying `approved: false` is a
329
+ // denial, not an error whose approval "happens to carry through".
330
+ if (obj['approval'] !== undefined && (approval === null || approval.approved === false)) {
331
+ return null
332
+ }
333
+ return {
334
+ ...common,
335
+ state: 'output-error',
336
+ errorText: obj['errorText'],
337
+ ...('input' in obj ? { input: obj['input'] } : {}),
338
+ ...('rawInput' in obj ? { rawInput: obj['rawInput'] } : {}),
339
+ ...(approval !== null ? { approval: approval as ToolApproval & { approved?: true } } : {}),
340
+ }
341
+ }
126
342
  default:
127
343
  return null
128
344
  }
129
345
  }
130
346
 
347
+ /** Validates the nested `approval` envelope. Returns null when absent/malformed. */
348
+ function parseApproval(raw: unknown): ToolApproval | null {
349
+ if (raw === null || typeof raw !== 'object') return null
350
+ const obj = raw as Record<string, unknown>
351
+ if (typeof obj['id'] !== 'string') return null
352
+ return {
353
+ id: obj['id'],
354
+ ...(typeof obj['approved'] === 'boolean' ? { approved: obj['approved'] } : {}),
355
+ ...(typeof obj['reason'] === 'string' ? { reason: obj['reason'] } : {}),
356
+ ...(typeof obj['isAutomatic'] === 'boolean' ? { isAutomatic: obj['isAutomatic'] } : {}),
357
+ ...(typeof obj['signature'] === 'string' ? { signature: obj['signature'] } : {}),
358
+ }
359
+ }
360
+
131
361
  // ── isStartPart ──────────────────────────────────────────────────────────────
132
362
 
133
363
  /** Type guard: narrows an unknown value to StartPart. */
@@ -0,0 +1,147 @@
1
+ /**
2
+ * createThreadsStore — ring-buffer caps (maxThreads/maxMessagesPerThread), newest-first ordering,
3
+ * setResumeToken's delete-the-key-on-undefined semantics, and same-tick action accumulation.
4
+ *
5
+ * Uses the REAL localStorage-backed store (happy-dom provides `window.localStorage`), so every
6
+ * test uses its own unique `key` — the same convention as
7
+ * `router-tanstack/multi-search-param-store.test.ts` — to avoid cross-test bleed through the
8
+ * shared localStorage instance the process-wide DOM registration provides.
9
+ */
10
+ import { describe, expect, test } from 'bun:test'
11
+ import { act, renderHook } from '@testing-library/react'
12
+ import { createThreadsStore } from './thread'
13
+ import type { ChatMessage } from './history'
14
+
15
+ function makeMessage(id: string): ChatMessage {
16
+ return { id, role: 'user', parts: [{ type: 'text', text: id }], createdAt: Date.now() }
17
+ }
18
+
19
+ describe('createThreadsStore', () => {
20
+ test('caps at 50 threads, dropping the oldest (tail) when exceeded', () => {
21
+ const useThreads = createThreadsStore({ key: 'thread-cap-a', version: 1 })
22
+ const { result } = renderHook(() => useThreads())
23
+
24
+ const ids: string[] = []
25
+ act(() => {
26
+ for (let i = 0; i < 51; i++) {
27
+ ids.push(result.current.create())
28
+ }
29
+ })
30
+
31
+ expect(result.current.threads).toHaveLength(50)
32
+ // The very first created thread is the oldest — it must have been dropped.
33
+ expect(result.current.threads.some((t) => t.id === ids[0])).toBe(false)
34
+ // The very last created thread is the newest — it must be retained, at index 0.
35
+ expect(result.current.threads[0]?.id).toBe(ids[50])
36
+ })
37
+
38
+ test('caps at 100 messages per thread, dropping the oldest first', () => {
39
+ const useThreads = createThreadsStore({ key: 'thread-cap-b', version: 1 })
40
+ const { result } = renderHook(() => useThreads())
41
+
42
+ let threadId = ''
43
+ act(() => {
44
+ threadId = result.current.create()
45
+ })
46
+
47
+ act(() => {
48
+ for (let i = 0; i < 101; i++) {
49
+ result.current.appendMessage(threadId, makeMessage(`m${i}`))
50
+ }
51
+ })
52
+
53
+ const thread = result.current.threads.find((t) => t.id === threadId)
54
+ expect(thread?.messages).toHaveLength(100)
55
+ // m0 (the oldest) must have been dropped; m1..m100 survive.
56
+ expect(thread?.messages[0]?.id).toBe('m1')
57
+ expect(thread?.messages.at(-1)?.id).toBe('m100')
58
+ })
59
+
60
+ test('threads are stored newest-first: the most recently created thread is always index 0', () => {
61
+ const useThreads = createThreadsStore({ key: 'thread-order-a', version: 1 })
62
+ const { result } = renderHook(() => useThreads())
63
+
64
+ let idA = ''
65
+ let idB = ''
66
+ let idC = ''
67
+ act(() => {
68
+ idA = result.current.create()
69
+ })
70
+ act(() => {
71
+ idB = result.current.create()
72
+ })
73
+ act(() => {
74
+ idC = result.current.create()
75
+ })
76
+
77
+ expect(result.current.threads.map((t) => t.id)).toEqual([idC, idB, idA])
78
+ })
79
+
80
+ test('setResumeToken(id, undefined) deletes the key rather than assigning undefined', () => {
81
+ const useThreads = createThreadsStore({ key: 'thread-resume-a', version: 1 })
82
+ const { result } = renderHook(() => useThreads())
83
+
84
+ let threadId = ''
85
+ act(() => {
86
+ threadId = result.current.create()
87
+ })
88
+
89
+ act(() => {
90
+ result.current.setResumeToken(threadId, 'token-1')
91
+ })
92
+ const withToken = result.current.threads.find((t) => t.id === threadId)
93
+ expect(withToken?.resumeToken).toBe('token-1')
94
+ expect('resumeToken' in (withToken ?? {})).toBe(true)
95
+ // JSON round-trip: the key is present and serialized.
96
+ expect(JSON.parse(JSON.stringify(withToken)).resumeToken).toBe('token-1')
97
+
98
+ act(() => {
99
+ result.current.setResumeToken(threadId, undefined)
100
+ })
101
+ const cleared = result.current.threads.find((t) => t.id === threadId)
102
+ // The distinction this test pins: the key is ABSENT, not present-with-value-undefined —
103
+ // observable via `in` (survives a plain assignment of `undefined`) and via JSON round-trip
104
+ // (an `undefined`-valued key would also vanish from JSON, so `in` is the load-bearing check).
105
+ expect('resumeToken' in (cleared ?? {})).toBe(false)
106
+ expect(cleared?.resumeToken).toBeUndefined()
107
+ expect(JSON.stringify(cleared)).not.toContain('resumeToken')
108
+ })
109
+
110
+ test('two synchronous actions called in the same tick accumulate rather than one clobbering the other', () => {
111
+ const useThreads = createThreadsStore({ key: 'thread-accumulate-a', version: 1 })
112
+ const { result } = renderHook(() => useThreads())
113
+
114
+ let threadId = ''
115
+ act(() => {
116
+ // Both calls happen before React re-renders and hands back a fresh `result.current` — the
117
+ // second call must still observe the first's effect via the ref mirror (thread.ts:178-190),
118
+ // not the stale pre-render `state` closure.
119
+ threadId = result.current.create()
120
+ result.current.appendMessage(threadId, makeMessage('accumulated'))
121
+ })
122
+
123
+ const thread = result.current.threads.find((t) => t.id === threadId)
124
+ expect(thread).toBeDefined()
125
+ expect(thread?.messages).toHaveLength(1)
126
+ expect(thread?.messages[0]?.id).toBe('accumulated')
127
+ })
128
+
129
+ test('two synchronous setStatus + setResumeToken calls in one tick both land, not just the last', () => {
130
+ const useThreads = createThreadsStore({ key: 'thread-accumulate-b', version: 1 })
131
+ const { result } = renderHook(() => useThreads())
132
+
133
+ let threadId = ''
134
+ act(() => {
135
+ threadId = result.current.create()
136
+ })
137
+
138
+ act(() => {
139
+ result.current.setStatus(threadId, 'streaming')
140
+ result.current.setResumeToken(threadId, 'tok-accum')
141
+ })
142
+
143
+ const thread = result.current.threads.find((t) => t.id === threadId)
144
+ expect(thread?.status).toBe('streaming')
145
+ expect(thread?.resumeToken).toBe('tok-accum')
146
+ })
147
+ })
@@ -0,0 +1,70 @@
1
+ /**
2
+ * transport — `isResumable` and `edenTransport`'s opt-in `idempotentReplay` flag.
3
+ *
4
+ * `resume` alone is NOT enough for a transport to be treated as resumable: `isResumable` also
5
+ * requires the literal `idempotentReplay: true` assertion, and `edenTransport` only sets it when
6
+ * the caller opts in explicitly via a third argument — never inferred from `resumeCall` alone.
7
+ */
8
+ import { describe, expect, test } from 'bun:test'
9
+ import { edenTransport, isResumable } from './transport'
10
+ import type { AgentTransport } from './transport'
11
+ import type { AgentPart } from './parts'
12
+
13
+ describe('isResumable', () => {
14
+ test('false for a transport with no resume() at all', () => {
15
+ const transport: AgentTransport<AgentPart, string> = {
16
+ async *stream() {},
17
+ }
18
+ expect(isResumable(transport)).toBe(false)
19
+ })
20
+
21
+ test('false for a transport with resume() but WITHOUT idempotentReplay — resume alone is not enough', () => {
22
+ const transport: AgentTransport<AgentPart, string> = {
23
+ async *stream() {},
24
+ async *resume() {
25
+ yield { id: 'p1', type: 'text', text: 'resumed' }
26
+ },
27
+ }
28
+ expect(isResumable(transport)).toBe(false)
29
+ })
30
+
31
+ test('true only once BOTH resume() and idempotentReplay: true are present', () => {
32
+ const transport = {
33
+ async *stream() {},
34
+ async *resume() {
35
+ yield { id: 'p1', type: 'text', text: 'resumed' }
36
+ },
37
+ idempotentReplay: true as const,
38
+ }
39
+ expect(isResumable(transport)).toBe(true)
40
+ })
41
+ })
42
+
43
+ describe('edenTransport — idempotentReplay is opt-in, never inferred', () => {
44
+ test('no resumeCall at all → the returned object has NO resume key', () => {
45
+ const transport = edenTransport<AgentPart>(async () => ({
46
+ data: (async function* () {})(),
47
+ error: null,
48
+ }))
49
+ expect('resume' in transport).toBe(false)
50
+ expect(isResumable(transport)).toBe(false)
51
+ })
52
+
53
+ test('resumeCall supplied but NO options → resume() exists but isResumable is still false', () => {
54
+ const transport = edenTransport<AgentPart>(
55
+ async () => ({ data: (async function* () {})(), error: null }),
56
+ async () => ({ data: (async function* () {})(), error: null }),
57
+ )
58
+ expect(typeof transport.resume).toBe('function')
59
+ expect(isResumable(transport)).toBe(false)
60
+ })
61
+
62
+ test('resumeCall + { idempotentReplay: true } → isResumable is true', () => {
63
+ const transport = edenTransport<AgentPart>(
64
+ async () => ({ data: (async function* () {})(), error: null }),
65
+ async () => ({ data: (async function* () {})(), error: null }),
66
+ { idempotentReplay: true },
67
+ )
68
+ expect(isResumable(transport)).toBe(true)
69
+ })
70
+ })