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,231 @@
1
+ /**
2
+ * useAgentStream — single in-flight-turn lifecycle: abort, supersede, error, AbortError handling,
3
+ * unmount cleanup, and StrictMode double-invoke of a "send on mount" consumer effect.
4
+ *
5
+ * Scope: the API AS IT EXISTS TODAY (B1) — no stop-preserves-the-partial-turn semantics, no
6
+ * `ChatMessage.finish`; those are later-release surface (see the sibling `use-agent-thread-runs.*`
7
+ * test files' scope note).
8
+ */
9
+ import { describe, expect, test } from 'bun:test'
10
+ import { StrictMode, useEffect } from 'react'
11
+ import { act, render, renderHook, waitFor } from '@testing-library/react'
12
+ import { useAgentStream } from './use-agent-stream'
13
+ import type { AgentTransport } from './transport'
14
+ import type { AgentPart } from './parts'
15
+
16
+ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
17
+ let resolve!: (value: T) => void
18
+ const promise = new Promise<T>((r) => {
19
+ resolve = r
20
+ })
21
+ return { promise, resolve }
22
+ }
23
+
24
+ describe('useAgentStream', () => {
25
+ test('stop() mid-stream sets status "done" and discards any parts that arrive after the abort', async () => {
26
+ const gate = deferred<void>()
27
+ const transport: AgentTransport<AgentPart, string> = {
28
+ async *stream() {
29
+ yield { type: 'text', text: 'a' }
30
+ await gate.promise
31
+ // Must never be observed — the consumer's guard should discard this once aborted.
32
+ yield { type: 'text', text: 'b' }
33
+ },
34
+ }
35
+
36
+ const { result } = renderHook(() => useAgentStream({ transport }))
37
+
38
+ act(() => {
39
+ void result.current.send('hi')
40
+ })
41
+ await waitFor(() => {
42
+ expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
43
+ })
44
+ expect(result.current.status).toBe('streaming')
45
+
46
+ act(() => {
47
+ result.current.stop()
48
+ })
49
+ // stop() sets 'done' synchronously — never 'error', even for an in-flight stream.
50
+ expect(result.current.status).toBe('done')
51
+
52
+ gate.resolve(undefined)
53
+ await waitFor(() => {
54
+ expect(result.current.status).toBe('done')
55
+ })
56
+ expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
57
+ })
58
+
59
+ test("a second send() supersedes an in-flight one — the first stream's later parts are discarded", async () => {
60
+ const gateFirst = deferred<void>()
61
+ const transport: AgentTransport<AgentPart, string> = {
62
+ async *stream(input) {
63
+ if (input === 'first') {
64
+ yield { type: 'text', text: 'first-1' }
65
+ await gateFirst.promise
66
+ // Must never be observed — 'second' has already superseded this controller.
67
+ yield { type: 'text', text: 'first-2' }
68
+ } else {
69
+ yield { type: 'text', text: 'second-1' }
70
+ }
71
+ },
72
+ }
73
+
74
+ const { result } = renderHook(() => useAgentStream({ transport }))
75
+
76
+ act(() => {
77
+ void result.current.send('first')
78
+ })
79
+ await waitFor(() => {
80
+ expect(result.current.parts).toEqual([{ type: 'text', text: 'first-1' }])
81
+ })
82
+
83
+ act(() => {
84
+ void result.current.send('second')
85
+ })
86
+ await waitFor(() => {
87
+ expect(result.current.parts).toEqual([{ type: 'text', text: 'second-1' }])
88
+ })
89
+ expect(result.current.status).toBe('done')
90
+
91
+ gateFirst.resolve(undefined)
92
+ await waitFor(() => {
93
+ expect(result.current.status).toBe('done')
94
+ })
95
+ expect(result.current.parts).toEqual([{ type: 'text', text: 'second-1' }])
96
+ })
97
+
98
+ test('a thrown (non-abort) error sets status "error" and captures the error', async () => {
99
+ const boom = new Error('boom')
100
+ const transport: AgentTransport<AgentPart, string> = {
101
+ async *stream() {
102
+ throw boom
103
+ // eslint-disable-next-line no-unreachable
104
+ yield { type: 'text', text: 'unreachable' }
105
+ },
106
+ }
107
+
108
+ const { result } = renderHook(() => useAgentStream({ transport }))
109
+
110
+ await act(async () => {
111
+ await result.current.send('hi')
112
+ })
113
+
114
+ expect(result.current.status).toBe('error')
115
+ expect(result.current.error).toBe(boom)
116
+ })
117
+
118
+ test('an AbortError thrown by the transport is ignored — it never overwrites status with "error"', async () => {
119
+ const gate = deferred<void>()
120
+ const transport: AgentTransport<AgentPart, string> = {
121
+ async *stream(_input, signal) {
122
+ yield { type: 'text', text: 'a' }
123
+ await gate.promise
124
+ if (signal?.aborted) throw new DOMException('The operation was aborted.', 'AbortError')
125
+ yield { type: 'text', text: 'b' }
126
+ },
127
+ }
128
+
129
+ const { result } = renderHook(() => useAgentStream({ transport }))
130
+
131
+ act(() => {
132
+ void result.current.send('hi')
133
+ })
134
+ await waitFor(() => {
135
+ expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
136
+ })
137
+
138
+ // stop() aborts the signal AND sets 'done' synchronously.
139
+ act(() => {
140
+ result.current.stop()
141
+ })
142
+ expect(result.current.status).toBe('done')
143
+
144
+ // The transport now throws AbortError (asynchronously, after 'done' is already set) — this
145
+ // must be swallowed by the catch's `err.name === 'AbortError'` guard, never flip to 'error'.
146
+ gate.resolve(undefined)
147
+ await waitFor(() => {
148
+ expect(result.current.status).toBe('done')
149
+ })
150
+ expect(result.current.error).toBeUndefined()
151
+ })
152
+
153
+ test('unmount aborts the in-flight stream', async () => {
154
+ let observedAbortedOnResume = false
155
+ const gate = deferred<void>()
156
+ const transport: AgentTransport<AgentPart, string> = {
157
+ async *stream(_input, signal) {
158
+ yield { type: 'text', text: 'a' }
159
+ await gate.promise
160
+ observedAbortedOnResume = signal?.aborted ?? false
161
+ },
162
+ }
163
+
164
+ const { result, unmount } = renderHook(() => useAgentStream({ transport }))
165
+
166
+ act(() => {
167
+ void result.current.send('hi')
168
+ })
169
+ await waitFor(() => {
170
+ expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
171
+ })
172
+
173
+ unmount()
174
+
175
+ gate.resolve(undefined)
176
+ // Let the generator's resumed step run and observe the signal.
177
+ await new Promise((resolveTick) => setTimeout(resolveTick, 0))
178
+ expect(observedAbortedOnResume).toBe(true)
179
+ })
180
+
181
+ test('StrictMode double-invoke of a "send on mount" consumer effect: the surviving stream is the only one reflected in final state', async () => {
182
+ // useAgentStream itself never calls transport.stream() from an effect — only send() does, and
183
+ // send() is consumer-invoked. The realistic StrictMode hazard is therefore a consumer's own
184
+ // `useEffect(() => { void send(x) }, [])` "send the first turn on mount" pattern, which
185
+ // StrictMode's dev-only double-invoke runs twice against the SAME fiber. Verified empirically
186
+ // (reported alongside this suite): transport.stream() IS called twice in that scenario — the
187
+ // first pass's controller is aborted (both by useAgentStream's own unmount-cleanup effect
188
+ // during the simulated unmount, and redundantly by send()'s own
189
+ // `controllerRef.current?.abort()` at the top of the second call) — so exactly ONE stream's
190
+ // output is ever reflected in the hook's observable state, even though the transport function
191
+ // itself was invoked twice. That is the invariant this test pins, not a literal single
192
+ // `transport.stream()` call count.
193
+ let streamCalls = 0
194
+ let capturedParts: AgentPart[] = []
195
+ let capturedStatus = ''
196
+
197
+ const transport: AgentTransport<AgentPart, string> = {
198
+ async *stream() {
199
+ streamCalls++
200
+ yield { type: 'text', text: `call-${streamCalls}` }
201
+ },
202
+ }
203
+
204
+ function Consumer(): null {
205
+ const { send, parts, status } = useAgentStream({ transport })
206
+ capturedParts = parts
207
+ capturedStatus = status
208
+ useEffect(() => {
209
+ void send('initial')
210
+ // eslint-disable-next-line react-hooks/exhaustive-deps
211
+ }, [])
212
+ return null
213
+ }
214
+
215
+ await act(async () => {
216
+ render(
217
+ <StrictMode>
218
+ <Consumer />
219
+ </StrictMode>,
220
+ )
221
+ })
222
+
223
+ await waitFor(() => {
224
+ expect(capturedStatus).toBe('done')
225
+ })
226
+
227
+ expect(streamCalls).toBe(2)
228
+ // Only the SECOND (surviving) call's part made it into final state — the first is discarded.
229
+ expect(capturedParts).toEqual([{ type: 'text', text: 'call-2' }])
230
+ })
231
+ })
@@ -0,0 +1,261 @@
1
+ /**
2
+ * useAgentThreadRuns — mount-time reconcile ("resume orphaned threads after a reload") coverage.
3
+ *
4
+ * The mount-reconcile effect (empty deps — a one-time sweep of whatever the store held when this
5
+ * manager first attaches) treats any persisted 'pending' or 'streaming' thread with no live
6
+ * controller as orphaned, and either attempts `transport.resume(resumeToken, signal)` (when the
7
+ * transport supports it, a resumeToken is present, and there's a last user message to attribute
8
+ * the resumed turn to) or falls back straight to 'interrupted'.
9
+ *
10
+ * The F3 wedge (StrictMode/`<Activity>` re-running this same effect on the SAME fiber) has its
11
+ * own file: `use-agent-thread-runs.wedge.test.tsx`.
12
+ */
13
+ import { describe, expect, test } from 'bun:test'
14
+ import { 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 { ChatMessage } from './history'
20
+ import type { AgentOutcome } from './outcome'
21
+
22
+ // ── test-only ThreadsStore double — see use-agent-thread-runs.wedge.test.tsx for the rationale ──
23
+ function createTestThreadsStore(initial: AgentThread<AgentPart>[]): ThreadsStore<AgentPart> {
24
+ let threads = initial
25
+ let activeId: string | null = null
26
+ return {
27
+ get threads() {
28
+ return threads
29
+ },
30
+ get activeId() {
31
+ return activeId
32
+ },
33
+ select(id) {
34
+ activeId = id
35
+ },
36
+ create(opts) {
37
+ const id = crypto.randomUUID()
38
+ const now = Date.now()
39
+ const thread: AgentThread<AgentPart> = {
40
+ id,
41
+ messages: [],
42
+ outcome: null,
43
+ status: 'pending',
44
+ read: false,
45
+ createdAt: now,
46
+ updatedAt: now,
47
+ ...(opts?.meta !== undefined ? { meta: opts.meta } : {}),
48
+ }
49
+ threads = [thread, ...threads]
50
+ return id
51
+ },
52
+ appendMessage(id, message) {
53
+ threads = threads.map((thread) =>
54
+ thread.id === id
55
+ ? { ...thread, messages: [...thread.messages, message], updatedAt: Date.now() }
56
+ : thread,
57
+ )
58
+ },
59
+ setOutcome(id, outcome) {
60
+ threads = threads.map((thread) =>
61
+ thread.id === id ? { ...thread, outcome, updatedAt: Date.now() } : thread,
62
+ )
63
+ },
64
+ setStatus(id, status) {
65
+ threads = threads.map((thread) =>
66
+ thread.id === id ? { ...thread, status, updatedAt: Date.now() } : thread,
67
+ )
68
+ },
69
+ setResumeToken(id, token) {
70
+ threads = threads.map((thread) => {
71
+ if (thread.id !== id) return thread
72
+ const { resumeToken: _resumeToken, ...rest } = thread
73
+ return {
74
+ ...rest,
75
+ ...(token !== undefined ? { resumeToken: token } : {}),
76
+ updatedAt: Date.now(),
77
+ }
78
+ })
79
+ },
80
+ markRead(id) {
81
+ threads = threads.map((thread) => (thread.id === id ? { ...thread, read: true } : thread))
82
+ },
83
+ remove(id) {
84
+ threads = threads.filter((thread) => thread.id !== id)
85
+ if (activeId === id) activeId = null
86
+ },
87
+ clear() {
88
+ threads = []
89
+ activeId = null
90
+ },
91
+ }
92
+ }
93
+
94
+ function makeStreamingThread(
95
+ overrides: Partial<AgentThread<AgentPart>> = {},
96
+ ): AgentThread<AgentPart> {
97
+ const now = Date.now()
98
+ const userMessage: ChatMessage<AgentPart> = {
99
+ id: crypto.randomUUID(),
100
+ role: 'user',
101
+ parts: [{ type: 'text', text: 'hello' }],
102
+ createdAt: now,
103
+ }
104
+ return {
105
+ id: crypto.randomUUID(),
106
+ messages: [userMessage],
107
+ outcome: null,
108
+ status: 'streaming',
109
+ read: false,
110
+ createdAt: now,
111
+ updatedAt: now,
112
+ resumeToken: 'resume-token',
113
+ ...overrides,
114
+ }
115
+ }
116
+
117
+ const resolveOutcome = (): AgentOutcome => ({ title: 'title', summary: 'summary', status: 'done' })
118
+
119
+ describe('useAgentThreadRuns — mount-time reconcile', () => {
120
+ test('no resume() on the transport → falls back to interrupted', async () => {
121
+ const thread = makeStreamingThread()
122
+ const store = createTestThreadsStore([thread])
123
+
124
+ const transport: AgentTransport<AgentPart, string> = {
125
+ async *stream() {},
126
+ // no `resume` key at all
127
+ }
128
+
129
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
130
+
131
+ await waitFor(() => {
132
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
133
+ })
134
+ })
135
+
136
+ test('no resumeToken on the persisted thread → falls back to interrupted, even with a resumable transport', async () => {
137
+ // exactOptionalPropertyTypes forbids `resumeToken: undefined` as an override — build the
138
+ // thread directly, simply omitting the key, to represent "never had a resumeToken".
139
+ const now = Date.now()
140
+ const userMessage: ChatMessage<AgentPart> = {
141
+ id: crypto.randomUUID(),
142
+ role: 'user',
143
+ parts: [{ type: 'text', text: 'hello' }],
144
+ createdAt: now,
145
+ }
146
+ const thread: AgentThread<AgentPart> = {
147
+ id: crypto.randomUUID(),
148
+ messages: [userMessage],
149
+ outcome: null,
150
+ status: 'streaming',
151
+ read: false,
152
+ createdAt: now,
153
+ updatedAt: now,
154
+ }
155
+ const store = createTestThreadsStore([thread])
156
+
157
+ let resumeCalls = 0
158
+ const transport: AgentTransport<AgentPart, string> = {
159
+ async *stream() {},
160
+ async *resume() {
161
+ resumeCalls++
162
+ yield { type: 'text', text: 'resumed' }
163
+ },
164
+ }
165
+
166
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
167
+
168
+ await waitFor(() => {
169
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
170
+ })
171
+ expect(resumeCalls).toBe(0)
172
+ })
173
+
174
+ test('no last user message on the persisted thread → falls back to interrupted, even with a resumable transport + token', async () => {
175
+ const thread = makeStreamingThread({ messages: [] })
176
+ const store = createTestThreadsStore([thread])
177
+
178
+ let resumeCalls = 0
179
+ const transport: AgentTransport<AgentPart, string> = {
180
+ async *stream() {},
181
+ async *resume() {
182
+ resumeCalls++
183
+ yield { type: 'text', text: 'resumed' }
184
+ },
185
+ }
186
+
187
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
188
+
189
+ await waitFor(() => {
190
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
191
+ })
192
+ expect(resumeCalls).toBe(0)
193
+ })
194
+
195
+ test('resumable transport + token + last user message → resume() is called exactly once, with that token', async () => {
196
+ const thread = makeStreamingThread({ resumeToken: 'the-exact-token' })
197
+ const store = createTestThreadsStore([thread])
198
+
199
+ const resumeArgs: string[] = []
200
+ const transport: AgentTransport<AgentPart, string> = {
201
+ async *stream() {},
202
+ async *resume(token) {
203
+ resumeArgs.push(token)
204
+ yield { type: 'text', text: 'resumed' }
205
+ },
206
+ }
207
+
208
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
209
+
210
+ await waitFor(() => {
211
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('done')
212
+ })
213
+ expect(resumeArgs).toEqual(['the-exact-token'])
214
+ })
215
+
216
+ test('a failed resume() falls back to interrupted via onFailureStatus, not error', async () => {
217
+ const thread = makeStreamingThread()
218
+ const store = createTestThreadsStore([thread])
219
+
220
+ const transport: AgentTransport<AgentPart, string> = {
221
+ async *stream() {},
222
+ // oxlint-disable-next-line require-yield
223
+ async *resume() {
224
+ throw new Error('resume failed')
225
+ },
226
+ }
227
+
228
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
229
+
230
+ await waitFor(() => {
231
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
232
+ })
233
+ })
234
+
235
+ test('a "pending" (never-started) thread with no live controller is also swept by reconcile', async () => {
236
+ // Not covered by "streaming" scenarios above: reconcile's orphan check is
237
+ // `status === 'pending' || status === 'streaming'`, so a thread that was created but never
238
+ // started is reconciled the same way on the very next mount.
239
+ const now = Date.now()
240
+ const pendingThread: AgentThread<AgentPart> = {
241
+ id: crypto.randomUUID(),
242
+ messages: [],
243
+ outcome: null,
244
+ status: 'pending',
245
+ read: false,
246
+ createdAt: now,
247
+ updatedAt: now,
248
+ }
249
+ const store = createTestThreadsStore([pendingThread])
250
+
251
+ const transport: AgentTransport<AgentPart, string> = {
252
+ async *stream() {},
253
+ }
254
+
255
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
256
+
257
+ await waitFor(() => {
258
+ expect(store.threads.find((t) => t.id === pendingThread.id)?.status).toBe('interrupted')
259
+ })
260
+ })
261
+ })