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,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* aiSdkTransport — snapshot→delta diffing (text/reasoning/source/tool),
|
|
3
|
-
* and deterministic chat-id binding via `.forThread()`.
|
|
2
|
+
* aiSdkTransport — snapshot→delta diffing (text/reasoning/source/tool), deterministic id minting,
|
|
3
|
+
* durationMs, signal-abort mid-stream, and deterministic chat-id binding via `.forThread()`.
|
|
4
4
|
*
|
|
5
|
-
* Scope: the diffing behaviour
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Scope: the diffing behaviour post-1.11.0's B2 rewrite — `diffToolPart` now models all seven
|
|
6
|
+
* `UIToolInvocation` states (including `input-streaming`, the nested `approval` envelope, and
|
|
7
|
+
* `output-denied`), mints deterministic ids (`${chatId}#${index}` for index-addressed parts,
|
|
8
|
+
* `tool#${toolCallId}` for tool parts), carries an authoritative `offset` on every text/reasoning
|
|
9
|
+
* delta, derives a dynamic tool's real name instead of the literal `'dynamic-tool'`, and does not
|
|
10
|
+
* suppress a `preliminary: true` output-available refinement.
|
|
9
11
|
*
|
|
10
12
|
* Driving `aiSdkTransport` through a scripted HTTP response — the only way to exercise its public
|
|
11
13
|
* stream()/resume() diffing at all, since the diffing internals are not exported — requires a real
|
|
@@ -18,6 +20,8 @@
|
|
|
18
20
|
import { describe, expect, test } from 'bun:test'
|
|
19
21
|
import { JsonToSseTransformStream } from 'ai'
|
|
20
22
|
import { aiSdkTransport } from './ai-sdk-transport'
|
|
23
|
+
import { withPartIds } from './id'
|
|
24
|
+
import { mergePart } from './merge'
|
|
21
25
|
import type { UIMessageChunk } from 'ai'
|
|
22
26
|
import type { AgentPart } from './parts'
|
|
23
27
|
|
|
@@ -46,8 +50,15 @@ async function collect(gen: AsyncGenerator<AgentPart>): Promise<AgentPart[]> {
|
|
|
46
50
|
return parts
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
/** Strips a non-deterministic `durationMs` (wall-clock, so never equality-comparable) before an
|
|
54
|
+
* exact `toEqual`, and separately asserts its type. */
|
|
55
|
+
function withoutDurationMs<T extends { durationMs?: number }>(part: T): Omit<T, 'durationMs'> {
|
|
56
|
+
const { durationMs: _durationMs, ...rest } = part
|
|
57
|
+
return rest
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
50
|
-
test('yields a synthesized start part first, then diffs text deltas across snapshots', async () => {
|
|
61
|
+
test('yields a synthesized start part first, with a deterministic id, then diffs text deltas across snapshots with an authoritative offset', async () => {
|
|
51
62
|
const chunks: UIMessageChunk[] = [
|
|
52
63
|
{ type: 'text-start', id: 't1' },
|
|
53
64
|
{ type: 'text-delta', id: 't1', delta: 'Hel' },
|
|
@@ -61,18 +72,52 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
61
72
|
const parts = await collect(transport.stream('hi'))
|
|
62
73
|
|
|
63
74
|
expect(parts[0]?.type).toBe('start')
|
|
64
|
-
|
|
75
|
+
expect(typeof (parts[0] as { id: string }).id).toBe('string')
|
|
76
|
+
const textParts = parts.filter(
|
|
77
|
+
(p): p is { id: string; type: 'text'; text: string; offset: number } => p.type === 'text',
|
|
78
|
+
)
|
|
65
79
|
// AI SDK's snapshot carries the FULL accumulated text at every write — the module's whole job
|
|
66
80
|
// is re-deriving the two incremental deltas ('Hel' then 'lo') rather than replaying 'Hello'
|
|
67
|
-
// twice or once whole.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
// twice or once whole. Both deltas share ONE id (the same message-part index) so mergePart
|
|
82
|
+
// splices them into a single TextPart; `offset` is the length of the previously-seen text.
|
|
83
|
+
expect(textParts.map((p) => p.text)).toEqual(['Hel', 'lo'])
|
|
84
|
+
expect(textParts.map((p) => p.offset)).toEqual([0, 3])
|
|
85
|
+
expect(new Set(textParts.map((p) => p.id)).size).toBe(1)
|
|
72
86
|
expect(textParts.reduce((acc, p) => acc + p.text, '')).toBe('Hello')
|
|
73
87
|
})
|
|
74
88
|
|
|
75
|
-
|
|
89
|
+
// F1: withPartIds is only ever a normalizer for DRAFTS (id-less parts). aiSdkTransport mints its
|
|
90
|
+
// own deterministic, content-stable ids for every part it yields (`${chatId}#${index}`,
|
|
91
|
+
// `tool#${toolCallId}`, `${chatId}#start`), so wrapping its output in withPartIds must be a pure
|
|
92
|
+
// no-op passthrough — proves the two hooks wiring withPartIds in unconditionally does not disturb
|
|
93
|
+
// a transport that already identifies its own parts.
|
|
94
|
+
test('withPartIds is a no-op over aiSdkTransport output — every part already carries a stable id', async () => {
|
|
95
|
+
const chunks: UIMessageChunk[] = [
|
|
96
|
+
{ type: 'text-start', id: 't1' },
|
|
97
|
+
{ type: 'text-delta', id: 't1', delta: 'Hel' },
|
|
98
|
+
{ type: 'text-delta', id: 't1', delta: 'lo' },
|
|
99
|
+
{ type: 'text-end', id: 't1' },
|
|
100
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
|
|
101
|
+
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
|
|
102
|
+
{ type: 'tool-output-available', toolCallId: 'call-1', output: { hits: 3 } },
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
// Same transport instance for both sides — aiSdkTransport mints its chat id at CONSTRUCTION
|
|
106
|
+
// time, so two separately-constructed transports would mint two different chat ids and
|
|
107
|
+
// trivially fail to match on that alone, masking whether withPartIds itself changed anything.
|
|
108
|
+
const transport = aiSdkTransport({
|
|
109
|
+
api: '/api/chat',
|
|
110
|
+
fetch: async () => scriptedResponse(chunks),
|
|
111
|
+
})
|
|
112
|
+
const direct = await collect(transport.stream('hi'))
|
|
113
|
+
const wrapped = await collect(withPartIds('unrelated-run-id', transport.stream('hi')))
|
|
114
|
+
|
|
115
|
+
expect(wrapped.map(withoutDurationMs)).toEqual(direct.map(withoutDurationMs))
|
|
116
|
+
// Every part already had an id — withPartIds' counter never had anything id-less to stamp.
|
|
117
|
+
expect(direct.every((part) => typeof (part as { id: string }).id === 'string')).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('diffs reasoning deltas the same way as text, with the same id/offset mechanism', async () => {
|
|
76
121
|
const chunks: UIMessageChunk[] = [
|
|
77
122
|
{ type: 'reasoning-start', id: 'r1' },
|
|
78
123
|
{ type: 'reasoning-delta', id: 'r1', delta: 'thinking ' },
|
|
@@ -85,14 +130,17 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
85
130
|
})
|
|
86
131
|
const parts = await collect(transport.stream('hi'))
|
|
87
132
|
|
|
88
|
-
const reasoningParts = parts.filter((p) => p.type === 'reasoning')
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
]
|
|
133
|
+
const reasoningParts = parts.filter((p) => p.type === 'reasoning') as {
|
|
134
|
+
id: string
|
|
135
|
+
text: string
|
|
136
|
+
offset: number
|
|
137
|
+
}[]
|
|
138
|
+
expect(reasoningParts.map((p) => p.text)).toEqual(['thinking ', 'more'])
|
|
139
|
+
expect(reasoningParts.map((p) => p.offset)).toEqual([0, 9])
|
|
140
|
+
expect(new Set(reasoningParts.map((p) => p.id)).size).toBe(1)
|
|
93
141
|
})
|
|
94
142
|
|
|
95
|
-
test('emits a source part exactly once for a source-url chunk', async () => {
|
|
143
|
+
test('emits a source part exactly once for a source-url chunk, with a deterministic id', async () => {
|
|
96
144
|
const chunks: UIMessageChunk[] = [
|
|
97
145
|
{ type: 'source-url', sourceId: 's1', url: 'https://example.com', title: 'Example' },
|
|
98
146
|
]
|
|
@@ -103,10 +151,16 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
103
151
|
const parts = await collect(transport.stream('hi'))
|
|
104
152
|
|
|
105
153
|
const sourceParts = parts.filter((p) => p.type === 'source')
|
|
106
|
-
expect(sourceParts).
|
|
154
|
+
expect(sourceParts).toHaveLength(1)
|
|
155
|
+
expect(sourceParts[0]).toMatchObject({
|
|
156
|
+
type: 'source',
|
|
157
|
+
url: 'https://example.com',
|
|
158
|
+
title: 'Example',
|
|
159
|
+
})
|
|
160
|
+
expect(typeof (sourceParts[0] as { id: string }).id).toBe('string')
|
|
107
161
|
})
|
|
108
162
|
|
|
109
|
-
test('a tool call progresses input-available → output-available
|
|
163
|
+
test('a tool call progresses input-streaming → input-available → output-available, one draft per state, addressed by tool#<toolCallId>', async () => {
|
|
110
164
|
const chunks: UIMessageChunk[] = [
|
|
111
165
|
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
|
|
112
166
|
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
|
|
@@ -119,18 +173,36 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
119
173
|
const parts = await collect(transport.stream('hi'))
|
|
120
174
|
|
|
121
175
|
const toolParts = parts.filter((p) => p.type === 'tool')
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
176
|
+
expect(toolParts.every((p) => (p as { id: string }).id === 'tool#call-1')).toBe(true)
|
|
177
|
+
expect(toolParts.map(withoutDurationMs)).toEqual([
|
|
178
|
+
{
|
|
179
|
+
id: 'tool#call-1',
|
|
180
|
+
type: 'tool',
|
|
181
|
+
state: 'input-streaming',
|
|
182
|
+
toolName: 'search',
|
|
183
|
+
toolCallId: 'call-1',
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'tool#call-1',
|
|
187
|
+
type: 'tool',
|
|
188
|
+
state: 'input-available',
|
|
189
|
+
toolName: 'search',
|
|
190
|
+
toolCallId: 'call-1',
|
|
191
|
+
input: { q: 'x' },
|
|
192
|
+
},
|
|
126
193
|
{
|
|
194
|
+
id: 'tool#call-1',
|
|
127
195
|
type: 'tool',
|
|
196
|
+
state: 'output-available',
|
|
128
197
|
toolName: 'search',
|
|
129
198
|
toolCallId: 'call-1',
|
|
130
199
|
input: { q: 'x' },
|
|
131
200
|
output: { hits: 3 },
|
|
132
201
|
},
|
|
133
202
|
])
|
|
203
|
+
// durationMs is only set once the call reaches a terminal state (output-available here).
|
|
204
|
+
expect((toolParts[0] as { durationMs?: number }).durationMs).toBeUndefined()
|
|
205
|
+
expect(typeof (toolParts.at(-1) as { durationMs?: number }).durationMs).toBe('number')
|
|
134
206
|
})
|
|
135
207
|
|
|
136
208
|
test('a repeated chunk carrying the SAME tool state is deduped — nothing new to report', async () => {
|
|
@@ -148,14 +220,141 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
148
220
|
const parts = await collect(transport.stream('hi'))
|
|
149
221
|
|
|
150
222
|
const toolParts = parts.filter((p) => p.type === 'tool')
|
|
151
|
-
|
|
223
|
+
// input-streaming once, input-available once (redundant resend deduped), output-available
|
|
224
|
+
// once — three states, not four.
|
|
225
|
+
expect(toolParts).toHaveLength(3)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('a preliminary output-available result emits EVERY refinement, not just the first (bug: state-equality alone would freeze on the first snapshot)', async () => {
|
|
229
|
+
const chunks: UIMessageChunk[] = [
|
|
230
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
|
|
231
|
+
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
|
|
232
|
+
{
|
|
233
|
+
type: 'tool-output-available',
|
|
234
|
+
toolCallId: 'call-1',
|
|
235
|
+
output: { hits: 1 },
|
|
236
|
+
preliminary: true,
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
type: 'tool-output-available',
|
|
240
|
+
toolCallId: 'call-1',
|
|
241
|
+
output: { hits: 5 },
|
|
242
|
+
preliminary: true,
|
|
243
|
+
},
|
|
244
|
+
{ type: 'tool-output-available', toolCallId: 'call-1', output: { hits: 5, done: true } },
|
|
245
|
+
]
|
|
246
|
+
const transport = aiSdkTransport({
|
|
247
|
+
api: '/api/chat',
|
|
248
|
+
fetch: async () => scriptedResponse(chunks),
|
|
249
|
+
})
|
|
250
|
+
const parts = await collect(transport.stream('hi'))
|
|
251
|
+
|
|
252
|
+
const outputs = parts
|
|
253
|
+
.filter((p) => p.type === 'tool' && p.state === 'output-available')
|
|
254
|
+
.map((p) => (p as { output: unknown; preliminary?: boolean }).output)
|
|
255
|
+
// All THREE refinements surfaced — a naive `prev.state === curr.state` dedup would have
|
|
256
|
+
// dropped the second and third (state stays 'output-available' throughout).
|
|
257
|
+
expect(outputs).toEqual([{ hits: 1 }, { hits: 5 }, { hits: 5, done: true }])
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('approval-requested → approval-responded carries the nested approval envelope through verbatim (isAutomatic and signature survive)', async () => {
|
|
261
|
+
const chunks: UIMessageChunk[] = [
|
|
262
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'delete-file' },
|
|
263
|
+
{
|
|
264
|
+
type: 'tool-input-available',
|
|
265
|
+
toolCallId: 'call-1',
|
|
266
|
+
toolName: 'delete-file',
|
|
267
|
+
input: { path: '/x' },
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
type: 'tool-approval-request',
|
|
271
|
+
approvalId: 'appr-1',
|
|
272
|
+
toolCallId: 'call-1',
|
|
273
|
+
isAutomatic: true,
|
|
274
|
+
signature: 'sig-xyz',
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
type: 'tool-approval-response',
|
|
278
|
+
approvalId: 'appr-1',
|
|
279
|
+
approved: true,
|
|
280
|
+
reason: 'looks safe',
|
|
281
|
+
},
|
|
282
|
+
]
|
|
283
|
+
const transport = aiSdkTransport({
|
|
284
|
+
api: '/api/chat',
|
|
285
|
+
fetch: async () => scriptedResponse(chunks),
|
|
286
|
+
})
|
|
287
|
+
const parts = await collect(transport.stream('hi'))
|
|
288
|
+
|
|
289
|
+
const toolParts = parts.filter((p) => p.type === 'tool') as {
|
|
290
|
+
state: string
|
|
291
|
+
approval?: {
|
|
292
|
+
id: string
|
|
293
|
+
approved?: boolean
|
|
294
|
+
reason?: string
|
|
295
|
+
isAutomatic?: boolean
|
|
296
|
+
signature?: string
|
|
297
|
+
}
|
|
298
|
+
}[]
|
|
299
|
+
|
|
300
|
+
const requested = toolParts.find((p) => p.state === 'approval-requested')
|
|
301
|
+
// signature only exists on the SDK's approval-requested envelope — this pins that OUR diffing
|
|
302
|
+
// does not drop it (a flattened `approvalId`/`approved` shape, the spec's own error, would).
|
|
303
|
+
expect(requested?.approval).toEqual({ id: 'appr-1', isAutomatic: true, signature: 'sig-xyz' })
|
|
304
|
+
|
|
305
|
+
const responded = toolParts.find((p) => p.state === 'approval-responded')
|
|
306
|
+
// AI SDK's OWN runtime carries `isAutomatic` forward into approval-responded but drops
|
|
307
|
+
// `signature` there (verified against dist/index.js) — this is the SDK's behavior, not a bug
|
|
308
|
+
// in this diffing layer; what this pins is that basalt passes whatever the SDK gives it
|
|
309
|
+
// through unflattened, not that basalt itself re-adds a dropped field.
|
|
310
|
+
expect(responded?.approval).toEqual({
|
|
311
|
+
id: 'appr-1',
|
|
312
|
+
approved: true,
|
|
313
|
+
reason: 'looks safe',
|
|
314
|
+
isAutomatic: true,
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test('output-denied is unambiguously terminal — no output, no errorText, just the explicit state + a settled durationMs', async () => {
|
|
319
|
+
const chunks: UIMessageChunk[] = [
|
|
320
|
+
{ type: 'tool-input-start', toolCallId: 'call-2', toolName: 'delete-file' },
|
|
321
|
+
{
|
|
322
|
+
type: 'tool-input-available',
|
|
323
|
+
toolCallId: 'call-2',
|
|
324
|
+
toolName: 'delete-file',
|
|
325
|
+
input: { path: '/etc' },
|
|
326
|
+
},
|
|
327
|
+
{ type: 'tool-approval-request', approvalId: 'appr-2', toolCallId: 'call-2' },
|
|
328
|
+
{ type: 'tool-approval-response', approvalId: 'appr-2', approved: false, reason: 'blocked' },
|
|
329
|
+
{ type: 'tool-output-denied', toolCallId: 'call-2' },
|
|
330
|
+
]
|
|
331
|
+
const transport = aiSdkTransport({
|
|
332
|
+
api: '/api/chat',
|
|
333
|
+
fetch: async () => scriptedResponse(chunks),
|
|
334
|
+
})
|
|
335
|
+
const parts = await collect(transport.stream('hi'))
|
|
336
|
+
|
|
337
|
+
const toolParts = parts.filter((p) => p.type === 'tool')
|
|
338
|
+
const denied = toolParts.at(-1) as {
|
|
339
|
+
state: string
|
|
340
|
+
output?: unknown
|
|
341
|
+
errorText?: unknown
|
|
342
|
+
input: unknown
|
|
343
|
+
approval?: unknown
|
|
344
|
+
durationMs?: number
|
|
345
|
+
}
|
|
346
|
+
expect(denied.state).toBe('output-denied')
|
|
347
|
+
expect('output' in denied).toBe(false)
|
|
348
|
+
expect('errorText' in denied).toBe(false)
|
|
349
|
+
expect(denied.input).toEqual({ path: '/etc' })
|
|
350
|
+
expect(denied.approval).toEqual({ id: 'appr-2', approved: false, reason: 'blocked' })
|
|
351
|
+
expect(typeof denied.durationMs).toBe('number')
|
|
152
352
|
})
|
|
153
353
|
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
test('output-error is currently smuggled through `output`, not a dedicated error field', async () => {
|
|
354
|
+
// ToolCallPart's `output-error` state carries a dedicated `errorText` field (the SDK's own field
|
|
355
|
+
// name — there is no field named `error` anywhere in the union). Stuffing the failure into
|
|
356
|
+
// `output` is gone: `output-error` doesn't even have an `output` field to smuggle it through.
|
|
357
|
+
test('output-error surfaces a dedicated `errorText` field, not `output`, with input carried forward', async () => {
|
|
159
358
|
const chunks: UIMessageChunk[] = [
|
|
160
359
|
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
|
|
161
360
|
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
|
|
@@ -169,13 +368,40 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
169
368
|
|
|
170
369
|
const toolParts = parts.filter((p) => p.type === 'tool')
|
|
171
370
|
const failed = toolParts.at(-1)
|
|
172
|
-
expect(failed).toEqual({
|
|
371
|
+
expect(withoutDurationMs(failed as { durationMs?: number })).toEqual({
|
|
372
|
+
id: 'tool#call-1',
|
|
173
373
|
type: 'tool',
|
|
374
|
+
state: 'output-error',
|
|
174
375
|
toolName: 'search',
|
|
175
376
|
toolCallId: 'call-1',
|
|
176
377
|
input: { q: 'x' },
|
|
177
|
-
|
|
378
|
+
errorText: 'boom',
|
|
379
|
+
})
|
|
380
|
+
expect('output' in (failed as object)).toBe(false)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test('a dynamic tool call yields its REAL toolName, not the literal "dynamic-tool"', async () => {
|
|
384
|
+
const chunks: UIMessageChunk[] = [
|
|
385
|
+
{ type: 'tool-input-start', toolCallId: 'call-4', toolName: 'custom-lookup', dynamic: true },
|
|
386
|
+
{
|
|
387
|
+
type: 'tool-input-available',
|
|
388
|
+
toolCallId: 'call-4',
|
|
389
|
+
toolName: 'custom-lookup',
|
|
390
|
+
input: { id: 7 },
|
|
391
|
+
dynamic: true,
|
|
392
|
+
},
|
|
393
|
+
{ type: 'tool-output-available', toolCallId: 'call-4', output: { found: true } },
|
|
394
|
+
]
|
|
395
|
+
const transport = aiSdkTransport({
|
|
396
|
+
api: '/api/chat',
|
|
397
|
+
fetch: async () => scriptedResponse(chunks),
|
|
178
398
|
})
|
|
399
|
+
const parts = await collect(transport.stream('hi'))
|
|
400
|
+
|
|
401
|
+
const toolParts = parts.filter((p) => p.type === 'tool') as { toolName: string; id: string }[]
|
|
402
|
+
expect(toolParts.length).toBeGreaterThan(0)
|
|
403
|
+
expect(toolParts.every((p) => p.toolName === 'custom-lookup')).toBe(true)
|
|
404
|
+
expect(toolParts.every((p) => p.id === 'tool#call-4')).toBe(true)
|
|
179
405
|
})
|
|
180
406
|
})
|
|
181
407
|
|
|
@@ -197,14 +423,14 @@ describe('aiSdkTransport — signal.aborted stops the yield', () => {
|
|
|
197
423
|
const start = await gen.next()
|
|
198
424
|
expect(start.value).toMatchObject({ type: 'start' })
|
|
199
425
|
const firstDelta = await gen.next()
|
|
200
|
-
expect(firstDelta.value).
|
|
426
|
+
expect(firstDelta.value).toMatchObject({ type: 'text', text: 'a', offset: 0 })
|
|
201
427
|
|
|
202
428
|
// Let AI SDK's background readUIMessageStream pump (kicked off inside diffChunkStream, and
|
|
203
429
|
// NOT gated on our own reads) fully settle before aborting — this is inherent to the library,
|
|
204
430
|
// not to the AgentTransport code under test: aborting while chunks are still in flight through
|
|
205
431
|
// that pump races diffChunkStream's early return against the pump's own stream teardown. A
|
|
206
432
|
// short real-time wait avoids exercising that unrelated race so this test isolates exactly
|
|
207
|
-
// ai-sdk-transport.ts
|
|
433
|
+
// ai-sdk-transport.ts's `if (signal?.aborted) return`.
|
|
208
434
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
209
435
|
|
|
210
436
|
controller.abort()
|
|
@@ -231,7 +457,7 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
|
|
|
231
457
|
expect((first.value as { runId: string }).runId).toBe((second.value as { runId: string }).runId)
|
|
232
458
|
})
|
|
233
459
|
|
|
234
|
-
test('.forThread(id) binds the StartPart runId/resumeToken deterministically to the given id', async () => {
|
|
460
|
+
test('.forThread(id) binds the StartPart runId/resumeToken/id deterministically to the given id', async () => {
|
|
235
461
|
const transport = aiSdkTransport({
|
|
236
462
|
api: '/api/chat',
|
|
237
463
|
fetch: () => {
|
|
@@ -244,6 +470,7 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
|
|
|
244
470
|
const second = await bound.stream('second turn').next()
|
|
245
471
|
|
|
246
472
|
expect(first.value).toEqual({
|
|
473
|
+
id: 'caller-supplied-thread-id#start',
|
|
247
474
|
type: 'start',
|
|
248
475
|
runId: 'caller-supplied-thread-id',
|
|
249
476
|
resumeToken: 'caller-supplied-thread-id',
|
|
@@ -252,7 +479,7 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
|
|
|
252
479
|
expect(second.value).toEqual(first.value)
|
|
253
480
|
})
|
|
254
481
|
|
|
255
|
-
test('two different .forThread() ids produce two different StartPart runIds', async () => {
|
|
482
|
+
test('two different .forThread() ids produce two different StartPart runIds/ids', async () => {
|
|
256
483
|
const transport = aiSdkTransport({
|
|
257
484
|
api: '/api/chat',
|
|
258
485
|
fetch: () => {
|
|
@@ -263,7 +490,78 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
|
|
|
263
490
|
const a = await transport.forThread('thread-a').stream('hi').next()
|
|
264
491
|
const b = await transport.forThread('thread-b').stream('hi').next()
|
|
265
492
|
|
|
266
|
-
expect(a.value).toEqual({
|
|
267
|
-
|
|
493
|
+
expect(a.value).toEqual({
|
|
494
|
+
id: 'thread-a#start',
|
|
495
|
+
type: 'start',
|
|
496
|
+
runId: 'thread-a',
|
|
497
|
+
resumeToken: 'thread-a',
|
|
498
|
+
})
|
|
499
|
+
expect(b.value).toEqual({
|
|
500
|
+
id: 'thread-b#start',
|
|
501
|
+
type: 'start',
|
|
502
|
+
runId: 'thread-b',
|
|
503
|
+
resumeToken: 'thread-b',
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
describe('aiSdkTransport — deterministic ids and replay idempotency', () => {
|
|
509
|
+
test('the same snapshot sequence produces the same ids twice', async () => {
|
|
510
|
+
const chunks: UIMessageChunk[] = [
|
|
511
|
+
{ type: 'text-start', id: 't1' },
|
|
512
|
+
{ type: 'text-delta', id: 't1', delta: 'Hello' },
|
|
513
|
+
{ type: 'text-end', id: 't1' },
|
|
514
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
|
|
515
|
+
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
|
|
516
|
+
]
|
|
517
|
+
const transport = aiSdkTransport({
|
|
518
|
+
api: '/api/chat',
|
|
519
|
+
fetch: async () => scriptedResponse(chunks),
|
|
520
|
+
}).forThread('thread-x')
|
|
521
|
+
|
|
522
|
+
const firstIds = (await collect(transport.stream('hi'))).map((p) => (p as { id: string }).id)
|
|
523
|
+
const secondIds = (await collect(transport.stream('hi'))).map((p) => (p as { id: string }).id)
|
|
524
|
+
|
|
525
|
+
expect(secondIds).toEqual(firstIds)
|
|
526
|
+
expect(firstIds).toEqual(['thread-x#start', 'thread-x#0', 'tool#call-1', 'tool#call-1'])
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
test('replaying the identical delta sequence through mergePart converges — it does not double the parts array (the defect this lane exists to make impossible)', async () => {
|
|
530
|
+
const chunks: UIMessageChunk[] = [
|
|
531
|
+
{ type: 'text-start', id: 't1' },
|
|
532
|
+
{ type: 'text-delta', id: 't1', delta: 'Hel' },
|
|
533
|
+
{ type: 'text-delta', id: 't1', delta: 'lo' },
|
|
534
|
+
{ type: 'text-end', id: 't1' },
|
|
535
|
+
]
|
|
536
|
+
const transport = aiSdkTransport({
|
|
537
|
+
api: '/api/chat',
|
|
538
|
+
fetch: async () => scriptedResponse(chunks),
|
|
539
|
+
}).forThread('thread-x')
|
|
540
|
+
|
|
541
|
+
const firstPass = await collect(transport.stream('hi'))
|
|
542
|
+
let parts: AgentPart[] = []
|
|
543
|
+
for (const part of firstPass) parts = mergePart(parts, part)
|
|
544
|
+
const afterFirstPass = parts
|
|
545
|
+
expect(afterFirstPass).toHaveLength(2) // start + one merged text part
|
|
546
|
+
|
|
547
|
+
// Simulate a resume()/replay that re-diffs the SAME chunk sequence from scratch (a fresh
|
|
548
|
+
// diffChunkStream call starts `prev = undefined` again, so the first snapshot it sees carries
|
|
549
|
+
// the FULL accumulated text at offset 0 — exactly what a real reconnectToStream reconnect
|
|
550
|
+
// looks like). Same bound transport (same chatId) ⇒ identical ids.
|
|
551
|
+
const secondPass = await collect(transport.stream('hi'))
|
|
552
|
+
for (const part of secondPass) parts = mergePart(parts, part)
|
|
553
|
+
|
|
554
|
+
expect(parts).toEqual(afterFirstPass)
|
|
555
|
+
expect(parts).toHaveLength(2)
|
|
556
|
+
const merged = parts.find((p) => p.type === 'text') as { text: string }
|
|
557
|
+
expect(merged.text).toBe('Hello')
|
|
558
|
+
|
|
559
|
+
// What this guards against: naive array-append accumulation (`[...prev, part]`, this lane's
|
|
560
|
+
// predecessor) would have appended the second pass's deltas onto the first pass's, doubling
|
|
561
|
+
// (and garbling) the rendered text instead of converging.
|
|
562
|
+
const naiveAppendTextParts = [...firstPass, ...secondPass].filter((p) => p.type === 'text')
|
|
563
|
+
expect(naiveAppendTextParts.length).toBeGreaterThan(
|
|
564
|
+
parts.filter((p) => p.type === 'text').length,
|
|
565
|
+
)
|
|
268
566
|
})
|
|
269
567
|
})
|