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
|
@@ -26,10 +26,10 @@ describe('useAgentStream', () => {
|
|
|
26
26
|
const gate = deferred<void>()
|
|
27
27
|
const transport: AgentTransport<AgentPart, string> = {
|
|
28
28
|
async *stream() {
|
|
29
|
-
yield { type: 'text', text: 'a' }
|
|
29
|
+
yield { id: 'p1', type: 'text', text: 'a' }
|
|
30
30
|
await gate.promise
|
|
31
31
|
// Must never be observed — the consumer's guard should discard this once aborted.
|
|
32
|
-
yield { type: 'text', text: 'b' }
|
|
32
|
+
yield { id: 'p2', type: 'text', text: 'b' }
|
|
33
33
|
},
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -39,7 +39,7 @@ describe('useAgentStream', () => {
|
|
|
39
39
|
void result.current.send('hi')
|
|
40
40
|
})
|
|
41
41
|
await waitFor(() => {
|
|
42
|
-
expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
|
|
42
|
+
expect(result.current.parts).toEqual([{ id: 'p1', type: 'text', text: 'a' }])
|
|
43
43
|
})
|
|
44
44
|
expect(result.current.status).toBe('streaming')
|
|
45
45
|
|
|
@@ -53,7 +53,7 @@ describe('useAgentStream', () => {
|
|
|
53
53
|
await waitFor(() => {
|
|
54
54
|
expect(result.current.status).toBe('done')
|
|
55
55
|
})
|
|
56
|
-
expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
|
|
56
|
+
expect(result.current.parts).toEqual([{ id: 'p1', type: 'text', text: 'a' }])
|
|
57
57
|
})
|
|
58
58
|
|
|
59
59
|
test("a second send() supersedes an in-flight one — the first stream's later parts are discarded", async () => {
|
|
@@ -61,12 +61,12 @@ describe('useAgentStream', () => {
|
|
|
61
61
|
const transport: AgentTransport<AgentPart, string> = {
|
|
62
62
|
async *stream(input) {
|
|
63
63
|
if (input === 'first') {
|
|
64
|
-
yield { type: 'text', text: 'first-1' }
|
|
64
|
+
yield { id: 'f1', type: 'text', text: 'first-1' }
|
|
65
65
|
await gateFirst.promise
|
|
66
66
|
// Must never be observed — 'second' has already superseded this controller.
|
|
67
|
-
yield { type: 'text', text: 'first-2' }
|
|
67
|
+
yield { id: 'f2', type: 'text', text: 'first-2' }
|
|
68
68
|
} else {
|
|
69
|
-
yield { type: 'text', text: 'second-1' }
|
|
69
|
+
yield { id: 's1', type: 'text', text: 'second-1' }
|
|
70
70
|
}
|
|
71
71
|
},
|
|
72
72
|
}
|
|
@@ -77,14 +77,14 @@ describe('useAgentStream', () => {
|
|
|
77
77
|
void result.current.send('first')
|
|
78
78
|
})
|
|
79
79
|
await waitFor(() => {
|
|
80
|
-
expect(result.current.parts).toEqual([{ type: 'text', text: 'first-1' }])
|
|
80
|
+
expect(result.current.parts).toEqual([{ id: 'f1', type: 'text', text: 'first-1' }])
|
|
81
81
|
})
|
|
82
82
|
|
|
83
83
|
act(() => {
|
|
84
84
|
void result.current.send('second')
|
|
85
85
|
})
|
|
86
86
|
await waitFor(() => {
|
|
87
|
-
expect(result.current.parts).toEqual([{ type: 'text', text: 'second-1' }])
|
|
87
|
+
expect(result.current.parts).toEqual([{ id: 's1', type: 'text', text: 'second-1' }])
|
|
88
88
|
})
|
|
89
89
|
expect(result.current.status).toBe('done')
|
|
90
90
|
|
|
@@ -92,7 +92,37 @@ describe('useAgentStream', () => {
|
|
|
92
92
|
await waitFor(() => {
|
|
93
93
|
expect(result.current.status).toBe('done')
|
|
94
94
|
})
|
|
95
|
-
expect(result.current.parts).toEqual([{ type: 'text', text: 'second-1' }])
|
|
95
|
+
expect(result.current.parts).toEqual([{ id: 's1', type: 'text', text: 'second-1' }])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('F1 regression: id-less drafts of different types within one send() do not destroy each other', async () => {
|
|
99
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
100
|
+
async *stream() {
|
|
101
|
+
yield { type: 'text', text: 'Hi' }
|
|
102
|
+
yield {
|
|
103
|
+
type: 'tool',
|
|
104
|
+
toolCallId: 'c1',
|
|
105
|
+
toolName: 'search',
|
|
106
|
+
state: 'input-available',
|
|
107
|
+
input: { q: 'x' },
|
|
108
|
+
}
|
|
109
|
+
yield { type: 'text', text: 'Bye' }
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const { result } = renderHook(() => useAgentStream({ transport }))
|
|
114
|
+
|
|
115
|
+
await act(async () => {
|
|
116
|
+
await result.current.send('hi')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// Pre-fix: mergePart matched all three drafts on `undefined === undefined`, so text→tool→text
|
|
120
|
+
// collapsed to a single (wrong-typed) entry instead of three. withPartIds stamping a distinct
|
|
121
|
+
// id per draft is what keeps them apart.
|
|
122
|
+
expect(result.current.parts).toHaveLength(3)
|
|
123
|
+
expect(result.current.parts.map((part) => part.type)).toEqual(['text', 'tool', 'text'])
|
|
124
|
+
const ids = result.current.parts.map((part) => (part as { id: string }).id)
|
|
125
|
+
expect(new Set(ids).size).toBe(3)
|
|
96
126
|
})
|
|
97
127
|
|
|
98
128
|
test('a thrown (non-abort) error sets status "error" and captures the error', async () => {
|
|
@@ -119,10 +149,10 @@ describe('useAgentStream', () => {
|
|
|
119
149
|
const gate = deferred<void>()
|
|
120
150
|
const transport: AgentTransport<AgentPart, string> = {
|
|
121
151
|
async *stream(_input, signal) {
|
|
122
|
-
yield { type: 'text', text: 'a' }
|
|
152
|
+
yield { id: 'p1', type: 'text', text: 'a' }
|
|
123
153
|
await gate.promise
|
|
124
154
|
if (signal?.aborted) throw new DOMException('The operation was aborted.', 'AbortError')
|
|
125
|
-
yield { type: 'text', text: 'b' }
|
|
155
|
+
yield { id: 'p2', type: 'text', text: 'b' }
|
|
126
156
|
},
|
|
127
157
|
}
|
|
128
158
|
|
|
@@ -132,7 +162,7 @@ describe('useAgentStream', () => {
|
|
|
132
162
|
void result.current.send('hi')
|
|
133
163
|
})
|
|
134
164
|
await waitFor(() => {
|
|
135
|
-
expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
|
|
165
|
+
expect(result.current.parts).toEqual([{ id: 'p1', type: 'text', text: 'a' }])
|
|
136
166
|
})
|
|
137
167
|
|
|
138
168
|
// stop() aborts the signal AND sets 'done' synchronously.
|
|
@@ -155,7 +185,7 @@ describe('useAgentStream', () => {
|
|
|
155
185
|
const gate = deferred<void>()
|
|
156
186
|
const transport: AgentTransport<AgentPart, string> = {
|
|
157
187
|
async *stream(_input, signal) {
|
|
158
|
-
yield { type: 'text', text: 'a' }
|
|
188
|
+
yield { id: 'p1', type: 'text', text: 'a' }
|
|
159
189
|
await gate.promise
|
|
160
190
|
observedAbortedOnResume = signal?.aborted ?? false
|
|
161
191
|
},
|
|
@@ -167,7 +197,7 @@ describe('useAgentStream', () => {
|
|
|
167
197
|
void result.current.send('hi')
|
|
168
198
|
})
|
|
169
199
|
await waitFor(() => {
|
|
170
|
-
expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
|
|
200
|
+
expect(result.current.parts).toEqual([{ id: 'p1', type: 'text', text: 'a' }])
|
|
171
201
|
})
|
|
172
202
|
|
|
173
203
|
unmount()
|
|
@@ -197,7 +227,7 @@ describe('useAgentStream', () => {
|
|
|
197
227
|
const transport: AgentTransport<AgentPart, string> = {
|
|
198
228
|
async *stream() {
|
|
199
229
|
streamCalls++
|
|
200
|
-
yield { type: 'text', text: `call-${streamCalls}` }
|
|
230
|
+
yield { id: `call-${streamCalls}`, type: 'text', text: `call-${streamCalls}` }
|
|
201
231
|
},
|
|
202
232
|
}
|
|
203
233
|
|
|
@@ -226,6 +256,6 @@ describe('useAgentStream', () => {
|
|
|
226
256
|
|
|
227
257
|
expect(streamCalls).toBe(2)
|
|
228
258
|
// Only the SECOND (surviving) call's part made it into final state — the first is discarded.
|
|
229
|
-
expect(capturedParts).toEqual([{ type: 'text', text: 'call-2' }])
|
|
259
|
+
expect(capturedParts).toEqual([{ id: 'call-2', type: 'text', text: 'call-2' }])
|
|
230
260
|
})
|
|
231
261
|
})
|
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
* }
|
|
28
28
|
*/
|
|
29
29
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
30
|
+
import { withPartIds } from './id'
|
|
30
31
|
import { isStartPart } from './parts'
|
|
32
|
+
import { mergePart } from './merge'
|
|
33
|
+
import type { PartLike } from './merge'
|
|
31
34
|
import type { AgentPart } from './parts'
|
|
32
35
|
import type { AgentTransport } from './transport'
|
|
33
36
|
|
|
@@ -67,7 +70,7 @@ export type UseAgentStreamReturn<TPart, TInput> = {
|
|
|
67
70
|
* // parts accumulates as the stream arrives
|
|
68
71
|
* // status: 'idle' → 'streaming' → 'done' (or 'error')
|
|
69
72
|
*/
|
|
70
|
-
export function useAgentStream<TPart = AgentPart, TInput = string>({
|
|
73
|
+
export function useAgentStream<TPart extends PartLike = AgentPart, TInput = string>({
|
|
71
74
|
transport,
|
|
72
75
|
}: {
|
|
73
76
|
transport: AgentTransport<TPart, TInput>
|
|
@@ -100,15 +103,22 @@ export function useAgentStream<TPart = AgentPart, TInput = string>({
|
|
|
100
103
|
setError(undefined)
|
|
101
104
|
setStatus('streaming')
|
|
102
105
|
|
|
106
|
+
// Per-send namespace: this hook has no thread/session id to key off, and each send() call
|
|
107
|
+
// fully resets `parts` anyway, so a fresh id per call is enough to keep two id-less drafts
|
|
108
|
+
// ARRIVING WITHIN THE SAME SEND from colliding on `undefined === undefined` in mergePart (the
|
|
109
|
+
// F1 bug — text→tool→text destroying each other). Not a replay mechanism — this hook has no
|
|
110
|
+
// resume() path — see withPartIds' own doc for why it can't be one anyway.
|
|
111
|
+
const runId = crypto.randomUUID()
|
|
112
|
+
|
|
103
113
|
try {
|
|
104
|
-
for await (const part of transport.stream(input, controller.signal)) {
|
|
114
|
+
for await (const part of withPartIds(runId, transport.stream(input, controller.signal))) {
|
|
105
115
|
// Guard: if a newer send() has superseded this stream, stop updating state.
|
|
106
116
|
if (controllerRef.current !== controller) return
|
|
107
117
|
if (controller.signal.aborted) break
|
|
108
118
|
// StartPart is a no-op signal (run id + resume token), not renderable content — this
|
|
109
119
|
// hook has no persistence layer to resume across, so just skip it.
|
|
110
120
|
if (isStartPart(part)) continue
|
|
111
|
-
setParts((prev) =>
|
|
121
|
+
setParts((prev) => mergePart(prev, part))
|
|
112
122
|
}
|
|
113
123
|
// Only mark done if this stream is still the current one and wasn't aborted.
|
|
114
124
|
if (controllerRef.current === controller && !controller.signal.aborted) setStatus('done')
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAgentThreadRuns — foreign-part round trip (B2 convergence fix).
|
|
3
|
+
*
|
|
4
|
+
* The open part registry (./foreign) only serves its headline purpose — a transport streaming an
|
|
5
|
+
* app-specific part type (`data-toolProgress`, `data-chart`, …) reaching `ThreadTranscript` — if a
|
|
6
|
+
* ForeignPart can flow all the way through this hook's mergePart accumulator: transport →
|
|
7
|
+
* consumeAndFinalize → runs.get(id).parts → the persisted assistant ChatMessage. This file proves
|
|
8
|
+
* that path end to end with `useAgentThreadRuns<TranscriptPart>`, which was a tsc error before
|
|
9
|
+
* mergePart's constraint was widened from `AgentPart` to the structural `PartLike`.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from 'bun:test'
|
|
12
|
+
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
13
|
+
import { useAgentThreadRuns } from './use-agent-thread-runs'
|
|
14
|
+
import type { AgentThread, ThreadsStore } from './thread'
|
|
15
|
+
import type { AgentTransport } from './transport'
|
|
16
|
+
import type { TranscriptPart } from './foreign'
|
|
17
|
+
import type { AgentOutcome, OutcomeResolver } from './outcome'
|
|
18
|
+
|
|
19
|
+
// ── test-only ThreadsStore double, typed over TranscriptPart ─────────────────────────────────────
|
|
20
|
+
function createTestThreadsStore(): ThreadsStore<TranscriptPart> {
|
|
21
|
+
let threads: AgentThread<TranscriptPart>[] = []
|
|
22
|
+
let activeId: string | null = null
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
get threads() {
|
|
26
|
+
return threads
|
|
27
|
+
},
|
|
28
|
+
get activeId() {
|
|
29
|
+
return activeId
|
|
30
|
+
},
|
|
31
|
+
select(id) {
|
|
32
|
+
activeId = id
|
|
33
|
+
},
|
|
34
|
+
create(createOpts) {
|
|
35
|
+
const id = crypto.randomUUID()
|
|
36
|
+
const now = Date.now()
|
|
37
|
+
const thread: AgentThread<TranscriptPart> = {
|
|
38
|
+
id,
|
|
39
|
+
messages: [],
|
|
40
|
+
outcome: null,
|
|
41
|
+
status: 'pending',
|
|
42
|
+
read: false,
|
|
43
|
+
createdAt: now,
|
|
44
|
+
updatedAt: now,
|
|
45
|
+
...(createOpts?.meta !== undefined ? { meta: createOpts.meta } : {}),
|
|
46
|
+
}
|
|
47
|
+
threads = [thread, ...threads]
|
|
48
|
+
return id
|
|
49
|
+
},
|
|
50
|
+
appendMessage(id, message) {
|
|
51
|
+
threads = threads.map((thread) =>
|
|
52
|
+
thread.id === id
|
|
53
|
+
? { ...thread, messages: [...thread.messages, message], updatedAt: Date.now() }
|
|
54
|
+
: thread,
|
|
55
|
+
)
|
|
56
|
+
},
|
|
57
|
+
setOutcome(id, outcome) {
|
|
58
|
+
threads = threads.map((thread) =>
|
|
59
|
+
thread.id === id ? { ...thread, outcome, updatedAt: Date.now() } : thread,
|
|
60
|
+
)
|
|
61
|
+
},
|
|
62
|
+
setStatus(id, status) {
|
|
63
|
+
threads = threads.map((thread) =>
|
|
64
|
+
thread.id === id ? { ...thread, status, updatedAt: Date.now() } : thread,
|
|
65
|
+
)
|
|
66
|
+
},
|
|
67
|
+
setResumeToken(id, token) {
|
|
68
|
+
threads = threads.map((thread) => {
|
|
69
|
+
if (thread.id !== id) return thread
|
|
70
|
+
const { resumeToken: _resumeToken, ...rest } = thread
|
|
71
|
+
return {
|
|
72
|
+
...rest,
|
|
73
|
+
...(token !== undefined ? { resumeToken: token } : {}),
|
|
74
|
+
updatedAt: Date.now(),
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
},
|
|
78
|
+
markRead(id) {
|
|
79
|
+
threads = threads.map((thread) => (thread.id === id ? { ...thread, read: true } : thread))
|
|
80
|
+
},
|
|
81
|
+
remove(id) {
|
|
82
|
+
threads = threads.filter((thread) => thread.id !== id)
|
|
83
|
+
if (activeId === id) activeId = null
|
|
84
|
+
},
|
|
85
|
+
clear() {
|
|
86
|
+
threads = []
|
|
87
|
+
activeId = null
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const resolveOutcome: OutcomeResolver<TranscriptPart> = (): AgentOutcome => ({
|
|
93
|
+
title: 'title',
|
|
94
|
+
summary: 'summary',
|
|
95
|
+
status: 'done',
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('useAgentThreadRuns<TranscriptPart> — foreign part round trip', () => {
|
|
99
|
+
test('a foreign part survives accumulation and a replay replaces rather than duplicates', async () => {
|
|
100
|
+
const store = createTestThreadsStore()
|
|
101
|
+
const threadId = store.create()
|
|
102
|
+
|
|
103
|
+
const transport: AgentTransport<TranscriptPart, string> = {
|
|
104
|
+
async *stream() {
|
|
105
|
+
yield { id: 'progress-1', type: 'data-toolProgress', message: 'searching' }
|
|
106
|
+
// Same id, second delta — must REPLACE the first entry, not duplicate it.
|
|
107
|
+
yield { id: 'progress-1', type: 'data-toolProgress', message: 'done' }
|
|
108
|
+
yield { id: 'text-1', type: 'text', text: 'Found it.' }
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { result } = renderHook(() =>
|
|
113
|
+
useAgentThreadRuns<TranscriptPart>({ transport, store, resolveOutcome }),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
act(() => {
|
|
117
|
+
result.current.start(threadId, 'go find it')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await waitFor(() => {
|
|
121
|
+
expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const assistantMessage = store.threads
|
|
125
|
+
.find((t) => t.id === threadId)
|
|
126
|
+
?.messages.find((m) => m.role === 'assistant')
|
|
127
|
+
|
|
128
|
+
expect(assistantMessage?.parts).toEqual([
|
|
129
|
+
{ id: 'progress-1', type: 'data-toolProgress', message: 'done' },
|
|
130
|
+
{ id: 'text-1', type: 'text', text: 'Found it.' },
|
|
131
|
+
])
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* The mount-reconcile effect (empty deps — a one-time sweep of whatever the store held when this
|
|
5
5
|
* manager first attaches) treats any persisted 'pending' or 'streaming' thread with no live
|
|
6
6
|
* controller as orphaned, and either attempts `transport.resume(resumeToken, signal)` (when the
|
|
7
|
-
* transport
|
|
8
|
-
*
|
|
7
|
+
* transport is RESUMABLE per `isResumable` — `resume` AND the literal `idempotentReplay: true`
|
|
8
|
+
* assertion, a resumeToken is present, and there's a last user message to attribute the resumed
|
|
9
|
+
* turn to) or falls back straight to 'interrupted'. `resume` alone is NOT enough post-B2 — see the
|
|
10
|
+
* dedicated "resume() present but no idempotentReplay" test below.
|
|
9
11
|
*
|
|
10
12
|
* The F3 wedge (StrictMode/`<Activity>` re-running this same effect on the SAME fiber) has its
|
|
11
13
|
* own file: `use-agent-thread-runs.wedge.test.tsx`.
|
|
@@ -155,12 +157,13 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
155
157
|
const store = createTestThreadsStore([thread])
|
|
156
158
|
|
|
157
159
|
let resumeCalls = 0
|
|
158
|
-
const transport
|
|
160
|
+
const transport = {
|
|
159
161
|
async *stream() {},
|
|
160
162
|
async *resume() {
|
|
161
163
|
resumeCalls++
|
|
162
164
|
yield { type: 'text', text: 'resumed' }
|
|
163
165
|
},
|
|
166
|
+
idempotentReplay: true as const,
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
@@ -175,6 +178,28 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
175
178
|
const thread = makeStreamingThread({ messages: [] })
|
|
176
179
|
const store = createTestThreadsStore([thread])
|
|
177
180
|
|
|
181
|
+
let resumeCalls = 0
|
|
182
|
+
const transport = {
|
|
183
|
+
async *stream() {},
|
|
184
|
+
async *resume() {
|
|
185
|
+
resumeCalls++
|
|
186
|
+
yield { type: 'text', text: 'resumed' }
|
|
187
|
+
},
|
|
188
|
+
idempotentReplay: true as const,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
192
|
+
|
|
193
|
+
await waitFor(() => {
|
|
194
|
+
expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
|
|
195
|
+
})
|
|
196
|
+
expect(resumeCalls).toBe(0)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('resume() present but WITHOUT idempotentReplay → still falls back to interrupted (resume alone is not enough post-B2)', async () => {
|
|
200
|
+
const thread = makeStreamingThread()
|
|
201
|
+
const store = createTestThreadsStore([thread])
|
|
202
|
+
|
|
178
203
|
let resumeCalls = 0
|
|
179
204
|
const transport: AgentTransport<AgentPart, string> = {
|
|
180
205
|
async *stream() {},
|
|
@@ -182,6 +207,7 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
182
207
|
resumeCalls++
|
|
183
208
|
yield { type: 'text', text: 'resumed' }
|
|
184
209
|
},
|
|
210
|
+
// deliberately NO idempotentReplay: true
|
|
185
211
|
}
|
|
186
212
|
|
|
187
213
|
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
@@ -197,12 +223,13 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
197
223
|
const store = createTestThreadsStore([thread])
|
|
198
224
|
|
|
199
225
|
const resumeArgs: string[] = []
|
|
200
|
-
const transport
|
|
226
|
+
const transport = {
|
|
201
227
|
async *stream() {},
|
|
202
|
-
async *resume(token) {
|
|
228
|
+
async *resume(token: string) {
|
|
203
229
|
resumeArgs.push(token)
|
|
204
230
|
yield { type: 'text', text: 'resumed' }
|
|
205
231
|
},
|
|
232
|
+
idempotentReplay: true as const,
|
|
206
233
|
}
|
|
207
234
|
|
|
208
235
|
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
@@ -217,12 +244,13 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
217
244
|
const thread = makeStreamingThread()
|
|
218
245
|
const store = createTestThreadsStore([thread])
|
|
219
246
|
|
|
220
|
-
const transport
|
|
247
|
+
const transport = {
|
|
221
248
|
async *stream() {},
|
|
222
249
|
// oxlint-disable-next-line require-yield
|
|
223
250
|
async *resume() {
|
|
224
251
|
throw new Error('resume failed')
|
|
225
252
|
},
|
|
253
|
+
idempotentReplay: true as const,
|
|
226
254
|
}
|
|
227
255
|
|
|
228
256
|
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|