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,15 +1,16 @@
1
1
  /**
2
- * parseAgentPart / isStartPart — the runtime narrowing guard over the AgentPart wire format.
2
+ * parseAgentPart / isStartPart / isToolCallSettled — the runtime narrowing guard over the
3
+ * AgentPart wire format, post-1.11.0 part identity + the seven-state ToolCallPart union.
3
4
  *
4
- * Scope: the parser AS IT EXISTS TODAY (B1) — no required `id`/`toolCallId`, no `AgentPartDraft`,
5
- * no seven-state ToolCallPart union; those are 1.10.0 part-identity work landing on top of this
6
- * currently-untested parser (see the sibling `ai-sdk-transport.test.ts`'s scope note for the same
7
- * cutoff). Every AgentPart variant below is validated against parts.ts:87's `parseAgentPart` and
8
- * parts.ts:134's `isStartPart` as they read right now.
5
+ * Every AgentPart variant below is validated against parts.ts's `parseAgentPart`, `isStartPart`,
6
+ * and `isToolCallSettled` as they read right now: `id` is required on every variant, and `tool`
7
+ * parts are validated against the seven-state discriminated union mirroring AI SDK v7's
8
+ * `UIToolInvocation` (see parts.ts's ToolCallPart doc for the ground-truth corrections against the
9
+ * design spec).
9
10
  */
10
11
  import { describe, expect, test } from 'bun:test'
11
- import { isStartPart, parseAgentPart } from './parts'
12
- import type { AgentPart } from './parts'
12
+ import { isStartPart, isToolCallSettled, parseAgentPart } from './parts'
13
+ import type { AgentPart, ToolCallPart } from './parts'
13
14
 
14
15
  /** JSON round-trip a value through JSON.stringify → JSON.parse, mirroring wire transport. */
