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.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +18 -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/ai-sdk-transport.d.ts +3 -52
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +108 -24
- package/dist/agent/coalesce.d.ts +26 -0
- package/dist/agent/coalesce.d.ts.map +1 -0
- package/dist/agent/coalesce.js +69 -0
- package/dist/agent/foreign.d.ts +97 -0
- package/dist/agent/foreign.d.ts.map +1 -0
- package/dist/agent/foreign.js +18 -0
- package/dist/agent/history.d.ts +2 -0
- package/dist/agent/history.d.ts.map +1 -1
- package/dist/agent/id.d.ts +35 -0
- package/dist/agent/id.d.ts.map +1 -0
- package/dist/agent/id.js +14 -0
- package/dist/agent/index.d.ts +9 -4
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +15 -3
- package/dist/agent/merge.d.ts +8 -0
- package/dist/agent/merge.d.ts.map +1 -0
- package/dist/agent/merge.js +23 -0
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +16 -6
- package/dist/agent/parts.d.ts +100 -21
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +128 -11
- package/dist/agent/transport.d.ts +39 -3
- package/dist/agent/transport.d.ts.map +1 -1
- package/dist/agent/transport.js +20 -17
- package/dist/agent/use-agent-stream.d.ts +2 -1
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +5 -2
- package/dist/agent/use-agent-thread-runs.d.ts +9 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +76 -23
- package/dist/agent-chat/index.d.ts +6 -3
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/thread-message.d.ts +24 -7
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +102 -43
- package/dist/agent-chat/tool-chip.d.ts +21 -0
- package/dist/agent-chat/tool-chip.d.ts.map +1 -0
- package/dist/agent-chat/tool-chip.js +120 -0
- 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/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +1 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/surfaces.d.ts +11 -1
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +22 -1
- package/llms.txt +10 -3
- package/package.json +6 -1
- package/src/agent/ai-sdk-transport.test.ts +567 -0
- package/src/agent/ai-sdk-transport.ts +206 -53
- package/src/agent/coalesce.test.ts +194 -0
- package/src/agent/coalesce.ts +118 -0
- package/src/agent/foreign.test.ts +47 -0
- package/src/agent/foreign.ts +140 -0
- package/src/agent/history.ts +2 -0
- package/src/agent/id.test.ts +71 -0
- package/src/agent/id.ts +43 -0
- package/src/agent/index.ts +26 -3
- package/src/agent/merge.test.ts +119 -0
- package/src/agent/merge.ts +67 -0
- package/src/agent/part-list.test.tsx +82 -0
- package/src/agent/part-list.tsx +31 -6
- package/src/agent/parts.test.ts +603 -0
- package/src/agent/parts.ts +262 -32
- package/src/agent/thread.test.ts +147 -0
- package/src/agent/transport.test.ts +70 -0
- package/src/agent/transport.ts +72 -20
- package/src/agent/use-agent-stream.test.tsx +261 -0
- package/src/agent/use-agent-stream.ts +13 -3
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +289 -0
- package/src/agent/use-agent-thread-runs.test.tsx +781 -0
- package/src/agent/use-agent-thread-runs.ts +177 -27
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +301 -0
- package/src/agent-chat/index.ts +8 -3
- package/src/agent-chat/thread-message.test.tsx +211 -0
- package/src/agent-chat/thread-message.tsx +235 -62
- package/src/agent-chat/tool-chip.test.tsx +139 -0
- package/src/agent-chat/tool-chip.tsx +219 -0
- 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/guard/check-source.test.ts +8 -4
- package/src/guard/index.ts +1 -3
- package/src/index.ts +2 -0
- package/src/provider/index.test.tsx +6 -5
- package/src/surfaces.ts +23 -1
- package/src/theme/use-basalt-spacing.test.tsx +7 -4
- package/src/tokens/build-fonts-css.test.ts +3 -2
|
@@ -0,0 +1,289 @@
|
|
|
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 is RESUMABLE per `isResumable` — `resume` AND the literal `idempotentReplay: true`
|
|
8
|
+
* assertion, a resumeToken is present, and there's a last user message to attribute the resumed
|
|
9
|
+
* turn to) or falls back straight to 'interrupted'. `resume` alone is NOT enough post-B2 — see the
|
|
10
|
+
* dedicated "resume() present but no idempotentReplay" test below.
|
|
11
|
+
*
|
|
12
|
+
* The F3 wedge (StrictMode/`<Activity>` re-running this same effect on the SAME fiber) has its
|
|
13
|
+
* own file: `use-agent-thread-runs.wedge.test.tsx`.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test'
|
|
16
|
+
import { renderHook, waitFor } from '@testing-library/react'
|
|
17
|
+
import { useAgentThreadRuns } from './use-agent-thread-runs'
|
|
18
|
+
import type { AgentThread, ThreadsStore } from './thread'
|
|
19
|
+
import type { AgentTransport } from './transport'
|
|
20
|
+
import type { AgentPart } from './parts'
|
|
21
|
+
import type { ChatMessage } from './history'
|
|
22
|
+
import type { AgentOutcome } from './outcome'
|
|
23
|
+
|
|
24
|
+
// ── test-only ThreadsStore double — see use-agent-thread-runs.wedge.test.tsx for the rationale ──
|
|
25
|
+
function createTestThreadsStore(initial: AgentThread<AgentPart>[]): ThreadsStore<AgentPart> {
|
|
26
|
+
let threads = initial
|
|
27
|
+
let activeId: string | null = null
|
|
28
|
+
return {
|
|
29
|
+
get threads() {
|
|
30
|
+
return threads
|
|
31
|
+
},
|
|
32
|
+
get activeId() {
|
|
33
|
+
return activeId
|
|
34
|
+
},
|
|
35
|
+
select(id) {
|
|
36
|
+
activeId = id
|
|
37
|
+
},
|
|
38
|
+
create(opts) {
|
|
39
|
+
const id = crypto.randomUUID()
|
|
40
|
+
const now = Date.now()
|
|
41
|
+
const thread: AgentThread<AgentPart> = {
|
|
42
|
+
id,
|
|
43
|
+
messages: [],
|
|
44
|
+
outcome: null,
|
|
45
|
+
status: 'pending',
|
|
46
|
+
read: false,
|
|
47
|
+
createdAt: now,
|
|
48
|
+
updatedAt: now,
|
|
49
|
+
...(opts?.meta !== undefined ? { meta: opts.meta } : {}),
|
|
50
|
+
}
|
|
51
|
+
threads = [thread, ...threads]
|
|
52
|
+
return id
|
|
53
|
+
},
|
|
54
|
+
appendMessage(id, message) {
|
|
55
|
+
threads = threads.map((thread) =>
|
|
56
|
+
thread.id === id
|
|
57
|
+
? { ...thread, messages: [...thread.messages, message], updatedAt: Date.now() }
|
|
58
|
+
: thread,
|
|
59
|
+
)
|
|
60
|
+
},
|
|
61
|
+
setOutcome(id, outcome) {
|
|
62
|
+
threads = threads.map((thread) =>
|
|
63
|
+
thread.id === id ? { ...thread, outcome, updatedAt: Date.now() } : thread,
|
|
64
|
+
)
|
|
65
|
+
},
|
|
66
|
+
setStatus(id, status) {
|
|
67
|
+
threads = threads.map((thread) =>
|
|
68
|
+
thread.id === id ? { ...thread, status, updatedAt: Date.now() } : thread,
|
|
69
|
+
)
|
|
70
|
+
},
|
|
71
|
+
setResumeToken(id, token) {
|
|
72
|
+
threads = threads.map((thread) => {
|
|
73
|
+
if (thread.id !== id) return thread
|
|
74
|
+
const { resumeToken: _resumeToken, ...rest } = thread
|
|
75
|
+
return {
|
|
76
|
+
...rest,
|
|
77
|
+
...(token !== undefined ? { resumeToken: token } : {}),
|
|
78
|
+
updatedAt: Date.now(),
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
},
|
|
82
|
+
markRead(id) {
|
|
83
|
+
threads = threads.map((thread) => (thread.id === id ? { ...thread, read: true } : thread))
|
|
84
|
+
},
|
|
85
|
+
remove(id) {
|
|
86
|
+
threads = threads.filter((thread) => thread.id !== id)
|
|
87
|
+
if (activeId === id) activeId = null
|
|
88
|
+
},
|
|
89
|
+
clear() {
|
|
90
|
+
threads = []
|
|
91
|
+
activeId = null
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function makeStreamingThread(
|
|
97
|
+
overrides: Partial<AgentThread<AgentPart>> = {},
|
|
98
|
+
): AgentThread<AgentPart> {
|
|
99
|
+
const now = Date.now()
|
|
100
|
+
const userMessage: ChatMessage<AgentPart> = {
|
|
101
|
+
id: crypto.randomUUID(),
|
|
102
|
+
role: 'user',
|
|
103
|
+
parts: [{ type: 'text', text: 'hello' }],
|
|
104
|
+
createdAt: now,
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
id: crypto.randomUUID(),
|
|
108
|
+
messages: [userMessage],
|
|
109
|
+
outcome: null,
|
|
110
|
+
status: 'streaming',
|
|
111
|
+
read: false,
|
|
112
|
+
createdAt: now,
|
|
113
|
+
updatedAt: now,
|
|
114
|
+
resumeToken: 'resume-token',
|
|
115
|
+
...overrides,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const resolveOutcome = (): AgentOutcome => ({ title: 'title', summary: 'summary', status: 'done' })
|
|
120
|
+
|
|
121
|
+
describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
122
|
+
test('no resume() on the transport → falls back to interrupted', async () => {
|
|
123
|
+
const thread = makeStreamingThread()
|
|
124
|
+
const store = createTestThreadsStore([thread])
|
|
125
|
+
|
|
126
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
127
|
+
async *stream() {},
|
|
128
|
+
// no `resume` key at all
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
132
|
+
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('no resumeToken on the persisted thread → falls back to interrupted, even with a resumable transport', async () => {
|
|
139
|
+
// exactOptionalPropertyTypes forbids `resumeToken: undefined` as an override — build the
|
|
140
|
+
// thread directly, simply omitting the key, to represent "never had a resumeToken".
|
|
141
|
+
const now = Date.now()
|
|
142
|
+
const userMessage: ChatMessage<AgentPart> = {
|
|
143
|
+
id: crypto.randomUUID(),
|
|
144
|
+
role: 'user',
|
|
145
|
+
parts: [{ type: 'text', text: 'hello' }],
|
|
146
|
+
createdAt: now,
|
|
147
|
+
}
|
|
148
|
+
const thread: AgentThread<AgentPart> = {
|
|
149
|
+
id: crypto.randomUUID(),
|
|
150
|
+
messages: [userMessage],
|
|
151
|
+
outcome: null,
|
|
152
|
+
status: 'streaming',
|
|
153
|
+
read: false,
|
|
154
|
+
createdAt: now,
|
|
155
|
+
updatedAt: now,
|
|
156
|
+
}
|
|
157
|
+
const store = createTestThreadsStore([thread])
|
|
158
|
+
|
|
159
|
+
let resumeCalls = 0
|
|
160
|
+
const transport = {
|
|
161
|
+
async *stream() {},
|
|
162
|
+
async *resume() {
|
|
163
|
+
resumeCalls++
|
|
164
|
+
yield { type: 'text', text: 'resumed' }
|
|
165
|
+
},
|
|
166
|
+
idempotentReplay: true as const,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
170
|
+
|
|
171
|
+
await waitFor(() => {
|
|
172
|
+
expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
|
|
173
|
+
})
|
|
174
|
+
expect(resumeCalls).toBe(0)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('no last user message on the persisted thread → falls back to interrupted, even with a resumable transport + token', async () => {
|
|
178
|
+
const thread = makeStreamingThread({ messages: [] })
|
|
179
|
+
const store = createTestThreadsStore([thread])
|
|
180
|
+
|
|
181
|
+
let resumeCalls = 0
|
|
182
|
+
const transport = {
|
|
183
|
+
async *stream() {},
|
|
184
|
+
async *resume() {
|
|
185
|
+
resumeCalls++
|
|
186
|
+
yield { type: 'text', text: 'resumed' }
|
|
187
|
+
},
|
|
188
|
+
idempotentReplay: true as const,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
192
|
+
|
|
193
|
+
await waitFor(() => {
|
|
194
|
+
expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
|
|
195
|
+
})
|
|
196
|
+
expect(resumeCalls).toBe(0)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('resume() present but WITHOUT idempotentReplay → still falls back to interrupted (resume alone is not enough post-B2)', async () => {
|
|
200
|
+
const thread = makeStreamingThread()
|
|
201
|
+
const store = createTestThreadsStore([thread])
|
|
202
|
+
|
|
203
|
+
let resumeCalls = 0
|
|
204
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
205
|
+
async *stream() {},
|
|
206
|
+
async *resume() {
|
|
207
|
+
resumeCalls++
|
|
208
|
+
yield { type: 'text', text: 'resumed' }
|
|
209
|
+
},
|
|
210
|
+
// deliberately NO idempotentReplay: true
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
214
|
+
|
|
215
|
+
await waitFor(() => {
|
|
216
|
+
expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
|
|
217
|
+
})
|
|
218
|
+
expect(resumeCalls).toBe(0)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('resumable transport + token + last user message → resume() is called exactly once, with that token', async () => {
|
|
222
|
+
const thread = makeStreamingThread({ resumeToken: 'the-exact-token' })
|
|
223
|
+
const store = createTestThreadsStore([thread])
|
|
224
|
+
|
|
225
|
+
const resumeArgs: string[] = []
|
|
226
|
+
const transport = {
|
|
227
|
+
async *stream() {},
|
|
228
|
+
async *resume(token: string) {
|
|
229
|
+
resumeArgs.push(token)
|
|
230
|
+
yield { type: 'text', text: 'resumed' }
|
|
231
|
+
},
|
|
232
|
+
idempotentReplay: true as const,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
236
|
+
|
|
237
|
+
await waitFor(() => {
|
|
238
|
+
expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('done')
|
|
239
|
+
})
|
|
240
|
+
expect(resumeArgs).toEqual(['the-exact-token'])
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('a failed resume() falls back to interrupted via onFailureStatus, not error', async () => {
|
|
244
|
+
const thread = makeStreamingThread()
|
|
245
|
+
const store = createTestThreadsStore([thread])
|
|
246
|
+
|
|
247
|
+
const transport = {
|
|
248
|
+
async *stream() {},
|
|
249
|
+
// oxlint-disable-next-line require-yield
|
|
250
|
+
async *resume() {
|
|
251
|
+
throw new Error('resume failed')
|
|
252
|
+
},
|
|
253
|
+
idempotentReplay: true as const,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
257
|
+
|
|
258
|
+
await waitFor(() => {
|
|
259
|
+
expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('a "pending" (never-started) thread with no live controller is also swept by reconcile', async () => {
|
|
264
|
+
// Not covered by "streaming" scenarios above: reconcile's orphan check is
|
|
265
|
+
// `status === 'pending' || status === 'streaming'`, so a thread that was created but never
|
|
266
|
+
// started is reconciled the same way on the very next mount.
|
|
267
|
+
const now = Date.now()
|
|
268
|
+
const pendingThread: AgentThread<AgentPart> = {
|
|
269
|
+
id: crypto.randomUUID(),
|
|
270
|
+
messages: [],
|
|
271
|
+
outcome: null,
|
|
272
|
+
status: 'pending',
|
|
273
|
+
read: false,
|
|
274
|
+
createdAt: now,
|
|
275
|
+
updatedAt: now,
|
|
276
|
+
}
|
|
277
|
+
const store = createTestThreadsStore([pendingThread])
|
|
278
|
+
|
|
279
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
280
|
+
async *stream() {},
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
284
|
+
|
|
285
|
+
await waitFor(() => {
|
|
286
|
+
expect(store.threads.find((t) => t.id === pendingThread.id)?.status).toBe('interrupted')
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
})
|