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.
Files changed (88) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +10 -0
  3. package/dist/agent/ai-sdk-transport.d.ts +3 -52
  4. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  5. package/dist/agent/ai-sdk-transport.js +108 -24
  6. package/dist/agent/coalesce.d.ts +26 -0
  7. package/dist/agent/coalesce.d.ts.map +1 -0
  8. package/dist/agent/coalesce.js +69 -0
  9. package/dist/agent/foreign.d.ts +97 -0
  10. package/dist/agent/foreign.d.ts.map +1 -0
  11. package/dist/agent/foreign.js +18 -0
  12. package/dist/agent/history.d.ts +2 -0
  13. package/dist/agent/history.d.ts.map +1 -1
  14. package/dist/agent/id.d.ts +35 -0
  15. package/dist/agent/id.d.ts.map +1 -0
  16. package/dist/agent/id.js +14 -0
  17. package/dist/agent/index.d.ts +9 -4
  18. package/dist/agent/index.d.ts.map +1 -1
  19. package/dist/agent/index.js +15 -3
  20. package/dist/agent/merge.d.ts +8 -0
  21. package/dist/agent/merge.d.ts.map +1 -0
  22. package/dist/agent/merge.js +23 -0
  23. package/dist/agent/part-list.d.ts.map +1 -1
  24. package/dist/agent/part-list.js +16 -6
  25. package/dist/agent/parts.d.ts +100 -21
  26. package/dist/agent/parts.d.ts.map +1 -1
  27. package/dist/agent/parts.js +128 -11
  28. package/dist/agent/transport.d.ts +39 -3
  29. package/dist/agent/transport.d.ts.map +1 -1
  30. package/dist/agent/transport.js +20 -17
  31. package/dist/agent/use-agent-stream.d.ts +2 -1
  32. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  33. package/dist/agent/use-agent-stream.js +5 -2
  34. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  35. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  36. package/dist/agent/use-agent-thread-runs.js +75 -23
  37. package/dist/agent-chat/index.d.ts +2 -0
  38. package/dist/agent-chat/index.d.ts.map +1 -1
  39. package/dist/agent-chat/index.js +2 -0
  40. package/dist/agent-chat/thread-message.d.ts +24 -7
  41. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  42. package/dist/agent-chat/thread-message.js +102 -43
  43. package/dist/agent-chat/tool-chip.d.ts +21 -0
  44. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  45. package/dist/agent-chat/tool-chip.js +120 -0
  46. package/dist/guard/index.d.ts.map +1 -1
  47. package/dist/guard/index.js +1 -3
  48. package/dist/index.d.ts +2 -2
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +3 -1
  51. package/dist/surfaces.d.ts +2 -2
  52. package/dist/surfaces.js +2 -2
  53. package/llms.txt +4 -4
  54. package/package.json +1 -1
  55. package/src/agent/ai-sdk-transport.test.ts +338 -40
  56. package/src/agent/ai-sdk-transport.ts +206 -53
  57. package/src/agent/coalesce.test.ts +194 -0
  58. package/src/agent/coalesce.ts +118 -0
  59. package/src/agent/foreign.test.ts +47 -0
  60. package/src/agent/foreign.ts +140 -0
  61. package/src/agent/history.ts +2 -0
  62. package/src/agent/id.test.ts +71 -0
  63. package/src/agent/id.ts +43 -0
  64. package/src/agent/index.ts +26 -3
  65. package/src/agent/merge.test.ts +119 -0
  66. package/src/agent/merge.ts +67 -0
  67. package/src/agent/part-list.test.tsx +82 -0
  68. package/src/agent/part-list.tsx +31 -6
  69. package/src/agent/parts.test.ts +401 -59
  70. package/src/agent/parts.ts +262 -32
  71. package/src/agent/transport.test.ts +70 -0
  72. package/src/agent/transport.ts +72 -20
  73. package/src/agent/use-agent-stream.test.tsx +47 -17
  74. package/src/agent/use-agent-stream.ts +13 -3
  75. package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
  76. package/src/agent/use-agent-thread-runs.resume.test.tsx +34 -6
  77. package/src/agent/use-agent-thread-runs.test.tsx +397 -14
  78. package/src/agent/use-agent-thread-runs.ts +170 -26
  79. package/src/agent/use-agent-thread-runs.wedge.test.tsx +4 -2
  80. package/src/agent-chat/index.ts +4 -0
  81. package/src/agent-chat/thread-message.test.tsx +211 -0
  82. package/src/agent-chat/thread-message.tsx +235 -62
  83. package/src/agent-chat/tool-chip.test.tsx +139 -0
  84. package/src/agent-chat/tool-chip.tsx +219 -0
  85. package/src/guard/check-source.test.ts +8 -4
  86. package/src/guard/index.ts +1 -3
  87. package/src/index.ts +2 -0
  88. package/src/surfaces.ts +2 -2
@@ -1,11 +1,13 @@
1
1
  /**
2
- * aiSdkTransport — snapshot→delta diffing (text/reasoning/source/tool), signal-abort mid-stream,
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 AS IT EXISTS TODAY (B1) — no seven-state ToolCallPart, no
6
- * `output-error` carrying a dedicated `error` field (today it is smuggled through `output`, see
7
- * the `output-error` test below), no `durationMs` on terminal states. Those are 1.10.0 part-
8
- * identity work; this file pins the CURRENT shape so that work has a baseline to diff against.
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
- const textParts = parts.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
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
- expect(textParts).toEqual([
69
- { type: 'text', text: 'Hel' },
70
- { type: 'text', text: 'lo' },
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
- test('diffs reasoning deltas the same way as text', async () => {
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
- expect(reasoningParts).toEqual([
90
- { type: 'reasoning', text: 'thinking ' },
91
- { type: 'reasoning', text: 'more' },
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).toEqual([{ type: 'source', url: 'https://example.com', title: 'Example' }])
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 with the accumulated output', async () => {
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
- // input-streaming is never emitted (deliberate v1 simplification) — only input-available then
123
- // output-available surface, one AgentPart each.
124
- expect(toolParts).toEqual([
125
- { type: 'tool', toolName: 'search', toolCallId: 'call-1', input: { q: 'x' } },
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
- expect(toolParts).toHaveLength(2) // input-available once, output-available once — not three
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
- // 1.10.0 will give ToolCallPart a dedicated error state/field (the seven-state union named in
155
- // this file's scope note). TODAY, ai-sdk-transport.ts:187-188 smuggles a tool failure through
156
- // the ordinary `output` field as `{ error: errorText }` — this test pins THAT current shape so
157
- // the 1.10.0 change has a documented baseline to diff against.
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
- output: { error: 'boom' },
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).toEqual({ type: 'text', text: 'a' })
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:218's `if (signal?.aborted) return`.
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({ type: 'start', runId: 'thread-a', resumeToken: 'thread-a' })
267
- expect(b.value).toEqual({ type: 'start', runId: 'thread-b', resumeToken: 'thread-b' })
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
  })