15
16
  function roundTrip(value: unknown): unknown {
@@ -19,13 +20,14 @@ function roundTrip(value: unknown): unknown {
19
20
  describe('parseAgentPart', () => {
20
21
  describe('start', () => {
21
22
  test('parses a minimal start part (no resumeToken)', () => {
22
- const raw = { type: 'start', runId: 'run-1' }
23
- expect(parseAgentPart(raw)).toEqual({ type: 'start', runId: 'run-1' })
23
+ const raw = { id: 'p1', type: 'start', runId: 'run-1' }
24
+ expect(parseAgentPart(raw)).toEqual({ id: 'p1', type: 'start', runId: 'run-1' })
24
25
  })
25
26
 
26
27
  test('parses a start part with resumeToken', () => {
27
- const raw = { type: 'start', runId: 'run-1', resumeToken: 'tok-1' }
28
+ const raw = { id: 'p1', type: 'start', runId: 'run-1', resumeToken: 'tok-1' }
28
29
  expect(parseAgentPart(raw)).toEqual({
30
+ id: 'p1',
29
31
  type: 'start',
30
32
  runId: 'run-1',
31
33
  resumeToken: 'tok-1',
@@ -33,120 +35,397 @@ describe('parseAgentPart', () => {
33
35
  })
34
36
 
35
37
  test('drops a non-string resumeToken rather than passing it through', () => {
36
- const raw = { type: 'start', runId: 'run-1', resumeToken: 42 }
37
- expect(parseAgentPart(raw)).toEqual({ type: 'start', runId: 'run-1' })
38
+ const raw = { id: 'p1', type: 'start', runId: 'run-1', resumeToken: 42 }
39
+ expect(parseAgentPart(raw)).toEqual({ id: 'p1', type: 'start', runId: 'run-1' })
38
40
  })
39
41
 
40
42
  test('rejects a missing runId', () => {
41
- expect(parseAgentPart({ type: 'start' })).toBeNull()
43
+ expect(parseAgentPart({ id: 'p1', type: 'start' })).toBeNull()
42
44
  })
43
45
 
44
46
  test('rejects a non-string runId', () => {
45
- expect(parseAgentPart({ type: 'start', runId: 42 })).toBeNull()
47
+ expect(parseAgentPart({ id: 'p1', type: 'start', runId: 42 })).toBeNull()
48
+ })
49
+
50
+ test('rejects a missing id', () => {
51
+ expect(parseAgentPart({ type: 'start', runId: 'run-1' })).toBeNull()
46
52
  })
47
53
 
48
54
  test('round-trips through JSON', () => {
49
- const part: AgentPart = { type: 'start', runId: 'run-1', resumeToken: 'tok-1' }
55
+ const part: AgentPart = { id: 'p1', type: 'start', runId: 'run-1', resumeToken: 'tok-1' }
50
56
  expect(parseAgentPart(roundTrip(part))).toEqual(part)
51
57
  })
52
58
  })
53
59
 
54
60
  describe('text', () => {
55
61
  test('parses a text part', () => {
56
- expect(parseAgentPart({ type: 'text', text: 'hello' })).toEqual({
62
+ expect(parseAgentPart({ id: 'p1', type: 'text', text: 'hello' })).toEqual({
63
+ id: 'p1',
57
64
  type: 'text',
58
65
  text: 'hello',
59
66
  })
60
67
  })
61
68
 
69
+ test('parses a text part with offset', () => {
70
+ const raw = { id: 'p1', type: 'text', text: 'llo', offset: 2 }
71
+ expect(parseAgentPart(raw)).toEqual({ id: 'p1', type: 'text', text: 'llo', offset: 2 })
72
+ })
73
+
62
74
  test('rejects a missing text field', () => {
63
- expect(parseAgentPart({ type: 'text' })).toBeNull()
75
+ expect(parseAgentPart({ id: 'p1', type: 'text' })).toBeNull()
64
76
  })
65
77
 
66
78
  test('rejects a non-string text field', () => {
67
- expect(parseAgentPart({ type: 'text', text: 1 })).toBeNull()
79
+ expect(parseAgentPart({ id: 'p1', type: 'text', text: 1 })).toBeNull()
80
+ })
81
+
82
+ test('rejects a missing id', () => {
83
+ expect(parseAgentPart({ type: 'text', text: 'hello' })).toBeNull()
68
84
  })
69
85
 
70
86
  test('round-trips through JSON', () => {
71
- const part: AgentPart = { type: 'text', text: 'hello' }
87
+ const part: AgentPart = { id: 'p1', type: 'text', text: 'hello', offset: 0 }
72
88
  expect(parseAgentPart(roundTrip(part))).toEqual(part)
73
89
  })
74
90
  })
75
91
 
76
92
  describe('reasoning', () => {
77
93
  test('parses a reasoning part', () => {
78
- expect(parseAgentPart({ type: 'reasoning', text: 'thinking...' })).toEqual({
94
+ expect(parseAgentPart({ id: 'p1', type: 'reasoning', text: 'thinking...' })).toEqual({
95
+ id: 'p1',
79
96
  type: 'reasoning',
80
97
  text: 'thinking...',
81
98
  })
82
99
  })
83
100
 
84
101
  test('rejects a missing text field', () => {
85
- expect(parseAgentPart({ type: 'reasoning' })).toBeNull()
102
+ expect(parseAgentPart({ id: 'p1', type: 'reasoning' })).toBeNull()
86
103
  })
87
104
 
88
105
  test('round-trips through JSON', () => {
89
- const part: AgentPart = { type: 'reasoning', text: 'thinking...' }
106
+ const part: AgentPart = { id: 'p1', type: 'reasoning', text: 'thinking...' }
90
107
  expect(parseAgentPart(roundTrip(part))).toEqual(part)
91
108
  })
92
109
  })
93
110
 
94
111
  describe('tool', () => {
95
- test('parses a minimal tool part (input only, no output/toolCallId)', () => {
96
- const raw = { type: 'tool', toolName: 'search', input: { q: 'x' } }
97
- expect(parseAgentPart(raw)).toEqual({
112
+ test('rejects a missing toolCallId', () => {
113
+ const raw = {
114
+ id: 'p1',
98
115
  type: 'tool',
99
116
  toolName: 'search',
100
- input: { q: 'x' },
101
- })
117
+ state: 'input-available',
118
+ input: {},
119
+ }
120
+ expect(parseAgentPart(raw)).toBeNull()
121
+ })
122
+
123
+ test('rejects a missing toolName', () => {
124
+ const raw = {
125
+ id: 'p1',
126
+ type: 'tool',
127
+ toolCallId: 'call-1',
128
+ state: 'input-available',
129
+ input: {},
130
+ }
131
+ expect(parseAgentPart(raw)).toBeNull()
132
+ })
133
+
134
+ test('rejects a missing state', () => {
135
+ const raw = { id: 'p1', type: 'tool', toolCallId: 'call-1', toolName: 'search', input: {} }
136
+ expect(parseAgentPart(raw)).toBeNull()
137
+ })
138
+
139
+ test('rejects an unknown state', () => {
140
+ const raw = {
141
+ id: 'p1',
142
+ type: 'tool',
143
+ toolCallId: 'call-1',
144
+ toolName: 'search',
145
+ state: 'running',
146
+ input: {},
147
+ }
148
+ expect(parseAgentPart(raw)).toBeNull()
102
149
  })
103
150
 
104
- test('parses a tool part with output and toolCallId', () => {
151
+ test('rejects the pre-1.11.0 flat shape (no `state` field at all)', () => {
105
152
  const raw = {
153
+ id: 'p1',
106
154
  type: 'tool',
107
155
  toolName: 'search',
156
+ toolCallId: 'call-1',
108
157
  input: { q: 'x' },
109
158
  output: { hits: 3 },
159
+ }
160
+ expect(parseAgentPart(raw)).toBeNull()
161
+ })
162
+
163
+ // ── the seven states ────────────────────────────────────────────────────
164
+
165
+ test('input-streaming: parses with no input', () => {
166
+ const raw = {
167
+ id: 'p1',
168
+ type: 'tool',
110
169
  toolCallId: 'call-1',
170
+ toolName: 'search',
171
+ state: 'input-streaming',
111
172
  }
112
173
  expect(parseAgentPart(raw)).toEqual({
174
+ id: 'p1',
113
175
  type: 'tool',
176
+ toolCallId: 'call-1',
114
177
  toolName: 'search',
178
+ state: 'input-streaming',
179
+ })
180
+ })
181
+
182
+ test('input-streaming: parses with a partial input', () => {
183
+ const raw = {
184
+ id: 'p1',
185
+ type: 'tool',
186
+ toolCallId: 'call-1',
187
+ toolName: 'search',
188
+ state: 'input-streaming',
115
189
  input: { q: 'x' },
116
- output: { hits: 3 },
190
+ }
191
+ expect(parseAgentPart(raw)).toEqual({
192
+ id: 'p1',
193
+ type: 'tool',
117
194
  toolCallId: 'call-1',
195
+ toolName: 'search',
196
+ state: 'input-streaming',
197
+ input: { q: 'x' },
118
198
  })
119
199
  })
120
200
 
121
- test('rejects a missing toolName', () => {
122
- expect(parseAgentPart({ type: 'tool', input: {} })).toBeNull()
201
+ test('input-available: requires input', () => {
202
+ const raw = {
203
+ id: 'p1',
204
+ type: 'tool',
205
+ toolCallId: 'call-1',
206
+ toolName: 'search',
207
+ state: 'input-available',
208
+ }
209
+ expect(parseAgentPart(raw)).toBeNull()
123
210
  })
124
211
 
125
- test('rejects a non-string toolName', () => {
126
- expect(parseAgentPart({ type: 'tool', toolName: 1, input: {} })).toBeNull()
212
+ test('input-available: round-trips', () => {
213
+ const part: ToolCallPart = {
214
+ id: 'p1',
215
+ type: 'tool',
216
+ toolCallId: 'call-1',
217
+ toolName: 'search',
218
+ state: 'input-available',
219
+ input: { q: 'x' },
220
+ }
221
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
127
222
  })
128
223
 
129
- test('rejects a missing input key entirely (undefined input is a valid value, absent key is not)', () => {
130
- expect(parseAgentPart({ type: 'tool', toolName: 'search' })).toBeNull()
224
+ test('approval-requested: round-trips', () => {
225
+ const part: ToolCallPart = {
226
+ id: 'p1',
227
+ type: 'tool',
228
+ toolCallId: 'call-1',
229
+ toolName: 'search',
230
+ state: 'approval-requested',
231
+ input: { q: 'x' },
232
+ approval: { id: 'appr-1' },
233
+ }
234
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
131
235
  })
132
236
 
133
- test('accepts an explicit `input: undefined` (the key is present)', () => {
134
- const raw = { type: 'tool', toolName: 'search', input: undefined }
135
- expect(parseAgentPart(raw)).toEqual({ type: 'tool', toolName: 'search', input: undefined })
237
+ test('approval-requested: rejects a missing approval', () => {
238
+ const raw = {
239
+ id: 'p1',
240
+ type: 'tool',
241
+ toolCallId: 'call-1',
242
+ toolName: 'search',
243
+ state: 'approval-requested',
244
+ input: { q: 'x' },
245
+ }
246
+ expect(parseAgentPart(raw)).toBeNull()
136
247
  })
137
248
 
138
- test('drops a non-string toolCallId rather than passing it through', () => {
139
- const raw = { type: 'tool', toolName: 'search', input: {}, toolCallId: 7 }
140
- expect(parseAgentPart(raw)).toEqual({ type: 'tool', toolName: 'search', input: {} })
249
+ test('approval-requested: rejects an approval that already carries approved (a verdict, not a pending request)', () => {
250
+ const raw = {
251
+ id: 'p1',
252
+ type: 'tool',
253
+ toolCallId: 'call-1',
254
+ toolName: 'search',
255
+ state: 'approval-requested',
256
+ input: { q: 'x' },
257
+ approval: { id: 'appr-1', approved: true },
258
+ }
259
+ expect(parseAgentPart(raw)).toBeNull()
141
260
  })
142
261
 
143
- test('round-trips through JSON', () => {
144
- const part: AgentPart = {
262
+ test('approval-requested: rejects an approval that already carries a reason', () => {
263
+ const raw = {
264
+ id: 'p1',
265
+ type: 'tool',
266
+ toolCallId: 'call-1',
267
+ toolName: 'search',
268
+ state: 'approval-requested',
269
+ input: { q: 'x' },
270
+ approval: { id: 'appr-1', reason: 'too risky' },
271
+ }
272
+ expect(parseAgentPart(raw)).toBeNull()
273
+ })
274
+
275
+ test('approval-responded: round-trips with approved + reason', () => {
276
+ const part: ToolCallPart = {
277
+ id: 'p1',
278
+ type: 'tool',
279
+ toolCallId: 'call-1',
280
+ toolName: 'search',
281
+ state: 'approval-responded',
282
+ input: { q: 'x' },
283
+ approval: { id: 'appr-1', approved: true, reason: 'looks safe' },
284
+ }
285
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
286
+ })
287
+
288
+ test('approval-responded: rejects a non-boolean approved', () => {
289
+ const raw = {
290
+ id: 'p1',
291
+ type: 'tool',
292
+ toolCallId: 'call-1',
293
+ toolName: 'search',
294
+ state: 'approval-responded',
295
+ input: { q: 'x' },
296
+ approval: { id: 'appr-1' },
297
+ }
298
+ expect(parseAgentPart(raw)).toBeNull()
299
+ })
300
+
301
+ test('output-denied: round-trips (approved: false)', () => {
302
+ const part: ToolCallPart = {
303
+ id: 'p1',
145
304
  type: 'tool',
305
+ toolCallId: 'call-1',
146
306
  toolName: 'search',
307
+ state: 'output-denied',
308
+ input: { q: 'x' },
309
+ approval: { id: 'appr-1', approved: false, reason: 'too risky' },
310
+ }
311
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
312
+ })
313
+
314
+ test('output-denied: rejects approved !== false', () => {
315
+ const raw = {
316
+ id: 'p1',
317
+ type: 'tool',
318
+ toolCallId: 'call-1',
319
+ toolName: 'search',
320
+ state: 'output-denied',
321
+ input: { q: 'x' },
322
+ approval: { id: 'appr-1', approved: true },
323
+ }
324
+ expect(parseAgentPart(raw)).toBeNull()
325
+ })
326
+
327
+ test('output-available: round-trips with output + preliminary', () => {
328
+ const part: ToolCallPart = {
329
+ id: 'p1',
330
+ type: 'tool',
331
+ toolCallId: 'call-1',
332
+ toolName: 'search',
333
+ state: 'output-available',
147
334
  input: { q: 'x' },
148
335
  output: { hits: 3 },
336
+ preliminary: true,
337
+ }
338
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
339
+ })
340
+
341
+ test('output-available: an optional approval carries through', () => {
342
+ const part: ToolCallPart = {
343
+ id: 'p1',
344
+ type: 'tool',
149
345
  toolCallId: 'call-1',
346
+ toolName: 'search',
347
+ state: 'output-available',
348
+ input: { q: 'x' },
349
+ output: { hits: 3 },
350
+ approval: { id: 'appr-1', approved: true },
351
+ }
352
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
353
+ })
354
+
355
+ test('output-available: rejects a missing output', () => {
356
+ const raw = {
357
+ id: 'p1',
358
+ type: 'tool',
359
+ toolCallId: 'call-1',
360
+ toolName: 'search',
361
+ state: 'output-available',
362
+ input: { q: 'x' },
363
+ }
364
+ expect(parseAgentPart(raw)).toBeNull()
365
+ })
366
+
367
+ test('output-available: rejects approval.approved === false (a denial masquerading as output)', () => {
368
+ const raw = {
369
+ id: 'p1',
370
+ type: 'tool',
371
+ toolCallId: 'call-1',
372
+ toolName: 'search',
373
+ state: 'output-available',
374
+ input: { q: 'x' },
375
+ output: { hits: 3 },
376
+ approval: { id: 'appr-1', approved: false },
377
+ }
378
+ expect(parseAgentPart(raw)).toBeNull()
379
+ })
380
+
381
+ test('output-error: uses errorText, not error, and round-trips with rawInput', () => {
382
+ const part: ToolCallPart = {
383
+ id: 'p1',
384
+ type: 'tool',
385
+ toolCallId: 'call-1',
386
+ toolName: 'search',
387
+ state: 'output-error',
388
+ errorText: 'boom',
389
+ rawInput: '{"q": invalid json',
390
+ }
391
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
392
+ })
393
+
394
+ test('output-error: rejects a missing errorText', () => {
395
+ const raw = {
396
+ id: 'p1',
397
+ type: 'tool',
398
+ toolCallId: 'call-1',
399
+ toolName: 'search',
400
+ state: 'output-error',
401
+ }
402
+ expect(parseAgentPart(raw)).toBeNull()
403
+ })
404
+
405
+ test('output-error: rejects approval.approved === false (a denial masquerading as an error)', () => {
406
+ const raw = {
407
+ id: 'p1',
408
+ type: 'tool',
409
+ toolCallId: 'call-1',
410
+ toolName: 'search',
411
+ state: 'output-error',
412
+ errorText: 'boom',
413
+ approval: { id: 'appr-1', approved: false },
414
+ }
415
+ expect(parseAgentPart(raw)).toBeNull()
416
+ })
417
+
418
+ test('durationMs and providerExecuted carry through when present', () => {
419
+ const part: ToolCallPart = {
420
+ id: 'p1',
421
+ type: 'tool',
422
+ toolCallId: 'call-1',
423
+ toolName: 'search',
424
+ state: 'output-available',
425
+ input: { q: 'x' },
426
+ output: { hits: 3 },
427
+ durationMs: 42,
428
+ providerExecuted: true,
150
429
  }
151
430
  expect(parseAgentPart(roundTrip(part))).toEqual(part)
152
431
  })
@@ -154,15 +433,17 @@ describe('parseAgentPart', () => {
154
433
 
155
434
  describe('source', () => {
156
435
  test('parses a minimal source part (no title)', () => {
157
- expect(parseAgentPart({ type: 'source', url: 'https://example.com' })).toEqual({
436
+ expect(parseAgentPart({ id: 'p1', type: 'source', url: 'https://example.com' })).toEqual({
437
+ id: 'p1',
158
438
  type: 'source',
159
439
  url: 'https://example.com',
160
440
  })
161
441
  })
162
442
 
163
443
  test('parses a source part with title', () => {
164
- const raw = { type: 'source', url: 'https://example.com', title: 'Example' }
444
+ const raw = { id: 'p1', type: 'source', url: 'https://example.com', title: 'Example' }
165
445
  expect(parseAgentPart(raw)).toEqual({
446
+ id: 'p1',
166
447
  type: 'source',
167
448
  url: 'https://example.com',
168
449
  title: 'Example',
@@ -170,38 +451,44 @@ describe('parseAgentPart', () => {
170
451
  })
171
452
 
172
453
  test('rejects a missing url', () => {
173
- expect(parseAgentPart({ type: 'source' })).toBeNull()
454
+ expect(parseAgentPart({ id: 'p1', type: 'source' })).toBeNull()
174
455
  })
175
456
 
176
457
  test('drops a non-string title rather than passing it through', () => {
177
- const raw = { type: 'source', url: 'https://example.com', title: 7 }
178
- expect(parseAgentPart(raw)).toEqual({ type: 'source', url: 'https://example.com' })
458
+ const raw = { id: 'p1', type: 'source', url: 'https://example.com', title: 7 }
459
+ expect(parseAgentPart(raw)).toEqual({ id: 'p1', type: 'source', url: 'https://example.com' })
179
460
  })
180
461
 
181
462
  test('round-trips through JSON', () => {
182
- const part: AgentPart = { type: 'source', url: 'https://example.com', title: 'Example' }
463
+ const part: AgentPart = {
464
+ id: 'p1',
465
+ type: 'source',
466
+ url: 'https://example.com',
467
+ title: 'Example',
468
+ }
183
469
  expect(parseAgentPart(roundTrip(part))).toEqual(part)
184
470
  })
185
471
  })
186
472
 
187
473
  describe('error', () => {
188
474
  test('parses an error part', () => {
189
- expect(parseAgentPart({ type: 'error', message: 'boom' })).toEqual({
475
+ expect(parseAgentPart({ id: 'p1', type: 'error', message: 'boom' })).toEqual({
476
+ id: 'p1',
190
477
  type: 'error',
191
478
  message: 'boom',
192
479
  })
193
480
  })
194
481
 
195
482
  test('rejects a missing message', () => {
196
- expect(parseAgentPart({ type: 'error' })).toBeNull()
483
+ expect(parseAgentPart({ id: 'p1', type: 'error' })).toBeNull()
197
484
  })
198
485
 
199
486
  test('rejects a non-string message', () => {
200
- expect(parseAgentPart({ type: 'error', message: 500 })).toBeNull()
487
+ expect(parseAgentPart({ id: 'p1', type: 'error', message: 500 })).toBeNull()
201
488
  })
202
489
 
203
490
  test('round-trips through JSON', () => {
204
- const part: AgentPart = { type: 'error', message: 'boom' }
491
+ const part: AgentPart = { id: 'p1', type: 'error', message: 'boom' }
205
492
  expect(parseAgentPart(roundTrip(part))).toEqual(part)
206
493
  })
207
494
  })
@@ -225,26 +512,26 @@ describe('parseAgentPart', () => {
225
512
  })
226
513
 
227
514
  test('rejects an object with no type field', () => {
228
- expect(parseAgentPart({ text: 'hello' })).toBeNull()
515
+ expect(parseAgentPart({ id: 'p1', text: 'hello' })).toBeNull()
229
516
  })
230
517
 
231
518
  test('rejects an object with a non-string type field', () => {
232
- expect(parseAgentPart({ type: 1 })).toBeNull()
519
+ expect(parseAgentPart({ id: 'p1', type: 1 })).toBeNull()
233
520
  })
234
521
 
235
522
  test('rejects an unknown type discriminant', () => {
236
- expect(parseAgentPart({ type: 'unknown-variant', text: 'hi' })).toBeNull()
523
+ expect(parseAgentPart({ id: 'p1', type: 'unknown-variant', text: 'hi' })).toBeNull()
237
524
  })
238
525
  })
239
526
  })
240
527
 
241
528
  describe('isStartPart', () => {
242
529
  test('returns true for a StartPart', () => {
243
- expect(isStartPart({ type: 'start', runId: 'run-1' })).toBe(true)
530
+ expect(isStartPart({ id: 'p1', type: 'start', runId: 'run-1' })).toBe(true)
244
531
  })
245
532
 
246
533
  test('returns false for a non-start AgentPart', () => {
247
- expect(isStartPart({ type: 'text', text: 'hi' })).toBe(false)
534
+ expect(isStartPart({ id: 'p1', type: 'text', text: 'hi' })).toBe(false)
248
535
  })
249
536
 
250
537
  test('returns false for null', () => {
@@ -259,3 +546,58 @@ describe('isStartPart', () => {
259
546
  expect(isStartPart({ runId: 'run-1' })).toBe(false)
260
547
  })
261
548
  })
549
+
550
+ describe('isToolCallSettled', () => {
551
+ const base = { id: 'p1', type: 'tool', toolCallId: 'call-1', toolName: 'search' } as const
552
+
553
+ test('false for input-streaming', () => {
554
+ expect(isToolCallSettled({ ...base, state: 'input-streaming' })).toBe(false)
555
+ })
556
+
557
+ test('false for input-available', () => {
558
+ expect(isToolCallSettled({ ...base, state: 'input-available', input: {} })).toBe(false)
559
+ })
560
+
561
+ test('false for approval-requested', () => {
562
+ expect(
563
+ isToolCallSettled({
564
+ ...base,
565
+ state: 'approval-requested',
566
+ input: {},
567
+ approval: { id: 'appr-1' },
568
+ }),
569
+ ).toBe(false)
570
+ })
571
+
572
+ test('false for approval-responded', () => {
573
+ expect(
574
+ isToolCallSettled({
575
+ ...base,
576
+ state: 'approval-responded',
577
+ input: {},
578
+ approval: { id: 'appr-1', approved: true },
579
+ }),
580
+ ).toBe(false)
581
+ })
582
+
583
+ test('true for output-available', () => {
584
+ expect(isToolCallSettled({ ...base, state: 'output-available', input: {}, output: {} })).toBe(
585
+ true,
586
+ )
587
+ })
588
+
589
+ test('true for output-error', () => {
590
+ expect(isToolCallSettled({ ...base, state: 'output-error', errorText: 'boom' })).toBe(true)
591
+ })
592
+
593
+ test('true for output-denied', () => {
594
+ expect(
595
+ isToolCallSettled({
596
+ ...base,
597
+ state: 'output-denied',
598
+ input: {},
599
+ approval: { id: 'appr-1', approved: false },
600
+ }),
601
+ ).toBe(true)
602
+ })
603
+ })