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
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useAgentThreadRuns — concurrency, busy-thread no-op, finalize ordering, failure status, retry
|
|
2
|
+
* useAgentThreadRuns — concurrency, busy-thread no-op, finalize ordering, failure status, retry,
|
|
3
|
+
* and (as of B2) the stop()/stopAll() lifecycle (partial-turn preservation, the idle no-op
|
|
4
|
+
* guard, and the resolveOutcome-race fix).
|
|
3
5
|
*
|
|
4
|
-
* Scope: the multi-thread run manager AS THE PUBLIC API EXISTS TODAY
|
|
6
|
+
* Scope: the multi-thread run manager AS THE PUBLIC API EXISTS TODAY. No mergePart/part ids/
|
|
5
7
|
* offsets, no seven-state ToolCallPart, no ResumableAgentTransport/isResumable/idempotentReplay
|
|
6
|
-
* beyond the plain optional `resume` already on AgentTransport, no
|
|
7
|
-
*
|
|
8
|
+
* beyond the plain optional `resume` already on AgentTransport, no ThreadsStoreAdapter — later
|
|
9
|
+
* releases, not exercised here.
|
|
8
10
|
*
|
|
9
11
|
* The mount-reconcile/resume path (StartPart handling, orphaned-thread resumption on mount) has
|
|
10
12
|
* its own file, `use-agent-thread-runs.resume.test.tsx`; the F3 wedge has its own file,
|
|
@@ -345,15 +347,12 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
|
|
|
345
347
|
expect(store.threads.find((t) => t.id === threadId)?.messages).toHaveLength(4)
|
|
346
348
|
})
|
|
347
349
|
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
// silently overwrites that 'done' with whatever the outcome resolved to. Verified failing against
|
|
355
|
-
// the current implementation before being marked .skip here.
|
|
356
|
-
test.skip('stop() during a slow resolveOutcome must not be clobbered by the outcome settling later', async () => {
|
|
350
|
+
// Pins the fix for a race in consumeAndFinalize's success path: it awaits resolveOutcome and
|
|
351
|
+
// then writes setOutcome/setStatus, and now RE-CHECKS the supersede/abort guards after that
|
|
352
|
+
// await (not just before it) before performing those writes. Without the recheck, a slow
|
|
353
|
+
// resolveOutcome racing a stop() call would clobber stop()'s synchronous 'done' with whatever
|
|
354
|
+
// the outcome resolved to once it finally settled.
|
|
355
|
+
test('stop() during a slow resolveOutcome must not be clobbered by the outcome settling later', async () => {
|
|
357
356
|
const store = createTestThreadsStore()
|
|
358
357
|
const threadId = store.create()
|
|
359
358
|
|
|
@@ -391,8 +390,392 @@ describe('useAgentThreadRuns — concurrency, no-op guard, finalize order, failu
|
|
|
391
390
|
releaseOutcome?.()
|
|
392
391
|
|
|
393
392
|
await waitFor(() => {
|
|
394
|
-
//
|
|
393
|
+
// The late resolveOutcome settling must not resurrect the thread past the 'done' stop()
|
|
394
|
+
// already committed.
|
|
395
395
|
expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
|
|
396
396
|
})
|
|
397
397
|
})
|
|
398
398
|
})
|
|
399
|
+
|
|
400
|
+
describe('useAgentThreadRuns — stop()/stopAll() lifecycle', () => {
|
|
401
|
+
test('stop() mid-stream persists the accumulated parts as a finish: "stopped" assistant message', async () => {
|
|
402
|
+
const store = createTestThreadsStore()
|
|
403
|
+
const threadId = store.create()
|
|
404
|
+
|
|
405
|
+
const gate = deferred<void>()
|
|
406
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
407
|
+
async *stream() {
|
|
408
|
+
yield { type: 'text', text: 'partial' }
|
|
409
|
+
await gate.promise
|
|
410
|
+
yield { type: 'text', text: 'never arrives' }
|
|
411
|
+
},
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
415
|
+
|
|
416
|
+
act(() => {
|
|
417
|
+
result.current.start(threadId, 'hi')
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
await waitFor(() => {
|
|
421
|
+
expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
act(() => {
|
|
425
|
+
result.current.stop(threadId)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
const thread = store.threads.find((t) => t.id === threadId)
|
|
429
|
+
expect(thread?.status).toBe('done')
|
|
430
|
+
expect(thread?.messages).toHaveLength(2)
|
|
431
|
+
const assistantMessage = thread?.messages[1]
|
|
432
|
+
expect(assistantMessage?.role).toBe('assistant')
|
|
433
|
+
expect(assistantMessage?.finish).toBe('stopped')
|
|
434
|
+
// withPartIds stamps the missing id as `${threadId}#0` — the first (and only) id-less part
|
|
435
|
+
// this run's generator yielded.
|
|
436
|
+
expect(assistantMessage?.parts).toEqual([
|
|
437
|
+
{ id: `${threadId}#0`, type: 'text', text: 'partial' },
|
|
438
|
+
])
|
|
439
|
+
// The run entry is torn down synchronously — no lingering 'streaming' indicator.
|
|
440
|
+
expect(result.current.runs.has(threadId)).toBe(false)
|
|
441
|
+
|
|
442
|
+
// Release the still-suspended (aborted, guard-caught) generator so it doesn't leak into the
|
|
443
|
+
// next test.
|
|
444
|
+
gate.resolve(undefined)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('stop() does not double-append when the stream already fully arrived before stop() was called', async () => {
|
|
448
|
+
const store = createTestThreadsStore()
|
|
449
|
+
const threadId = store.create()
|
|
450
|
+
|
|
451
|
+
let releaseOutcome: (() => void) | undefined
|
|
452
|
+
const slowResolveOutcome: OutcomeResolver<AgentPart> = () =>
|
|
453
|
+
new Promise<AgentOutcome>((resolve) => {
|
|
454
|
+
releaseOutcome = () => resolve({ title: 't', summary: 's', status: 'done' })
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
458
|
+
async *stream() {
|
|
459
|
+
yield { type: 'text', text: 'hi' }
|
|
460
|
+
},
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const { result } = renderHook(() =>
|
|
464
|
+
useAgentThreadRuns({ transport, store, resolveOutcome: slowResolveOutcome }),
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
act(() => {
|
|
468
|
+
result.current.start(threadId, 'hi')
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
// Wait until consumeAndFinalize's own appendMessage has already landed (it happens BEFORE
|
|
472
|
+
// its await on resolveOutcome) — this is the narrow window stop() can race without ever
|
|
473
|
+
// being able to observe "not yet appended" (see finalizeStop's doc comment).
|
|
474
|
+
await waitFor(() => {
|
|
475
|
+
expect(store.threads.find((t) => t.id === threadId)?.messages.length).toBe(2)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
act(() => {
|
|
479
|
+
result.current.stop(threadId)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
// Still exactly 2 messages (user + the ORIGINAL 'complete' assistant message) — stop() must
|
|
483
|
+
// recognize the terminal message already landed and skip its own append rather than
|
|
484
|
+
// duplicating it as a second 'stopped' message.
|
|
485
|
+
const thread = store.threads.find((t) => t.id === threadId)
|
|
486
|
+
expect(thread?.messages).toHaveLength(2)
|
|
487
|
+
expect(thread?.messages[1]?.finish).toBe('complete')
|
|
488
|
+
|
|
489
|
+
releaseOutcome?.()
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
test('stop() is a true no-op when threadId has no in-flight run', () => {
|
|
493
|
+
const store = createTestThreadsStore()
|
|
494
|
+
// No store.create() here on purpose — mirrors the "retry() is a no-op" test above: isolates
|
|
495
|
+
// stop()'s own "nothing in flight" guard from the mount-reconcile effect's unrelated handling
|
|
496
|
+
// of a freshly-created 'pending' thread.
|
|
497
|
+
const threadId = crypto.randomUUID()
|
|
498
|
+
|
|
499
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
500
|
+
async *stream() {
|
|
501
|
+
yield { type: 'text', text: 'x' }
|
|
502
|
+
},
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
506
|
+
|
|
507
|
+
act(() => {
|
|
508
|
+
result.current.stop(threadId)
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
expect(store.threads).toEqual([])
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
// Pins the fix for the reference-equality hole: runsRef mirrors `runs` only as of the last
|
|
515
|
+
// FLUSHED render (assigned during render, `:~355`), so a stop() landing inside the narrow
|
|
516
|
+
// window between consumeAndFinalize's post-loop appendMessage and its `await resolveOutcome`
|
|
517
|
+
// resolving could observe a STALE parts array (one delta behind) via runsRef — defeating the
|
|
518
|
+
// old `lastMessage.parts === parts` guard and double-appending. This test lands stop()
|
|
519
|
+
// deterministically inside that exact window (fired synchronously from inside the injected
|
|
520
|
+
// resolveOutcome, itself called synchronously right after appendMessage, before any of the
|
|
521
|
+
// hook's pending state updates can flush — flushing needs a macrotask, and nothing here crosses
|
|
522
|
+
// one) rather than relying on timing luck, so it is a reliable regression guard for the fixed
|
|
523
|
+
// marker-based mechanism, not just a one-off repro.
|
|
524
|
+
test("stop() fired synchronously from inside resolveOutcome (before the final delta's render flushes) must not double-append", async () => {
|
|
525
|
+
const store = createTestThreadsStore()
|
|
526
|
+
const threadId = store.create()
|
|
527
|
+
|
|
528
|
+
const gate = deferred<void>()
|
|
529
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
530
|
+
async *stream() {
|
|
531
|
+
// Distinct ids: mergePart addresses by id, and two parts sharing the (undefined) id would
|
|
532
|
+
// splice into ONE entry instead of accumulating two — this test needs the SECOND delta to
|
|
533
|
+
// produce a genuinely NEW array entry (and therefore a new `parts` array reference) to
|
|
534
|
+
// exercise the race at all.
|
|
535
|
+
yield { id: 'p1', type: 'text', text: 'first' }
|
|
536
|
+
await gate.promise
|
|
537
|
+
yield { id: 'p2', type: 'text', text: 'second' }
|
|
538
|
+
},
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const hookRef: { stop?: (id: string) => void } = {}
|
|
542
|
+
let resolveCalls = 0
|
|
543
|
+
const raceResolveOutcome: OutcomeResolver<AgentPart> = async () => {
|
|
544
|
+
resolveCalls += 1
|
|
545
|
+
// Fire stop() from inside the FIRST resolveOutcome call — synchronously, before this
|
|
546
|
+
// function even returns its promise. Everything from the second delta's setRuns dispatch to
|
|
547
|
+
// this point runs purely through microtasks (async-generator machinery + this direct call),
|
|
548
|
+
// so no macrotask has elapsed and React's scheduler cannot have flushed that dispatch into
|
|
549
|
+
// runsRef yet — this reliably lands stop() inside the race window.
|
|
550
|
+
if (resolveCalls === 1) {
|
|
551
|
+
hookRef.stop?.(threadId)
|
|
552
|
+
}
|
|
553
|
+
return { title: 't', summary: 's', status: 'done' }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const { result } = renderHook(() =>
|
|
557
|
+
useAgentThreadRuns({ transport, store, resolveOutcome: raceResolveOutcome }),
|
|
558
|
+
)
|
|
559
|
+
hookRef.stop = result.current.stop
|
|
560
|
+
|
|
561
|
+
act(() => {
|
|
562
|
+
result.current.start(threadId, 'hi')
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
// Let the FIRST delta's render flush — runsRef now mirrors a non-empty parts array
|
|
566
|
+
// (['first']), which is what makes the eventual mismatch observable rather than vacuously
|
|
567
|
+
// guarded by parts.length === 0.
|
|
568
|
+
await waitFor(() => {
|
|
569
|
+
expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
// Resolve the gate outside act() on purpose: this must not synchronously flush React state —
|
|
573
|
+
// the whole point is to let the second delta, the loop exit, and appendMessage run purely as
|
|
574
|
+
// microtask continuations, with stop() firing from inside resolveOutcome before any render for
|
|
575
|
+
// the second delta occurs.
|
|
576
|
+
gate.resolve(undefined)
|
|
577
|
+
|
|
578
|
+
await waitFor(() => {
|
|
579
|
+
expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
const thread = store.threads.find((t) => t.id === threadId)
|
|
583
|
+
const assistantMessages = thread?.messages.filter((m) => m.role === 'assistant') ?? []
|
|
584
|
+
expect(assistantMessages).toHaveLength(1)
|
|
585
|
+
// The surviving message must be the ORIGINAL 'complete' one with the full (['first','second'])
|
|
586
|
+
// content — not a truncated 'stopped' message built from the stale ['first']-only parts.
|
|
587
|
+
expect(assistantMessages[0]?.finish).toBe('complete')
|
|
588
|
+
expect(assistantMessages[0]?.parts).toEqual([
|
|
589
|
+
{ id: 'p1', type: 'text', text: 'first' },
|
|
590
|
+
{ id: 'p2', type: 'text', text: 'second' },
|
|
591
|
+
])
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
// Pins the flip side of the marker fix: the marker must be cleared once a run concludes, or a
|
|
595
|
+
// stale entry from an EARLIER finished run on the same thread would wrongly suppress a
|
|
596
|
+
// legitimate append on a LATER run — a worse bug than the double-append this mechanism exists to
|
|
597
|
+
// prevent.
|
|
598
|
+
test('a stale marker from an earlier finished run must not suppress a legitimate later stop() append', async () => {
|
|
599
|
+
const store = createTestThreadsStore()
|
|
600
|
+
const threadId = store.create()
|
|
601
|
+
|
|
602
|
+
const inputs: string[] = []
|
|
603
|
+
let gate = deferred<void>()
|
|
604
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
605
|
+
async *stream(input) {
|
|
606
|
+
inputs.push(input)
|
|
607
|
+
// Distinct ids: two id-less parts sharing the (undefined) id would silently splice into
|
|
608
|
+
// ONE entry (the F1 bug) — turn 1's own "did these two deltas stay separate?" assertion
|
|
609
|
+
// below is invisible otherwise, since it only checked assistant MESSAGE count, not parts.
|
|
610
|
+
yield { id: `${input}-partial`, type: 'text', text: `${input}-partial` }
|
|
611
|
+
await gate.promise
|
|
612
|
+
yield { id: `${input}-final`, type: 'text', text: `${input}-final` }
|
|
613
|
+
},
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
617
|
+
|
|
618
|
+
// Turn 1: runs to full completion (no stop()) so consumeAndFinalize's own success path sets
|
|
619
|
+
// and then clears the marker for this thread.
|
|
620
|
+
act(() => {
|
|
621
|
+
result.current.start(threadId, 'first-turn')
|
|
622
|
+
})
|
|
623
|
+
gate.resolve(undefined)
|
|
624
|
+
await waitFor(() => {
|
|
625
|
+
expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
|
|
626
|
+
})
|
|
627
|
+
const turn1Assistant = store.threads
|
|
628
|
+
.find((t) => t.id === threadId)
|
|
629
|
+
?.messages.filter((m) => m.role === 'assistant')
|
|
630
|
+
expect(turn1Assistant).toHaveLength(1)
|
|
631
|
+
// The two deltas landed as two SEPARATE parts, not spliced into one.
|
|
632
|
+
expect(turn1Assistant?.[0]?.parts).toEqual([
|
|
633
|
+
{ id: 'first-turn-partial', type: 'text', text: 'first-turn-partial' },
|
|
634
|
+
{ id: 'first-turn-final', type: 'text', text: 'first-turn-final' },
|
|
635
|
+
])
|
|
636
|
+
|
|
637
|
+
// Turn 2: a genuinely new run on the SAME thread, stopped mid-stream. If a stale marker from
|
|
638
|
+
// turn 1 leaked, this stop() would be wrongly treated as "already persisted" and silently drop
|
|
639
|
+
// the partial content instead of appending it.
|
|
640
|
+
gate = deferred<void>()
|
|
641
|
+
act(() => {
|
|
642
|
+
result.current.retry(threadId)
|
|
643
|
+
})
|
|
644
|
+
expect(inputs).toEqual(['first-turn', 'first-turn'])
|
|
645
|
+
|
|
646
|
+
await waitFor(() => {
|
|
647
|
+
expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
act(() => {
|
|
651
|
+
result.current.stop(threadId)
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
const thread = store.threads.find((t) => t.id === threadId)
|
|
655
|
+
const assistantMessages = thread?.messages.filter((m) => m.role === 'assistant') ?? []
|
|
656
|
+
expect(assistantMessages).toHaveLength(2)
|
|
657
|
+
expect(assistantMessages[1]?.finish).toBe('stopped')
|
|
658
|
+
expect(assistantMessages[1]?.parts).toEqual([
|
|
659
|
+
{ id: 'first-turn-partial', type: 'text', text: 'first-turn-partial' },
|
|
660
|
+
])
|
|
661
|
+
|
|
662
|
+
gate.resolve(undefined)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
test('stopAll() resolves every aborted thread instead of stranding them in "streaming"', async () => {
|
|
666
|
+
const store = createTestThreadsStore()
|
|
667
|
+
const threadA = store.create()
|
|
668
|
+
const threadB = store.create()
|
|
669
|
+
|
|
670
|
+
const gateA = deferred<void>()
|
|
671
|
+
const gateB = deferred<void>()
|
|
672
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
673
|
+
async *stream(input) {
|
|
674
|
+
if (input === 'a') {
|
|
675
|
+
yield { type: 'text', text: 'partial-a' }
|
|
676
|
+
await gateA.promise
|
|
677
|
+
} else {
|
|
678
|
+
yield { type: 'text', text: 'partial-b' }
|
|
679
|
+
await gateB.promise
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
685
|
+
|
|
686
|
+
act(() => {
|
|
687
|
+
result.current.start(threadA, 'a')
|
|
688
|
+
result.current.start(threadB, 'b')
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
await waitFor(() => {
|
|
692
|
+
expect(result.current.runs.get(threadA)?.parts).toHaveLength(1)
|
|
693
|
+
expect(result.current.runs.get(threadB)?.parts).toHaveLength(1)
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
act(() => {
|
|
697
|
+
result.current.stopAll()
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
expect(store.threads.find((t) => t.id === threadA)?.status).toBe('done')
|
|
701
|
+
expect(store.threads.find((t) => t.id === threadB)?.status).toBe('done')
|
|
702
|
+
expect(result.current.runs.size).toBe(0)
|
|
703
|
+
|
|
704
|
+
// Release the still-suspended (aborted, guard-caught) generators so they don't leak into the
|
|
705
|
+
// next test.
|
|
706
|
+
gateA.resolve(undefined)
|
|
707
|
+
gateB.resolve(undefined)
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
// F4 regression: stop()'s no-op guard used to read runsRef, which only mirrors `runs` as of the
|
|
711
|
+
// last FLUSHED RENDER. start() then stop() called in the SAME synchronous act() batch — e.g.
|
|
712
|
+
// `onClick={() => { start(id, text); stop(id) }}` — never lets a render land in between, so
|
|
713
|
+
// runsRef was still empty when stop() ran and it wrongly no-op'd: the controller was never
|
|
714
|
+
// aborted, and a transport that only resolves on its AbortSignal firing would strand the thread
|
|
715
|
+
// in 'streaming' forever (this is exactly what `stopAll()`'s own regression test above cannot
|
|
716
|
+
// see — it deliberately `waitFor`s a render to flush before calling stopAll()).
|
|
717
|
+
test('F4 regression: start() then stop() in the same synchronous tick must actually abort, not strand the thread in "streaming"', async () => {
|
|
718
|
+
const store = createTestThreadsStore()
|
|
719
|
+
const threadId = store.create()
|
|
720
|
+
|
|
721
|
+
// Hangs until its AbortSignal fires — never resolves on its own. If stop()'s no-op guard skips
|
|
722
|
+
// the abort, this generator never settles and the thread is stuck 'streaming' forever.
|
|
723
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
724
|
+
// oxlint-disable-next-line require-yield -- hangs until aborted; never has anything to yield
|
|
725
|
+
async *stream(_input, signal) {
|
|
726
|
+
await new Promise((resolve) => {
|
|
727
|
+
signal?.addEventListener('abort', () => resolve(undefined))
|
|
728
|
+
})
|
|
729
|
+
},
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
733
|
+
|
|
734
|
+
act(() => {
|
|
735
|
+
result.current.start(threadId, 'hi')
|
|
736
|
+
result.current.stop(threadId)
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
await waitFor(() => {
|
|
740
|
+
expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
|
|
741
|
+
})
|
|
742
|
+
expect(result.current.runs.has(threadId)).toBe(false)
|
|
743
|
+
})
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
describe('useAgentThreadRuns — F1 regression: id-less drafts within one run', () => {
|
|
747
|
+
test('drafts of different types do not destroy each other — mergePart no longer sees undefined === undefined', async () => {
|
|
748
|
+
const store = createTestThreadsStore()
|
|
749
|
+
const threadId = store.create()
|
|
750
|
+
|
|
751
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
752
|
+
async *stream() {
|
|
753
|
+
yield { type: 'text', text: 'Hi' }
|
|
754
|
+
yield {
|
|
755
|
+
type: 'tool',
|
|
756
|
+
toolCallId: 'c1',
|
|
757
|
+
toolName: 'search',
|
|
758
|
+
state: 'input-available',
|
|
759
|
+
input: { q: 'x' },
|
|
760
|
+
}
|
|
761
|
+
yield { type: 'text', text: 'Bye' }
|
|
762
|
+
},
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
766
|
+
|
|
767
|
+
act(() => {
|
|
768
|
+
result.current.start(threadId, 'go')
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
await waitFor(() => {
|
|
772
|
+
expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
const assistantMessage = store.threads
|
|
776
|
+
.find((t) => t.id === threadId)
|
|
777
|
+
?.messages.find((m) => m.role === 'assistant')
|
|
778
|
+
expect(assistantMessage?.parts).toHaveLength(3)
|
|
779
|
+
expect(assistantMessage?.parts.map((part) => part.type)).toEqual(['text', 'tool', 'text'])
|
|
780
|
+
})
|
|
781
|
+
})
|