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
|
@@ -40,10 +40,14 @@
|
|
|
40
40
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
41
41
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
|
|
42
42
|
import type { ChatMessage } from './history'
|
|
43
|
+
import { withPartIds } from './id'
|
|
44
|
+
import { mergePart } from './merge'
|
|
45
|
+
import type { PartLike } from './merge'
|
|
43
46
|
import type { AgentOutcome, OutcomeResolver } from './outcome'
|
|
44
47
|
import { isStartPart } from './parts'
|
|
45
48
|
import type { AgentPart } from './parts'
|
|
46
49
|
import type { AgentThread, ThreadStatus, ThreadsStore } from './thread'
|
|
50
|
+
import { isResumable } from './transport'
|
|
47
51
|
import type { AgentTransport } from './transport'
|
|
48
52
|
|
|
49
53
|
// ── ThreadRunState ────────────────────────────────────────────────────────────
|
|
@@ -106,9 +110,14 @@ export type UseAgentThreadRunsReturn<TPart = AgentPart> = {
|
|
|
106
110
|
* `threadId` has never had a turn started, or already has one in flight.
|
|
107
111
|
*/
|
|
108
112
|
readonly retry: (threadId: string) => void
|
|
109
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Abort the in-flight stream for `threadId` (no-op if idle). Preserves whatever content had
|
|
115
|
+
* already arrived: if the run had accumulated any parts, they're persisted as an assistant
|
|
116
|
+
* ChatMessage with `finish: 'stopped'` (unless that message already landed — see finalizeStop),
|
|
117
|
+
* then distilled into an outcome and the thread is settled to 'done'.
|
|
118
|
+
*/
|
|
110
119
|
readonly stop: (threadId: string) => void
|
|
111
|
-
/** Abort every in-flight stream across all threads. */
|
|
120
|
+
/** Abort every in-flight stream across all threads, settling each the same way stop() would. */
|
|
112
121
|
readonly stopAll: () => void
|
|
113
122
|
}
|
|
114
123
|
|
|
@@ -116,7 +125,7 @@ export type UseAgentThreadRunsReturn<TPart = AgentPart> = {
|
|
|
116
125
|
|
|
117
126
|
/** Default toUserParts: wraps raw input in a single text part. */
|
|
118
127
|
function defaultToUserParts(input: string): AgentPart[] {
|
|
119
|
-
return [{ type: 'text', text: input }]
|
|
128
|
+
return [{ id: crypto.randomUUID(), type: 'text', text: input }]
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
/**
|
|
@@ -161,12 +170,13 @@ function buildDoneSnapshot<TPart>(
|
|
|
161
170
|
* the resume path passes 'interrupted' (a failed resume falls back to the same terminal state a
|
|
162
171
|
* non-resumable orphaned thread already lands in).
|
|
163
172
|
*/
|
|
164
|
-
async function consumeAndFinalize<TPart>(args: {
|
|
173
|
+
async function consumeAndFinalize<TPart extends PartLike>(args: {
|
|
165
174
|
threadId: string
|
|
166
175
|
controller: AbortController
|
|
167
176
|
generator: AsyncGenerator<TPart>
|
|
168
177
|
userMessage: ChatMessage<TPart>
|
|
169
178
|
controllersRef: MutableRefObject<Map<string, AbortController>>
|
|
179
|
+
appendedRef: MutableRefObject<Map<string, AbortController>>
|
|
170
180
|
storeRef: MutableRefObject<ThreadsStore<TPart>>
|
|
171
181
|
resolveOutcome: OutcomeResolver<TPart>
|
|
172
182
|
setRuns: Dispatch<SetStateAction<Map<string, ThreadRunState<TPart>>>>
|
|
@@ -178,12 +188,13 @@ async function consumeAndFinalize<TPart>(args: {
|
|
|
178
188
|
generator,
|
|
179
189
|
userMessage,
|
|
180
190
|
controllersRef,
|
|
191
|
+
appendedRef,
|
|
181
192
|
storeRef,
|
|
182
193
|
resolveOutcome,
|
|
183
194
|
setRuns,
|
|
184
195
|
onFailureStatus,
|
|
185
196
|
} = args
|
|
186
|
-
|
|
197
|
+
let parts: TPart[] = []
|
|
187
198
|
try {
|
|
188
199
|
for await (const part of generator) {
|
|
189
200
|
// Guard: a newer call superseded this stream's controller for this thread, or it
|
|
@@ -194,10 +205,13 @@ async function consumeAndFinalize<TPart>(args: {
|
|
|
194
205
|
storeRef.current.setResumeToken(threadId, part.resumeToken)
|
|
195
206
|
continue
|
|
196
207
|
}
|
|
197
|
-
parts
|
|
208
|
+
// mergePart REWRITES an existing id in place (never mutates `parts` — reassigns to the new
|
|
209
|
+
// array it returns), so the local accumulator and the rendered `runs` snapshot below stay
|
|
210
|
+
// identical: a replayed/resumed delta rewrites its part instead of duplicating it.
|
|
211
|
+
parts = mergePart(parts, part)
|
|
198
212
|
setRuns((prev) => {
|
|
199
213
|
const next = new Map(prev)
|
|
200
|
-
next.set(threadId, { status: 'streaming', parts
|
|
214
|
+
next.set(threadId, { status: 'streaming', parts })
|
|
201
215
|
return next
|
|
202
216
|
})
|
|
203
217
|
}
|
|
@@ -211,17 +225,33 @@ async function consumeAndFinalize<TPart>(args: {
|
|
|
211
225
|
role: 'assistant',
|
|
212
226
|
parts,
|
|
213
227
|
createdAt: Date.now(),
|
|
228
|
+
finish: 'complete',
|
|
214
229
|
}
|
|
215
230
|
storeRef.current.appendMessage(threadId, assistantMessage)
|
|
231
|
+
// Record the terminal-append fact for THIS run (identified by `controller`, mirroring
|
|
232
|
+
// controllersRef's own per-thread/per-run keying) — see appendedRef's doc on why this, and not
|
|
233
|
+
// array-reference equality, is what finalizeStop checks.
|
|
234
|
+
appendedRef.current.set(threadId, controller)
|
|
216
235
|
|
|
217
236
|
const priorThread = storeRef.current.threads.find((thread) => thread.id === threadId)
|
|
218
237
|
const snapshot = buildDoneSnapshot(priorThread, threadId, userMessage, assistantMessage)
|
|
219
238
|
const outcome: AgentOutcome = await resolveOutcome(snapshot)
|
|
220
239
|
|
|
240
|
+
// Re-check: resolveOutcome above is the only await in this function's success path, and
|
|
241
|
+
// stop() may have run while it was pending — aborting the controller, appending its own
|
|
242
|
+
// 'stopped' message only if appendedRef didn't already carry this run's marker (see
|
|
243
|
+
// finalizeStop), and already forcing the thread to 'done'. Without this recheck, a slow
|
|
244
|
+
// resolveOutcome settling AFTER stop() has already finalized the thread would silently
|
|
245
|
+
// overwrite that outcome with whatever THIS call resolved to.
|
|
246
|
+
if (controllersRef.current.get(threadId) !== controller || controller.signal.aborted) {
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
221
250
|
storeRef.current.setOutcome(threadId, outcome)
|
|
222
251
|
storeRef.current.setStatus(threadId, outcome.status)
|
|
223
252
|
storeRef.current.setResumeToken(threadId, undefined)
|
|
224
253
|
controllersRef.current.delete(threadId)
|
|
254
|
+
appendedRef.current.delete(threadId)
|
|
225
255
|
setRuns((prev) => {
|
|
226
256
|
if (!prev.has(threadId)) return prev
|
|
227
257
|
const next = new Map(prev)
|
|
@@ -237,6 +267,7 @@ async function consumeAndFinalize<TPart>(args: {
|
|
|
237
267
|
if (controller.signal.aborted) return
|
|
238
268
|
storeRef.current.setStatus(threadId, onFailureStatus)
|
|
239
269
|
controllersRef.current.delete(threadId)
|
|
270
|
+
appendedRef.current.delete(threadId)
|
|
240
271
|
setRuns((prev) => {
|
|
241
272
|
if (!prev.has(threadId)) return prev
|
|
242
273
|
const next = new Map(prev)
|
|
@@ -246,6 +277,56 @@ async function consumeAndFinalize<TPart>(args: {
|
|
|
246
277
|
}
|
|
247
278
|
}
|
|
248
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Finalizes a stop() call for `threadId` — the counterpart to consumeAndFinalize's success path,
|
|
282
|
+
* reused so the persist/settle/resolve sequence exists exactly once. `parts` is the accumulated
|
|
283
|
+
* (possibly partial) content read out of the run's `ThreadRunState` at the moment stop() was
|
|
284
|
+
* called; the caller has already aborted the controller and cleared `controllersRef` before
|
|
285
|
+
* invoking this. `alreadyAppended` is precomputed by the caller (stop()) from `appendedRef` — see
|
|
286
|
+
* that ref's doc for why this is an explicit marker rather than an inferred comparison.
|
|
287
|
+
*
|
|
288
|
+
* Ordering: the append, forcing status to 'done', clearing the resume token, and tearing down the
|
|
289
|
+
* run entry ALL happen synchronously (this function's body runs synchronously up to its one
|
|
290
|
+
* `await`) — status is never derived from the resolved outcome, so it doesn't need to wait on one,
|
|
291
|
+
* matching useAgentStream's stop(), which settles 'done' immediately rather than blocking on async
|
|
292
|
+
* work. Only `setOutcome` — which genuinely needs the resolved value — happens after the await.
|
|
293
|
+
*/
|
|
294
|
+
async function finalizeStop<TPart extends PartLike>(args: {
|
|
295
|
+
threadId: string
|
|
296
|
+
parts: TPart[]
|
|
297
|
+
alreadyAppended: boolean
|
|
298
|
+
storeRef: MutableRefObject<ThreadsStore<TPart>>
|
|
299
|
+
resolveOutcome: OutcomeResolver<TPart>
|
|
300
|
+
setRuns: Dispatch<SetStateAction<Map<string, ThreadRunState<TPart>>>>
|
|
301
|
+
}): Promise<void> {
|
|
302
|
+
const { threadId, parts, alreadyAppended, storeRef, resolveOutcome, setRuns } = args
|
|
303
|
+
|
|
304
|
+
if (!alreadyAppended && parts.length > 0) {
|
|
305
|
+
const stoppedMessage: ChatMessage<TPart> = {
|
|
306
|
+
id: crypto.randomUUID(),
|
|
307
|
+
role: 'assistant',
|
|
308
|
+
parts,
|
|
309
|
+
createdAt: Date.now(),
|
|
310
|
+
finish: 'stopped',
|
|
311
|
+
}
|
|
312
|
+
storeRef.current.appendMessage(threadId, stoppedMessage)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
storeRef.current.setStatus(threadId, 'done')
|
|
316
|
+
storeRef.current.setResumeToken(threadId, undefined)
|
|
317
|
+
setRuns((prev) => {
|
|
318
|
+
if (!prev.has(threadId)) return prev
|
|
319
|
+
const next = new Map(prev)
|
|
320
|
+
next.delete(threadId)
|
|
321
|
+
return next
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
const snapshot = storeRef.current.threads.find((thread) => thread.id === threadId)
|
|
325
|
+
if (snapshot === undefined) return
|
|
326
|
+
const outcome: AgentOutcome = await resolveOutcome(snapshot)
|
|
327
|
+
storeRef.current.setOutcome(threadId, outcome)
|
|
328
|
+
}
|
|
329
|
+
|
|
249
330
|
// ── useAgentThreadRuns ────────────────────────────────────────────────────────
|
|
250
331
|
|
|
251
332
|
/**
|
|
@@ -259,16 +340,43 @@ async function consumeAndFinalize<TPart>(args: {
|
|
|
259
340
|
* start(threadB, 'What changed in the last release?')
|
|
260
341
|
* // both stream concurrently; runs.get(threadA) / runs.get(threadB) update independently
|
|
261
342
|
*/
|
|
262
|
-
export function useAgentThreadRuns<TPart = AgentPart>({
|
|
343
|
+
export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
|
|
263
344
|
transport,
|
|
264
345
|
store,
|
|
265
346
|
resolveOutcome,
|
|
266
347
|
toUserParts,
|
|
267
348
|
}: UseAgentThreadRunsArgs<TPart>): UseAgentThreadRunsReturn<TPart> {
|
|
268
349
|
const [runs, setRuns] = useState<Map<string, ThreadRunState<TPart>>>(new Map())
|
|
350
|
+
// Mirrors the latest `runs` state every render so stop() (a plain callback, not a render) can
|
|
351
|
+
// synchronously read a thread's accumulated parts without going through an async setState
|
|
352
|
+
// updater — same mirroring pattern as storeRef/transportRef below.
|
|
353
|
+
const runsRef = useRef(runs)
|
|
354
|
+
runsRef.current = runs
|
|
269
355
|
|
|
270
356
|
// Refs: mutable per-render state that must not trigger re-renders.
|
|
271
357
|
const controllersRef = useRef<Map<string, AbortController>>(new Map())
|
|
358
|
+
// Explicit single-writer marker: has THIS run's terminal assistant message already been
|
|
359
|
+
// appended? Keyed the same way as controllersRef (threadId -> the specific run's controller),
|
|
360
|
+
// set by consumeAndFinalize the instant it appends, and cleared on every path that ends a run
|
|
361
|
+
// (normal completion, the error path, stop(), and unmount) — same lifecycle discipline as
|
|
362
|
+
// controllersRef, deliberately, since that's the existing coordination channel between
|
|
363
|
+
// consumeAndFinalize and stop()/finalizeStop.
|
|
364
|
+
//
|
|
365
|
+
// This replaces an earlier reference-equality check (`lastMessage.parts === parts`) that
|
|
366
|
+
// compared the array runsRef mirrors against the array the message actually persisted. That
|
|
367
|
+
// comparison implicitly depended on THREE things staying true at once: the run-state mirror not
|
|
368
|
+
// re-copying `parts` on every delta, the accumulator reassigning (never mutating) `parts`, and —
|
|
369
|
+
// the one that actually broke it — React's flush timing: runsRef is only updated during a
|
|
370
|
+
// render (`runsRef.current = runs`, assigned unconditionally on every render, not in an effect),
|
|
371
|
+
// so a setRuns dispatch that hasn't yet flushed into a render leaves runsRef holding an EARLIER
|
|
372
|
+
// parts array. A stop() landing between consumeAndFinalize's post-loop appendMessage and its
|
|
373
|
+
// `await resolveOutcome` resolving could observe exactly that staleness and defeat the
|
|
374
|
+
// reference-equality guard, double-appending — see the regression test with "resolveOutcome"
|
|
375
|
+
// in its name. Keying by controller identity (an AbortController, freshly minted per run) makes
|
|
376
|
+
// a false-positive match across two different runs on the same thread structurally
|
|
377
|
+
// impossible — no render, no array-copy semantics, no accumulator-reassignment assumption is
|
|
378
|
+
// load-bearing anymore.
|
|
379
|
+
const appendedRef = useRef<Map<string, AbortController>>(new Map())
|
|
272
380
|
// Caches the last user input per thread so retry() can replay a failed turn without the
|
|
273
381
|
// caller having to hold onto (or re-collect) what was typed.
|
|
274
382
|
const lastInputRef = useRef<Map<string, string>>(new Map())
|
|
@@ -305,10 +413,17 @@ export function useAgentThreadRuns<TPart = AgentPart>({
|
|
|
305
413
|
return resolved
|
|
306
414
|
}
|
|
307
415
|
|
|
308
|
-
// Abort every in-flight stream on unmount to stop the async generators.
|
|
416
|
+
// Abort every in-flight stream on unmount to stop the async generators. Also clears the map —
|
|
417
|
+
// leaving aborted entries behind would make the mount-reconcile effect below see a
|
|
418
|
+
// `controllersRef.current.has(id)` of `true` for a thread whose only consumer was just aborted,
|
|
419
|
+
// skipping the orphan-resume path and wedging the thread in 'streaming' forever. Reachable
|
|
420
|
+
// whenever this fiber's effects re-run without the fiber itself unmounting (React 19 StrictMode
|
|
421
|
+
// double-invoke; `<Activity>` hide/show) — see this hook's `@example`-adjacent doc / the F3 note.
|
|
309
422
|
useEffect(
|
|
310
423
|
() => () => {
|
|
311
424
|
controllersRef.current.forEach((controller) => controller.abort())
|
|
425
|
+
controllersRef.current.clear()
|
|
426
|
+
appendedRef.current.clear()
|
|
312
427
|
},
|
|
313
428
|
[],
|
|
314
429
|
)
|
|
@@ -334,8 +449,10 @@ export function useAgentThreadRuns<TPart = AgentPart>({
|
|
|
334
449
|
const lastUserMessage = thread.messages.filter((message) => message.role === 'user').at(-1)
|
|
335
450
|
const resolvedTransport = resolveTransport(thread.id)
|
|
336
451
|
|
|
452
|
+
// isResumable requires BOTH `resume` and the literal `idempotentReplay: true` assertion —
|
|
453
|
+
// a transport with `resume` alone is not enough (see ResumableAgentTransport's doc).
|
|
337
454
|
if (
|
|
338
|
-
resolvedTransport
|
|
455
|
+
!isResumable(resolvedTransport) ||
|
|
339
456
|
resumeToken === undefined ||
|
|
340
457
|
lastUserMessage === undefined
|
|
341
458
|
) {
|
|
@@ -355,9 +472,17 @@ export function useAgentThreadRuns<TPart = AgentPart>({
|
|
|
355
472
|
void consumeAndFinalize({
|
|
356
473
|
threadId: thread.id,
|
|
357
474
|
controller,
|
|
358
|
-
|
|
475
|
+
// Namespaced by thread.id (stable across a thread's whole lifetime, including this
|
|
476
|
+
// resume) rather than per-call — see withPartIds' own doc for why this is NOT a replay-
|
|
477
|
+
// convergence guarantee on its own: it only protects against two id-less drafts within
|
|
478
|
+
// ONE generator colliding (the F1 bug). A transport whose resume() needs genuine replay
|
|
479
|
+
// safety must assert `idempotentReplay: true` AND mint its own content-stable ids (as
|
|
480
|
+
// aiSdkTransport does) — withPartIds is then a no-op passthrough for it, not the mechanism
|
|
481
|
+
// providing that safety.
|
|
482
|
+
generator: withPartIds(thread.id, resume(resumeToken, controller.signal)),
|
|
359
483
|
userMessage: lastUserMessage,
|
|
360
484
|
controllersRef,
|
|
485
|
+
appendedRef,
|
|
361
486
|
storeRef,
|
|
362
487
|
resolveOutcome,
|
|
363
488
|
setRuns,
|
|
@@ -397,9 +522,11 @@ export function useAgentThreadRuns<TPart = AgentPart>({
|
|
|
397
522
|
void consumeAndFinalize({
|
|
398
523
|
threadId,
|
|
399
524
|
controller,
|
|
400
|
-
|
|
525
|
+
// Namespaced by threadId — same choice, same caveat, as the mount-time resume call above.
|
|
526
|
+
generator: withPartIds(threadId, resolvedTransport.stream(input, controller.signal)),
|
|
401
527
|
userMessage,
|
|
402
528
|
controllersRef,
|
|
529
|
+
appendedRef,
|
|
403
530
|
storeRef,
|
|
404
531
|
resolveOutcome,
|
|
405
532
|
setRuns,
|
|
@@ -418,23 +545,46 @@ export function useAgentThreadRuns<TPart = AgentPart>({
|
|
|
418
545
|
[start],
|
|
419
546
|
)
|
|
420
547
|
|
|
421
|
-
const stop = useCallback(
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
548
|
+
const stop = useCallback(
|
|
549
|
+
(threadId: string): void => {
|
|
550
|
+
const controller = controllersRef.current.get(threadId)
|
|
551
|
+
// No in-flight run for this thread — true no-op, matching this hook's stop() JSDoc. Decided
|
|
552
|
+
// from controllersRef, NOT runsRef: controllersRef is written synchronously by start() (line
|
|
553
|
+
// ~509, before consumeAndFinalize is even invoked), while runsRef only mirrors `runs` as of
|
|
554
|
+
// the last FLUSHED RENDER (`runsRef.current = runs`, assigned during render, above). A
|
|
555
|
+
// start() immediately followed by stop() in the same synchronous tick/act() batch — e.g.
|
|
556
|
+
// `onClick={() => { start(id, text); stop(id) }}` — has controllersRef already populated but
|
|
557
|
+
// runsRef still holding the PREVIOUS render's (empty) map. Gating on runsRef here would read
|
|
558
|
+
// `undefined`, wrongly treat this as a no-op, leave the controller registered and never
|
|
559
|
+
// abort it — stranding the run in 'streaming' (see the F4 regression test).
|
|
560
|
+
if (controller === undefined) return
|
|
561
|
+
|
|
562
|
+
controllersRef.current.delete(threadId)
|
|
563
|
+
// Read the accumulated parts straight out of the live run entry (runsRef mirrors `runs` as of
|
|
564
|
+
// the last flushed render) — falls back to [] for the same synchronous start()-then-stop()
|
|
565
|
+
// case above, where no render has flushed yet; finalizeStop already treats an empty `parts`
|
|
566
|
+
// as "nothing to persist".
|
|
567
|
+
const parts = runsRef.current.get(threadId)?.parts ?? []
|
|
568
|
+
|
|
569
|
+
controller.abort()
|
|
570
|
+
|
|
571
|
+
// Was THIS run's terminal message already appended by consumeAndFinalize's own success path
|
|
572
|
+
// (it can race this call — see appendedRef's doc)? Matched by controller identity, not by
|
|
573
|
+
// comparing `parts` against anything React-observed, so this is correct regardless of
|
|
574
|
+
// whether runsRef has flushed the latest delta yet.
|
|
575
|
+
const alreadyAppended = appendedRef.current.get(threadId) === controller
|
|
576
|
+
appendedRef.current.delete(threadId)
|
|
577
|
+
|
|
578
|
+
void finalizeStop({ threadId, parts, alreadyAppended, storeRef, resolveOutcome, setRuns })
|
|
579
|
+
},
|
|
580
|
+
[resolveOutcome],
|
|
581
|
+
)
|
|
432
582
|
|
|
433
583
|
const stopAll = useCallback((): void => {
|
|
434
|
-
controllersRef.current.
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
}, [])
|
|
584
|
+
for (const threadId of Array.from(controllersRef.current.keys())) {
|
|
585
|
+
stop(threadId)
|
|
586
|
+
}
|
|
587
|
+
}, [stop])
|
|
438
588
|
|
|
439
589
|
return { runs, start, retry, stop, stopAll }
|
|
440
590
|
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F3 — the mount-reconcile / unmount-cleanup wedge in useAgentThreadRuns.
|
|
3
|
+
*
|
|
4
|
+
* The bug: the unmount-cleanup effect (`controllersRef.current.forEach(c => c.abort())`) aborted
|
|
5
|
+
* every in-flight controller but never CLEARED the map. The mount-reconcile effect treats a thread
|
|
6
|
+
* as "orphaned" (worth a resume attempt) only when `!controllersRef.current.has(thread.id)`. If
|
|
7
|
+
* this fiber's effects re-run WITHOUT the fiber itself unmounting — React 19 StrictMode's
|
|
8
|
+
* dev-only double-invoke, or a `<Activity>` boundary being hidden then shown again — the stale,
|
|
9
|
+
* now-aborted entry survives into the second reconcile pass, `orphaned` reads `false`, no new
|
|
10
|
+
* resume is attempted, and the one live consumer that WAS attempted already returned silently on
|
|
11
|
+
* its AbortError (`consumeAndFinalize`'s guard at the top of its try block). The thread is left in
|
|
12
|
+
* `'streaming'` forever with nothing left to resolve it.
|
|
13
|
+
*
|
|
14
|
+
* Both halves matter equally here:
|
|
15
|
+
*
|
|
16
|
+
* (a) REFUTED — a genuine unmount + remount (a fresh component instance, not a re-run of THIS
|
|
17
|
+
* fiber's effects) gets a brand-new `useRef` Map. There is nothing for the fix to protect
|
|
18
|
+
* against here; the test exists so nobody "fixes" this non-bug later.
|
|
19
|
+
* (b) CONFIRMED — React 19 StrictMode's dev-only double-invoke reuses the SAME fiber (and thus
|
|
20
|
+
* the same ref) across a simulated unmount+remount within one commit.
|
|
21
|
+
* (c) CONFIRMED — React 19.2's `<Activity>` does the SAME thing in PRODUCTION: hiding an
|
|
22
|
+
* Activity boundary destroys the subtree's effects and re-creates them on show, while state,
|
|
23
|
+
* DOM, and ref identity survive. This is the reachable-in-prod half of the wedge, not a
|
|
24
|
+
* StrictMode-only curiosity — hence testing it directly against `<Activity>` from `react`
|
|
25
|
+
* rather than only through Mantine's `Collapse`. At the `@mantine/core` 9.3.0 pinned in THIS
|
|
26
|
+
* repo, `Collapse` has no `keepMounted` DEFAULT — with the prop unset it keeps `children`
|
|
27
|
+
* permanently mounted (the `else` branch below), so a bare `<Collapse>` cannot wedge and a
|
|
28
|
+
* default-Collapse-based test would not reproduce the bug. But a consumer writing
|
|
29
|
+
* `<Collapse keepMounted>` on this SAME 9.3.0 does hit the `<Activity>` path — read
|
|
30
|
+
* the installed `@mantine/core` 9.3.0 `Collapse.mjs` source directly: it imports
|
|
31
|
+
* `Activity` from `react` and, when `keepMounted === true`, renders
|
|
32
|
+
* `jsx(Activity, { mode: isExited ? 'hidden' : 'visible', … })` on the ordinary non-zero-
|
|
33
|
+
* duration path (only the `duration === 0` fast path carries a `&& env !== 'test'` guard). So
|
|
34
|
+
* the Activity hazard is live on THIS pinned version, not only on the 9.4.1 argo resolves —
|
|
35
|
+
* testing `<Activity>` directly is still the right call because it is what a `keepMounted`
|
|
36
|
+
* consumer actually gets here today, not because 9.3.0 is exempt.
|
|
37
|
+
*
|
|
38
|
+
* Verified manually while writing this file (reported alongside the test suite, not re-asserted
|
|
39
|
+
* here as a runtime check): reverting the one-line `controllersRef.current.clear()` fix makes (b)
|
|
40
|
+
* and (c) fail — the thread's status stays `'streaming'` instead of settling — while (a) passes
|
|
41
|
+
* either way, exactly as the "refutation" framing above expects.
|
|
42
|
+
*
|
|
43
|
+
* Both (b) and (c) observe `resume()` invoked TWICE post-fix (once for the pass that gets aborted,
|
|
44
|
+
* once for the pass that survives), not once — React's whole justification for double-invoking a
|
|
45
|
+
* mount effect with a side effect (a network call here) is to surface exactly this
|
|
46
|
+
* non-idempotence; a spec that anticipated "resume() attempted exactly once" for this scenario
|
|
47
|
+
* does not match observed React 19.2.7 behavior. What's invariant, and what these tests assert, is
|
|
48
|
+
* that the thread SETTLES (leaves `'streaming'`) rather than wedging — the resume() call count is
|
|
49
|
+
* asserted too, but as an observed constant with the doubling explained, not as evidence of a
|
|
50
|
+
* single attempt.
|
|
51
|
+
*/
|
|
52
|
+
import { describe, expect, test } from 'bun:test'
|
|
53
|
+
import { Activity, StrictMode, useState } from 'react'
|
|
54
|
+
import { act, render, renderHook, waitFor } from '@testing-library/react'
|
|
55
|
+
import { useAgentThreadRuns } from './use-agent-thread-runs'
|
|
56
|
+
import type { AgentThread, ThreadsStore } from './thread'
|
|
57
|
+
import type { AgentTransport } from './transport'
|
|
58
|
+
import type { AgentPart } from './parts'
|
|
59
|
+
import type { ChatMessage } from './history'
|
|
60
|
+
import type { AgentOutcome } from './outcome'
|
|
61
|
+
|
|
62
|
+
// ── test-only ThreadsStore double ────────────────────────────────────────────
|
|
63
|
+
//
|
|
64
|
+
// A plain mutable object satisfying the ThreadsStore contract — no React state, no
|
|
65
|
+
// localStorage/useSyncExternalStore. useAgentThreadRuns only ever reads `store.threads` and calls
|
|
66
|
+
// its action methods synchronously (via `storeRef.current`), so a hand-rolled mutable double gives
|
|
67
|
+
// the test full, synchronous, non-persisted control over what a "reload mid-stream" looked like —
|
|
68
|
+
// exactly what these mount-reconcile tests need to seed.
|
|
69
|
+
function createTestThreadsStore(initial: AgentThread<AgentPart>[] = []): ThreadsStore<AgentPart> {
|
|
70
|
+
let threads = initial
|
|
71
|
+
let activeId: string | null = null
|
|
72
|
+
return {
|
|
73
|
+
get threads() {
|
|
74
|
+
return threads
|
|
75
|
+
},
|
|
76
|
+
get activeId() {
|
|
77
|
+
return activeId
|
|
78
|
+
},
|
|
79
|
+
select(id) {
|
|
80
|
+
activeId = id
|
|
81
|
+
},
|
|
82
|
+
create(opts) {
|
|
83
|
+
const id = crypto.randomUUID()
|
|
84
|
+
const now = Date.now()
|
|
85
|
+
const thread: AgentThread<AgentPart> = {
|
|
86
|
+
id,
|
|
87
|
+
messages: [],
|
|
88
|
+
outcome: null,
|
|
89
|
+
status: 'pending',
|
|
90
|
+
read: false,
|
|
91
|
+
createdAt: now,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
...(opts?.meta !== undefined ? { meta: opts.meta } : {}),
|
|
94
|
+
}
|
|
95
|
+
threads = [thread, ...threads]
|
|
96
|
+
return id
|
|
97
|
+
},
|
|
98
|
+
appendMessage(id, message) {
|
|
99
|
+
threads = threads.map((thread) =>
|
|
100
|
+
thread.id === id
|
|
101
|
+
? { ...thread, messages: [...thread.messages, message], updatedAt: Date.now() }
|
|
102
|
+
: thread,
|
|
103
|
+
)
|
|
104
|
+
},
|
|
105
|
+
setOutcome(id, outcome) {
|
|
106
|
+
threads = threads.map((thread) =>
|
|
107
|
+
thread.id === id ? { ...thread, outcome, updatedAt: Date.now() } : thread,
|
|
108
|
+
)
|
|
109
|
+
},
|
|
110
|
+
setStatus(id, status) {
|
|
111
|
+
threads = threads.map((thread) =>
|
|
112
|
+
thread.id === id ? { ...thread, status, updatedAt: Date.now() } : thread,
|
|
113
|
+
)
|
|
114
|
+
},
|
|
115
|
+
setResumeToken(id, token) {
|
|
116
|
+
threads = threads.map((thread) => {
|
|
117
|
+
if (thread.id !== id) return thread
|
|
118
|
+
const { resumeToken: _resumeToken, ...rest } = thread
|
|
119
|
+
return {
|
|
120
|
+
...rest,
|
|
121
|
+
...(token !== undefined ? { resumeToken: token } : {}),
|
|
122
|
+
updatedAt: Date.now(),
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
},
|
|
126
|
+
markRead(id) {
|
|
127
|
+
threads = threads.map((thread) => (thread.id === id ? { ...thread, read: true } : thread))
|
|
128
|
+
},
|
|
129
|
+
remove(id) {
|
|
130
|
+
threads = threads.filter((thread) => thread.id !== id)
|
|
131
|
+
if (activeId === id) activeId = null
|
|
132
|
+
},
|
|
133
|
+
clear() {
|
|
134
|
+
threads = []
|
|
135
|
+
activeId = null
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function makeThread(overrides: Partial<AgentThread<AgentPart>> = {}): AgentThread<AgentPart> {
|
|
141
|
+
const now = Date.now()
|
|
142
|
+
return {
|
|
143
|
+
id: crypto.randomUUID(),
|
|
144
|
+
messages: [],
|
|
145
|
+
outcome: null,
|
|
146
|
+
status: 'pending',
|
|
147
|
+
read: false,
|
|
148
|
+
createdAt: now,
|
|
149
|
+
updatedAt: now,
|
|
150
|
+
...overrides,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
|
155
|
+
let resolve!: (value: T) => void
|
|
156
|
+
const promise = new Promise<T>((r) => {
|
|
157
|
+
resolve = r
|
|
158
|
+
})
|
|
159
|
+
return { promise, resolve }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const resolveOutcome = (): AgentOutcome => ({ title: 'title', summary: 'summary', status: 'done' })
|
|
163
|
+
|
|
164
|
+
describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', () => {
|
|
165
|
+
test('(a) REFUTED: a genuine unmount + remount gets a fresh controllers map — resolves to interrupted, never re-enters streaming', () => {
|
|
166
|
+
const store = createTestThreadsStore()
|
|
167
|
+
const threadId = store.create()
|
|
168
|
+
|
|
169
|
+
// No `resume` on this transport: on remount, the orphaned thread must fall straight to
|
|
170
|
+
// 'interrupted' with no async wait involved.
|
|
171
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
172
|
+
// oxlint-disable-next-line require-yield -- hangs until aborted; never has anything to yield
|
|
173
|
+
async *stream(_input, signal) {
|
|
174
|
+
await new Promise((resolve) => {
|
|
175
|
+
signal?.addEventListener('abort', () => resolve(undefined))
|
|
176
|
+
})
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const { result, unmount } = renderHook(() =>
|
|
181
|
+
useAgentThreadRuns({ transport, store, resolveOutcome }),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
act(() => {
|
|
185
|
+
result.current.start(threadId, 'hello')
|
|
186
|
+
})
|
|
187
|
+
expect(store.threads.find((thread) => thread.id === threadId)?.status).toBe('streaming')
|
|
188
|
+
|
|
189
|
+
unmount()
|
|
190
|
+
|
|
191
|
+
// A brand-new component instance against the SAME store object — a fresh fiber, fresh
|
|
192
|
+
// `useRef` Map. This must hold both before AND after the F3 fix.
|
|
193
|
+
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
194
|
+
|
|
195
|
+
expect(store.threads.find((thread) => thread.id === threadId)?.status).toBe('interrupted')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('(b) CONFIRMED: React 19 StrictMode double-invoke resumes the orphaned thread instead of wedging it in streaming', async () => {
|
|
199
|
+
const userMessage: ChatMessage<AgentPart> = {
|
|
200
|
+
id: crypto.randomUUID(),
|
|
201
|
+
role: 'user',
|
|
202
|
+
parts: [{ type: 'text', text: 'hello' }],
|
|
203
|
+
createdAt: Date.now(),
|
|
204
|
+
}
|
|
205
|
+
const thread = makeThread({
|
|
206
|
+
status: 'streaming',
|
|
207
|
+
resumeToken: 'resume-token-1',
|
|
208
|
+
messages: [userMessage],
|
|
209
|
+
})
|
|
210
|
+
const store = createTestThreadsStore([thread])
|
|
211
|
+
|
|
212
|
+
let resumeCalls = 0
|
|
213
|
+
const transport = {
|
|
214
|
+
async *stream() {},
|
|
215
|
+
async *resume() {
|
|
216
|
+
resumeCalls++
|
|
217
|
+
yield { type: 'text', text: 'resumed' }
|
|
218
|
+
},
|
|
219
|
+
idempotentReplay: true as const,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }), {
|
|
223
|
+
wrapper: StrictMode,
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
await waitFor(() => {
|
|
227
|
+
expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('done')
|
|
228
|
+
})
|
|
229
|
+
// Observed constant for this scenario (see file doc): the aborted first pass plus the
|
|
230
|
+
// surviving second pass, not "exactly once".
|
|
231
|
+
expect(resumeCalls).toBe(2)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('(c) CONFIRMED: an <Activity> hide/show cycle resumes the orphaned thread instead of wedging it in streaming', async () => {
|
|
235
|
+
const userMessage: ChatMessage<AgentPart> = {
|
|
236
|
+
id: crypto.randomUUID(),
|
|
237
|
+
role: 'user',
|
|
238
|
+
parts: [{ type: 'text', text: 'hello' }],
|
|
239
|
+
createdAt: Date.now(),
|
|
240
|
+
}
|
|
241
|
+
const thread = makeThread({
|
|
242
|
+
status: 'streaming',
|
|
243
|
+
resumeToken: 'resume-token-2',
|
|
244
|
+
messages: [userMessage],
|
|
245
|
+
})
|
|
246
|
+
const store = createTestThreadsStore([thread])
|
|
247
|
+
|
|
248
|
+
// Gates the first resume() attempt so the hide happens strictly before it settles — an
|
|
249
|
+
// explicit happens-before, not a scheduler coincidence a `setTimeout(…, 0)` would only
|
|
250
|
+
// usually win.
|
|
251
|
+
const gate = deferred<void>()
|
|
252
|
+
|
|
253
|
+
let resumeCalls = 0
|
|
254
|
+
const transport = {
|
|
255
|
+
async *stream() {},
|
|
256
|
+
async *resume() {
|
|
257
|
+
resumeCalls++
|
|
258
|
+
await gate.promise
|
|
259
|
+
yield { type: 'text', text: 'resumed' }
|
|
260
|
+
},
|
|
261
|
+
idempotentReplay: true as const,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let externalSetMode: ((mode: 'visible' | 'hidden') => void) | undefined
|
|
265
|
+
|
|
266
|
+
function Probe(): null {
|
|
267
|
+
useAgentThreadRuns({ transport, store, resolveOutcome })
|
|
268
|
+
return null
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function Harness(): JSX.Element {
|
|
272
|
+
const [mode, setMode] = useState<'visible' | 'hidden'>('visible')
|
|
273
|
+
externalSetMode = setMode
|
|
274
|
+
return (
|
|
275
|
+
<Activity mode={mode}>
|
|
276
|
+
<Probe />
|
|
277
|
+
</Activity>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
await act(async () => {
|
|
282
|
+
render(<Harness />)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// First resume attempt is in flight (its gate hasn't been released yet) — hide now to abort
|
|
286
|
+
// it via the F3-fixed cleanup effect. `gate.resolve()` only runs after this completes, so the
|
|
287
|
+
// hide is guaranteed to land before the first attempt could ever settle.
|
|
288
|
+
await act(async () => {
|
|
289
|
+
externalSetMode?.('hidden')
|
|
290
|
+
})
|
|
291
|
+
gate.resolve()
|
|
292
|
+
await act(async () => {
|
|
293
|
+
externalSetMode?.('visible')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
await waitFor(() => {
|
|
297
|
+
expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('done')
|
|
298
|
+
})
|
|
299
|
+
expect(resumeCalls).toBe(2)
|
|
300
|
+
})
|
|
301
|
+
})
|
package/src/agent-chat/index.ts
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* Mantine-styled thread-chat components over the headless `basalt-ui/agent` layer, capped by the
|
|
3
3
|
* flagship `ThreadWorkspace` composite.
|
|
4
4
|
*
|
|
5
|
-
* This module is Mantine-coupled by design and
|
|
6
|
-
*
|
|
5
|
+
* This module is Mantine-coupled by design and ships its own `basalt-ui/agent-chat` subpath
|
|
6
|
+
* (also re-exported from the root entry, unchanged for existing consumers) — unlike
|
|
7
|
+
* `basalt-ui/agent`, which stays headless so it can be styled by any consumer.
|
|
7
8
|
*
|
|
8
9
|
* @example
|
|
9
|
-
* import { ThreadWorkspace } from 'basalt-ui'
|
|
10
|
+
* import { ThreadWorkspace } from 'basalt-ui/agent-chat'
|
|
10
11
|
* import { createThreadsStore, edenTransport, heuristicOutcome } from 'basalt-ui/agent'
|
|
11
12
|
*/
|
|
12
13
|
|
|
@@ -33,3 +34,7 @@ export type { ComposerProps } from './composer'
|
|
|
33
34
|
// ── threadPartRenderers + ThreadTranscript ────────────────────────────────────
|
|
34
35
|
export { threadPartRenderers, ThreadTranscript } from './thread-message'
|
|
35
36
|
export type { ThreadTranscriptProps } from './thread-message'
|
|
37
|
+
|
|
38
|
+
// ── ToolChip ──────────────────────────────────────────────────────────────────
|
|
39
|
+
export { ToolChip } from './tool-chip'
|
|
40
|
+
export type { ToolChipProps } from './tool-chip'
|