basalt-ui 1.9.0 → 1.10.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.
@@ -0,0 +1,398 @@
1
+ /**
2
+ * useAgentThreadRuns — concurrency, busy-thread no-op, finalize ordering, failure status, retry.
3
+ *
4
+ * Scope: the multi-thread run manager AS THE PUBLIC API EXISTS TODAY (B1). No mergePart/part ids/
5
+ * offsets, no seven-state ToolCallPart, no ResumableAgentTransport/isResumable/idempotentReplay
6
+ * beyond the plain optional `resume` already on AgentTransport, no stop-preserves-partial-turn, no
7
+ * ThreadsStoreAdapter — all later releases, not exercised here.
8
+ *
9
+ * The mount-reconcile/resume path (StartPart handling, orphaned-thread resumption on mount) has
10
+ * its own file, `use-agent-thread-runs.resume.test.tsx`; the F3 wedge has its own file,
11
+ * `use-agent-thread-runs.wedge.test.tsx`.
12
+ */
13
+ import { describe, expect, test } from 'bun:test'
14
+ import { act, renderHook, waitFor } from '@testing-library/react'
15
+ import { useAgentThreadRuns } from './use-agent-thread-runs'
16
+ import type { AgentThread, ThreadsStore } from './thread'
17
+ import type { AgentTransport } from './transport'
18
+ import type { AgentPart } from './parts'
19
+ import type { AgentOutcome, OutcomeResolver } from './outcome'
20
+
21
+ // ── test-only ThreadsStore double — see use-agent-thread-runs.wedge.test.tsx for the rationale ──
22
+ function createTestThreadsStore(
23
+ opts: {
24
+ readonly initial?: AgentThread<AgentPart>[]
25
+ readonly onCall?: (method: string) => void
26
+ } = {},
27
+ ): ThreadsStore<AgentPart> {
28
+ let threads = opts.initial ?? []
29
+ let activeId: string | null = null
30
+ const onCall = opts.onCall ?? ((): void => {})
31
+
32
+ return {
33
+ get threads() {
34
+ return threads
35
+ },
36
+ get activeId() {
37
+ return activeId
38
+ },
39
+ select(id) {
40
+ activeId = id
41
+ },
42
+ create(createOpts) {
43
+ const id = crypto.randomUUID()
44
+ const now = Date.now()
45
+ const thread: AgentThread<AgentPart> = {
46
+ id,
47
+ messages: [],
48
+ outcome: null,
49
+ status: 'pending',
50
+ read: false,
51
+ createdAt: now,
52
+ updatedAt: now,
53
+ ...(createOpts?.meta !== undefined ? { meta: createOpts.meta } : {}),
54
+ }
55
+ threads = [thread, ...threads]
56
+ return id
57
+ },
58
+ appendMessage(id, message) {
59
+ onCall('appendMessage')
60
+ threads = threads.map((thread) =>
61
+ thread.id === id
62
+ ? { ...thread, messages: [...thread.messages, message], updatedAt: Date.now() }
63
+ : thread,
64
+ )
65
+ },
66
+ setOutcome(id, outcome) {
67
+ onCall('setOutcome')
68
+ threads = threads.map((thread) =>
69
+ thread.id === id ? { ...thread, outcome, updatedAt: Date.now() } : thread,
70
+ )
71
+ },
72
+ setStatus(id, status) {
73
+ onCall('setStatus')
74
+ threads = threads.map((thread) =>
75
+ thread.id === id ? { ...thread, status, updatedAt: Date.now() } : thread,
76
+ )
77
+ },
78
+ setResumeToken(id, token) {
79
+ onCall('setResumeToken')
80
+ threads = threads.map((thread) => {
81
+ if (thread.id !== id) return thread
82
+ const { resumeToken: _resumeToken, ...rest } = thread
83
+ return {
84
+ ...rest,
85
+ ...(token !== undefined ? { resumeToken: token } : {}),
86
+ updatedAt: Date.now(),
87
+ }
88
+ })
89
+ },
90
+ markRead(id) {
91
+ threads = threads.map((thread) => (thread.id === id ? { ...thread, read: true } : thread))
92
+ },
93
+ remove(id) {
94
+ threads = threads.filter((thread) => thread.id !== id)
95
+ if (activeId === id) activeId = null
96
+ },
97
+ clear() {
98
+ threads = []
99
+ activeId = null
100
+ },
101
+ }
102
+ }
103
+
104
+ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
105
+ let resolve!: (value: T) => void
106
+ const promise = new Promise<T>((r) => {
107
+ resolve = r
108
+ })
109
+ return { promise, resolve }
110
+ }
111
+
112
+ const resolveOutcome: OutcomeResolver<AgentPart> = (): AgentOutcome => ({
113
+ title: 'title',
114
+ summary: 'summary',
115
+ status: 'done',
116
+ })
117
+
118
+ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failure, retry', () => {
119
+ test('two threads stream fully independently', async () => {
120
+ const store = createTestThreadsStore()
121
+ const threadA = store.create()
122
+ const threadB = store.create()
123
+
124
+ const gates = new Map<string, ReturnType<typeof deferred<void>>>()
125
+ gates.set('input-a', deferred<void>())
126
+ gates.set('input-b', deferred<void>())
127
+
128
+ const transport: AgentTransport<AgentPart, string> = {
129
+ async *stream(input) {
130
+ await gates.get(input)?.promise
131
+ yield { type: 'text', text: `done:${input}` }
132
+ },
133
+ }
134
+
135
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
136
+
137
+ act(() => {
138
+ result.current.start(threadA, 'input-a')
139
+ result.current.start(threadB, 'input-b')
140
+ })
141
+
142
+ expect(result.current.runs.get(threadA)?.status).toBe('streaming')
143
+ expect(result.current.runs.get(threadB)?.status).toBe('streaming')
144
+
145
+ // Resolve A only — B must stay untouched. Resolving a promise is not itself a React update,
146
+ // so this stays outside act(); waitFor (imported from @testing-library/react) is act-wrapped
147
+ // internally and polls until the resulting state settles.
148
+ gates.get('input-a')?.resolve(undefined)
149
+
150
+ await waitFor(() => {
151
+ expect(store.threads.find((t) => t.id === threadA)?.status).toBe('done')
152
+ })
153
+ expect(store.threads.find((t) => t.id === threadB)?.status).toBe('streaming')
154
+ expect(result.current.runs.has(threadB)).toBe(true)
155
+
156
+ // Resolve B — it settles independently, unaffected by A having already finished.
157
+ gates.get('input-b')?.resolve(undefined)
158
+
159
+ await waitFor(() => {
160
+ expect(store.threads.find((t) => t.id === threadB)?.status).toBe('done')
161
+ })
162
+ })
163
+
164
+ test('start() on a busy thread is a no-op and does not overwrite the cached input', async () => {
165
+ const store = createTestThreadsStore()
166
+ const threadId = store.create()
167
+
168
+ const calls: string[] = []
169
+ const gate = deferred<void>()
170
+ const transport: AgentTransport<AgentPart, string> = {
171
+ async *stream(input) {
172
+ calls.push(input)
173
+ await gate.promise
174
+ yield { type: 'text', text: 'ok' }
175
+ },
176
+ }
177
+
178
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
179
+
180
+ act(() => {
181
+ result.current.start(threadId, 'first')
182
+ })
183
+ expect(calls).toEqual(['first'])
184
+
185
+ // Busy: the SAME thread already has a controller. This must no-op — no second stream() call,
186
+ // and (critically) must NOT clobber the cached input retry() will replay.
187
+ act(() => {
188
+ result.current.start(threadId, 'second')
189
+ })
190
+ expect(calls).toEqual(['first'])
191
+
192
+ // Let the first (and only) turn finish, then retry() — it must replay 'first', never 'second'.
193
+ gate.resolve(undefined)
194
+ await waitFor(() => {
195
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
196
+ })
197
+
198
+ act(() => {
199
+ result.current.retry(threadId)
200
+ })
201
+ expect(calls).toEqual(['first', 'first'])
202
+
203
+ // Let the replayed run settle too, so no pending async work leaks past this test's act
204
+ // boundary into the next one.
205
+ await waitFor(() => {
206
+ expect(result.current.runs.has(threadId)).toBe(false)
207
+ })
208
+ })
209
+
210
+ test('finalize order on success: appendMessage → resolveOutcome → setOutcome → setStatus → setResumeToken', async () => {
211
+ const order: string[] = []
212
+ const store = createTestThreadsStore({ onCall: (method) => order.push(method) })
213
+ const threadId = store.create()
214
+
215
+ const gate = deferred<void>()
216
+ const transport: AgentTransport<AgentPart, string> = {
217
+ async *stream() {
218
+ await gate.promise
219
+ yield { type: 'text', text: 'hi' }
220
+ },
221
+ }
222
+
223
+ const orderedResolveOutcome: OutcomeResolver<AgentPart> = async (): Promise<AgentOutcome> => {
224
+ order.push('resolveOutcome')
225
+ return { title: 'title', summary: 'summary', status: 'done' }
226
+ }
227
+
228
+ const { result } = renderHook(() =>
229
+ useAgentThreadRuns({ transport, store, resolveOutcome: orderedResolveOutcome }),
230
+ )
231
+
232
+ act(() => {
233
+ result.current.start(threadId, 'hi')
234
+ })
235
+ // start() itself already logged 'appendMessage' (user message) + 'setStatus' ('streaming') —
236
+ // baseline everything from here on.
237
+ const baseline = order.length
238
+
239
+ gate.resolve(undefined)
240
+ await waitFor(() => {
241
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
242
+ })
243
+
244
+ expect(order.slice(baseline)).toEqual([
245
+ 'appendMessage',
246
+ 'resolveOutcome',
247
+ 'setOutcome',
248
+ 'setStatus',
249
+ 'setResumeToken',
250
+ ])
251
+
252
+ // The controller/runs entry is torn down as the final step of the same finalize — proven
253
+ // indirectly: the run entry is gone, and a subsequent start() is accepted immediately (the
254
+ // busy guard would block it if the controller were still registered).
255
+ expect(result.current.runs.has(threadId)).toBe(false)
256
+ })
257
+
258
+ test('a thrown (non-abort) error sets the onFailureStatus for start() — "error"', async () => {
259
+ const store = createTestThreadsStore()
260
+ const threadId = store.create()
261
+
262
+ const transport: AgentTransport<AgentPart, string> = {
263
+ async *stream() {
264
+ throw new Error('boom')
265
+ // eslint-disable-next-line no-unreachable
266
+ yield { type: 'text', text: 'unreachable' }
267
+ },
268
+ }
269
+
270
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
271
+
272
+ act(() => {
273
+ result.current.start(threadId, 'hi')
274
+ })
275
+
276
+ await waitFor(() => {
277
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('error')
278
+ })
279
+ expect(result.current.runs.has(threadId)).toBe(false)
280
+ })
281
+
282
+ test('retry() is a no-op when nothing has been cached for that thread', () => {
283
+ // No store.create() here on purpose: a freshly-created 'pending' thread is itself swept by
284
+ // the mount-reconcile effect (it treats any 'pending'/'streaming' thread with no live
285
+ // controller as orphaned — see use-agent-thread-runs.resume.test.tsx), which would confound
286
+ // this assertion. Using an id with NO entry in the store at all isolates retry()'s own
287
+ // "nothing cached" guard from that unrelated mount-time behavior.
288
+ const store = createTestThreadsStore()
289
+ const threadId = crypto.randomUUID()
290
+
291
+ const calls: string[] = []
292
+ const transport: AgentTransport<AgentPart, string> = {
293
+ async *stream(input) {
294
+ calls.push(input)
295
+ yield { type: 'text', text: 'x' }
296
+ },
297
+ }
298
+
299
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
300
+
301
+ act(() => {
302
+ result.current.retry(threadId)
303
+ })
304
+
305
+ expect(calls).toEqual([])
306
+ expect(store.threads).toEqual([])
307
+ })
308
+
309
+ test('retry() re-invokes start() with the cached input and produces a genuine second run', async () => {
310
+ const store = createTestThreadsStore()
311
+ const threadId = store.create()
312
+
313
+ const calls: string[] = []
314
+ const transport: AgentTransport<AgentPart, string> = {
315
+ async *stream(input) {
316
+ calls.push(input)
317
+ yield { type: 'text', text: `done:${input}` }
318
+ },
319
+ }
320
+
321
+ const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
322
+
323
+ act(() => {
324
+ result.current.start(threadId, 'original input')
325
+ })
326
+ await waitFor(() => {
327
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
328
+ })
329
+ expect(calls).toEqual(['original input'])
330
+
331
+ act(() => {
332
+ result.current.retry(threadId)
333
+ })
334
+ // The positive path this test exists to pin: retry() actually calls start() again with the
335
+ // SAME cached input — not a no-op, and not a clobbered/different input.
336
+ expect(calls).toEqual(['original input', 'original input'])
337
+ // A genuine second run is now live — the run entry is back and streaming.
338
+ expect(result.current.runs.get(threadId)?.status).toBe('streaming')
339
+
340
+ await waitFor(() => {
341
+ expect(result.current.runs.has(threadId)).toBe(false)
342
+ })
343
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
344
+ // Two full turns landed in history: user+assistant, twice.
345
+ expect(store.threads.find((t) => t.id === threadId)?.messages).toHaveLength(4)
346
+ })
347
+
348
+ // Flagged for scheduling, not fixed here (out of this task's scope — see the brief's "while you
349
+ // are in there, report, do not fix" note (i)): consumeAndFinalize's success path awaits
350
+ // resolveOutcome (:219) and then writes setOutcome/setStatus (:221-222) WITHOUT re-checking the
351
+ // supersede/abort guards it checked right before the await (:205-207). A slow resolveOutcome
352
+ // racing a stop() call is reachable: stop() sets the thread to 'done' immediately, but once the
353
+ // delayed resolveOutcome finally resolves, the unconditional setStatus(threadId, outcome.status)
354
+ // silently overwrites that 'done' with whatever the outcome resolved to. Verified failing against
355
+ // the current implementation before being marked .skip here.
356
+ test.skip('stop() during a slow resolveOutcome must not be clobbered by the outcome settling later', async () => {
357
+ const store = createTestThreadsStore()
358
+ const threadId = store.create()
359
+
360
+ let releaseOutcome: (() => void) | undefined
361
+ const slowResolveOutcome: OutcomeResolver<AgentPart> = () =>
362
+ new Promise<AgentOutcome>((resolve) => {
363
+ releaseOutcome = () => resolve({ title: 't', summary: 's', status: 'attention' })
364
+ })
365
+
366
+ const transport: AgentTransport<AgentPart, string> = {
367
+ async *stream() {
368
+ yield { type: 'text', text: 'hi' }
369
+ },
370
+ }
371
+
372
+ const { result } = renderHook(() =>
373
+ useAgentThreadRuns({ transport, store, resolveOutcome: slowResolveOutcome }),
374
+ )
375
+
376
+ act(() => {
377
+ result.current.start(threadId, 'hi')
378
+ })
379
+
380
+ // Wait until the assistant message has landed (proving resolveOutcome has been called and is
381
+ // now the pending await).
382
+ await waitFor(() => {
383
+ expect(store.threads.find((t) => t.id === threadId)?.messages.length).toBe(2)
384
+ })
385
+
386
+ act(() => {
387
+ result.current.stop(threadId)
388
+ })
389
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
390
+
391
+ releaseOutcome?.()
392
+
393
+ await waitFor(() => {
394
+ // Currently fails: the late setStatus overwrites 'done' with 'attention'.
395
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
396
+ })
397
+ })
398
+ })
@@ -305,10 +305,16 @@ export function useAgentThreadRuns<TPart = AgentPart>({
305
305
  return resolved
306
306
  }
307
307
 
308
- // Abort every in-flight stream on unmount to stop the async generators.
308
+ // Abort every in-flight stream on unmount to stop the async generators. Also clears the map —
309
+ // leaving aborted entries behind would make the mount-reconcile effect below see a
310
+ // `controllersRef.current.has(id)` of `true` for a thread whose only consumer was just aborted,
311
+ // skipping the orphan-resume path and wedging the thread in 'streaming' forever. Reachable
312
+ // whenever this fiber's effects re-run without the fiber itself unmounting (React 19 StrictMode
313
+ // double-invoke; `<Activity>` hide/show) — see this hook's `@example`-adjacent doc / the F3 note.
309
314
  useEffect(
310
315
  () => () => {
311
316
  controllersRef.current.forEach((controller) => controller.abort())
317
+ controllersRef.current.clear()
312
318
  },
313
319
  [],
314
320
  )