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.
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +8 -0
- package/README.md +64 -34
- package/agent/rules/basalt-agent.md +49 -6
- package/configs/oxlint-plugin.js +279 -0
- package/configs/oxlint-plugin.test.ts +216 -1
- package/configs/oxlint.json +5 -2
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +1 -0
- package/dist/agent-chat/index.d.ts +4 -3
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/cli/index.d.ts +33 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +131 -0
- package/dist/surfaces.d.ts +10 -0
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +21 -0
- package/llms.txt +9 -2
- package/package.json +6 -1
- package/src/agent/ai-sdk-transport.test.ts +269 -0
- package/src/agent/parts.test.ts +261 -0
- package/src/agent/thread.test.ts +147 -0
- package/src/agent/use-agent-stream.test.tsx +231 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +261 -0
- package/src/agent/use-agent-thread-runs.test.tsx +398 -0
- package/src/agent/use-agent-thread-runs.ts +7 -1
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +299 -0
- package/src/agent-chat/index.ts +4 -3
- package/src/charts/primitives/ChartFrame.test.tsx +8 -5
- package/src/cli/index.ts +285 -0
- package/src/dashboard/stat-card.test.tsx +5 -3
- package/src/provider/index.test.tsx +6 -5
- package/src/surfaces.ts +22 -0
- package/src/theme/use-basalt-spacing.test.tsx +7 -4
- package/src/tokens/build-fonts-css.test.ts +3 -2
|
@@ -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
|
+
})
|