basalt-ui 1.10.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 -2
- package/CHANGELOG.md +10 -0
- 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 +75 -23
- package/dist/agent-chat/index.d.ts +2 -0
- 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/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 +2 -2
- package/dist/surfaces.js +2 -2
- package/llms.txt +4 -4
- package/package.json +1 -1
- package/src/agent/ai-sdk-transport.test.ts +338 -40
- 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 +401 -59
- package/src/agent/parts.ts +262 -32
- package/src/agent/transport.test.ts +70 -0
- package/src/agent/transport.ts +72 -20
- package/src/agent/use-agent-stream.test.tsx +47 -17
- 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 +34 -6
- package/src/agent/use-agent-thread-runs.test.tsx +397 -14
- package/src/agent/use-agent-thread-runs.ts +170 -26
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +4 -2
- package/src/agent-chat/index.ts +4 -0
- 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/guard/check-source.test.ts +8 -4
- package/src/guard/index.ts +1 -3
- package/src/index.ts +2 -0
- package/src/surfaces.ts +2 -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())
|
|
@@ -315,6 +423,7 @@ export function useAgentThreadRuns<TPart = AgentPart>({
|
|
|
315
423
|
() => () => {
|
|
316
424
|
controllersRef.current.forEach((controller) => controller.abort())
|
|
317
425
|
controllersRef.current.clear()
|
|
426
|
+
appendedRef.current.clear()
|
|
318
427
|
},
|
|
319
428
|
[],
|
|
320
429
|
)
|
|
@@ -340,8 +449,10 @@ export function useAgentThreadRuns<TPart = AgentPart>({
|
|
|
340
449
|
const lastUserMessage = thread.messages.filter((message) => message.role === 'user').at(-1)
|
|
341
450
|
const resolvedTransport = resolveTransport(thread.id)
|
|
342
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).
|
|
343
454
|
if (
|
|
344
|
-
resolvedTransport
|
|
455
|
+
!isResumable(resolvedTransport) ||
|
|
345
456
|
resumeToken === undefined ||
|
|
346
457
|
lastUserMessage === undefined
|
|
347
458
|
) {
|
|
@@ -361,9 +472,17 @@ export function useAgentThreadRuns<TPart = AgentPart>({
|
|
|
361
472
|
void consumeAndFinalize({
|
|
362
473
|
threadId: thread.id,
|
|
363
474
|
controller,
|
|
364
|
-
|
|
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)),
|
|
365
483
|
userMessage: lastUserMessage,
|
|
366
484
|
controllersRef,
|
|
485
|
+
appendedRef,
|
|
367
486
|
storeRef,
|
|
368
487
|
resolveOutcome,
|
|
369
488
|
setRuns,
|
|
@@ -403,9 +522,11 @@ export function useAgentThreadRuns<TPart = AgentPart>({
|
|
|
403
522
|
void consumeAndFinalize({
|
|
404
523
|
threadId,
|
|
405
524
|
controller,
|
|
406
|
-
|
|
525
|
+
// Namespaced by threadId — same choice, same caveat, as the mount-time resume call above.
|
|
526
|
+
generator: withPartIds(threadId, resolvedTransport.stream(input, controller.signal)),
|
|
407
527
|
userMessage,
|
|
408
528
|
controllersRef,
|
|
529
|
+
appendedRef,
|
|
409
530
|
storeRef,
|
|
410
531
|
resolveOutcome,
|
|
411
532
|
setRuns,
|
|
@@ -424,23 +545,46 @@ export function useAgentThreadRuns<TPart = AgentPart>({
|
|
|
424
545
|
[start],
|
|
425
546
|
)
|
|
426
547
|
|
|
427
|
-
const stop = useCallback(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
+
)
|
|
438
582
|
|
|
439
583
|
const stopAll = useCallback((): void => {
|
|
440
|
-
controllersRef.current.
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}, [])
|
|
584
|
+
for (const threadId of Array.from(controllersRef.current.keys())) {
|
|
585
|
+
stop(threadId)
|
|
586
|
+
}
|
|
587
|
+
}, [stop])
|
|
444
588
|
|
|
445
589
|
return { runs, start, retry, stop, stopAll }
|
|
446
590
|
}
|
|
@@ -210,12 +210,13 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', ()
|
|
|
210
210
|
const store = createTestThreadsStore([thread])
|
|
211
211
|
|
|
212
212
|
let resumeCalls = 0
|
|
213
|
-
const transport
|
|
213
|
+
const transport = {
|
|
214
214
|
async *stream() {},
|
|
215
215
|
async *resume() {
|
|
216
216
|
resumeCalls++
|
|
217
217
|
yield { type: 'text', text: 'resumed' }
|
|
218
218
|
},
|
|
219
|
+
idempotentReplay: true as const,
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }), {
|
|
@@ -250,13 +251,14 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', ()
|
|
|
250
251
|
const gate = deferred<void>()
|
|
251
252
|
|
|
252
253
|
let resumeCalls = 0
|
|
253
|
-
const transport
|
|
254
|
+
const transport = {
|
|
254
255
|
async *stream() {},
|
|
255
256
|
async *resume() {
|
|
256
257
|
resumeCalls++
|
|
257
258
|
await gate.promise
|
|
258
259
|
yield { type: 'text', text: 'resumed' }
|
|
259
260
|
},
|
|
261
|
+
idempotentReplay: true as const,
|
|
260
262
|
}
|
|
261
263
|
|
|
262
264
|
let externalSetMode: ((mode: 'visible' | 'hidden') => void) | undefined
|
package/src/agent-chat/index.ts
CHANGED
|
@@ -34,3 +34,7 @@ export type { ComposerProps } from './composer'
|
|
|
34
34
|
// ── threadPartRenderers + ThreadTranscript ────────────────────────────────────
|
|
35
35
|
export { threadPartRenderers, ThreadTranscript } from './thread-message'
|
|
36
36
|
export type { ThreadTranscriptProps } from './thread-message'
|
|
37
|
+
|
|
38
|
+
// ── ToolChip ──────────────────────────────────────────────────────────────────
|
|
39
|
+
export { ToolChip } from './tool-chip'
|
|
40
|
+
export type { ToolChipProps } from './tool-chip'
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThreadTranscript — the open part-renderer registry's resolution order, the dev/prod fallback
|
|
3
|
+
* contract, and the `MessageBlock` memoization budget from AGENT-CHAT-SPEC.md §9.
|
|
4
|
+
*
|
|
5
|
+
* `messageBlockRenderCounter` is imported directly from `./thread-message` (bypassing the
|
|
6
|
+
* `agent-chat` barrel on purpose — it is not part of the public surface) to prove the memo
|
|
7
|
+
* boundary in a controlled render-count harness.
|
|
8
|
+
*/
|
|
9
|
+
import { MantineProvider } from '@mantine/core'
|
|
10
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
11
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
12
|
+
import { definePartRenderers } from '../agent'
|
|
13
|
+
import type { ChatMessage, TranscriptPart } from '../agent'
|
|
14
|
+
import { messageBlockRenderCounter, ThreadTranscript } from './thread-message'
|
|
15
|
+
|
|
16
|
+
afterEach(cleanup)
|
|
17
|
+
|
|
18
|
+
function withNodeEnv<T>(value: string, fn: () => T): T {
|
|
19
|
+
const original = process.env['NODE_ENV']
|
|
20
|
+
process.env['NODE_ENV'] = value
|
|
21
|
+
try {
|
|
22
|
+
return fn()
|
|
23
|
+
} finally {
|
|
24
|
+
process.env['NODE_ENV'] = original
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildMessages(count: number): ChatMessage<TranscriptPart>[] {
|
|
29
|
+
return Array.from({ length: count }, (_, i) => {
|
|
30
|
+
const role: ChatMessage<TranscriptPart>['role'] = i % 2 === 0 ? 'user' : 'assistant'
|
|
31
|
+
return {
|
|
32
|
+
id: `m${i}`,
|
|
33
|
+
role,
|
|
34
|
+
parts: [{ id: `m${i}-p1`, type: 'text', text: `message ${i}` }],
|
|
35
|
+
createdAt: i,
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('resolution order', () => {
|
|
41
|
+
test('a registered consumer renderer wins over the built-in union', () => {
|
|
42
|
+
const renderers = definePartRenderers({
|
|
43
|
+
'data-chart': () => <div data-testid="chart" />,
|
|
44
|
+
})
|
|
45
|
+
const messages: ChatMessage<TranscriptPart>[] = [
|
|
46
|
+
{
|
|
47
|
+
id: 'm1',
|
|
48
|
+
role: 'assistant',
|
|
49
|
+
parts: [{ id: 'p1', type: 'data-chart', spec: {} }],
|
|
50
|
+
createdAt: 0,
|
|
51
|
+
},
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
render(
|
|
55
|
+
<MantineProvider>
|
|
56
|
+
<ThreadTranscript messages={messages} renderers={renderers} />
|
|
57
|
+
</MantineProvider>,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
expect(screen.getByTestId('chart')).toBeDefined()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('a built-in AgentPart type still routes through PartList when unclaimed by renderers', () => {
|
|
64
|
+
const messages: ChatMessage<TranscriptPart>[] = [
|
|
65
|
+
{
|
|
66
|
+
id: 'm1',
|
|
67
|
+
role: 'assistant',
|
|
68
|
+
parts: [{ id: 'p1', type: 'text', text: 'hello' }],
|
|
69
|
+
createdAt: 0,
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
render(
|
|
74
|
+
<MantineProvider>
|
|
75
|
+
<ThreadTranscript messages={messages} />
|
|
76
|
+
</MantineProvider>,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
expect(screen.getByText('hello')).toBeDefined()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('unknown-part fallback — never throws, dev-visible, prod-silent', () => {
|
|
84
|
+
const messages: ChatMessage<TranscriptPart>[] = [
|
|
85
|
+
{
|
|
86
|
+
id: 'm1',
|
|
87
|
+
role: 'assistant',
|
|
88
|
+
parts: [{ id: 'p1', type: 'data-chart', spec: {} }],
|
|
89
|
+
createdAt: 0,
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
test('renders a visible UnknownPartChip outside production', () => {
|
|
94
|
+
withNodeEnv('development', () => {
|
|
95
|
+
expect(() =>
|
|
96
|
+
render(
|
|
97
|
+
<MantineProvider>
|
|
98
|
+
<ThreadTranscript messages={messages} />
|
|
99
|
+
</MantineProvider>,
|
|
100
|
+
),
|
|
101
|
+
).not.toThrow()
|
|
102
|
+
expect(screen.getByText(/Unknown part: data-chart/)).toBeDefined()
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('renders nothing in production, and still never throws', () => {
|
|
107
|
+
withNodeEnv('production', () => {
|
|
108
|
+
expect(() =>
|
|
109
|
+
render(
|
|
110
|
+
<MantineProvider>
|
|
111
|
+
<ThreadTranscript messages={messages} />
|
|
112
|
+
</MantineProvider>,
|
|
113
|
+
),
|
|
114
|
+
).not.toThrow()
|
|
115
|
+
expect(screen.queryByText(/Unknown part/)).toBeNull()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
function messageWithFinish(
|
|
121
|
+
finish: ChatMessage<TranscriptPart>['finish'],
|
|
122
|
+
): ChatMessage<TranscriptPart>[] {
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
125
|
+
id: 'm1',
|
|
126
|
+
role: 'assistant',
|
|
127
|
+
parts: [{ id: 'p1', type: 'text', text: 'hello' }],
|
|
128
|
+
createdAt: 0,
|
|
129
|
+
...(finish === undefined ? {} : { finish }),
|
|
130
|
+
},
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
describe('finish indicator (AGENT-CHAT-SPEC.md §10)', () => {
|
|
135
|
+
test('finish: stopped renders an indicator with accessible text', () => {
|
|
136
|
+
render(
|
|
137
|
+
<MantineProvider>
|
|
138
|
+
<ThreadTranscript messages={messageWithFinish('stopped')} />
|
|
139
|
+
</MantineProvider>,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
expect(screen.getByText('Stopped')).toBeDefined()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('finish: error renders an indicator with accessible text', () => {
|
|
146
|
+
render(
|
|
147
|
+
<MantineProvider>
|
|
148
|
+
<ThreadTranscript messages={messageWithFinish('error')} />
|
|
149
|
+
</MantineProvider>,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
expect(screen.getByText('Error')).toBeDefined()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('finish: complete renders no indicator', () => {
|
|
156
|
+
render(
|
|
157
|
+
<MantineProvider>
|
|
158
|
+
<ThreadTranscript messages={messageWithFinish('complete')} />
|
|
159
|
+
</MantineProvider>,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
expect(screen.queryByText('Stopped')).toBeNull()
|
|
163
|
+
expect(screen.queryByText('Error')).toBeNull()
|
|
164
|
+
expect(screen.queryByText('Complete')).toBeNull()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test("finish absent renders no indicator, identical to today's output", () => {
|
|
168
|
+
render(
|
|
169
|
+
<MantineProvider>
|
|
170
|
+
<ThreadTranscript messages={messageWithFinish(undefined)} />
|
|
171
|
+
</MantineProvider>,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
expect(screen.getByText('hello')).toBeDefined()
|
|
175
|
+
expect(screen.queryByText('Stopped')).toBeNull()
|
|
176
|
+
expect(screen.queryByText('Error')).toBeNull()
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('MessageBlock memoization (AGENT-CHAT-SPEC.md §9)', () => {
|
|
181
|
+
test('a streamed delta re-renders exactly one MessageBlock on a 50-message thread', () => {
|
|
182
|
+
const messages = buildMessages(50)
|
|
183
|
+
const { rerender } = render(
|
|
184
|
+
<MantineProvider>
|
|
185
|
+
<ThreadTranscript
|
|
186
|
+
messages={messages}
|
|
187
|
+
liveParts={[{ id: 'live', type: 'text', text: 'a' }]}
|
|
188
|
+
liveStatus="streaming"
|
|
189
|
+
/>
|
|
190
|
+
</MantineProvider>,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
// Mount executes every MessageBlock once (nothing to bail out against yet) — that is not the
|
|
194
|
+
// measurement. Reset the counter once the initial render has settled.
|
|
195
|
+
messageBlockRenderCounter.count = 0
|
|
196
|
+
|
|
197
|
+
// Same `messages` array reference (unchanged settled history) + a NEW `liveParts` array
|
|
198
|
+
// reference (the streamed delta) — the one prop a delta actually changes.
|
|
199
|
+
rerender(
|
|
200
|
+
<MantineProvider>
|
|
201
|
+
<ThreadTranscript
|
|
202
|
+
messages={messages}
|
|
203
|
+
liveParts={[{ id: 'live', type: 'text', text: 'ab' }]}
|
|
204
|
+
liveStatus="streaming"
|
|
205
|
+
/>
|
|
206
|
+
</MantineProvider>,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
expect(messageBlockRenderCounter.count).toBe(1)
|
|
210
|
+
})
|
|
211
|
+
})
|