basalt-ui 1.9.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 (104) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +64 -34
  4. package/agent/rules/basalt-agent.md +49 -6
  5. package/configs/oxlint-plugin.js +279 -0
  6. package/configs/oxlint-plugin.test.ts +216 -1
  7. package/configs/oxlint.json +5 -2
  8. package/dist/agent/ai-sdk-transport.d.ts +3 -52
  9. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  10. package/dist/agent/ai-sdk-transport.js +108 -24
  11. package/dist/agent/coalesce.d.ts +26 -0
  12. package/dist/agent/coalesce.d.ts.map +1 -0
  13. package/dist/agent/coalesce.js +69 -0
  14. package/dist/agent/foreign.d.ts +97 -0
  15. package/dist/agent/foreign.d.ts.map +1 -0
  16. package/dist/agent/foreign.js +18 -0
  17. package/dist/agent/history.d.ts +2 -0
  18. package/dist/agent/history.d.ts.map +1 -1
  19. package/dist/agent/id.d.ts +35 -0
  20. package/dist/agent/id.d.ts.map +1 -0
  21. package/dist/agent/id.js +14 -0
  22. package/dist/agent/index.d.ts +9 -4
  23. package/dist/agent/index.d.ts.map +1 -1
  24. package/dist/agent/index.js +15 -3
  25. package/dist/agent/merge.d.ts +8 -0
  26. package/dist/agent/merge.d.ts.map +1 -0
  27. package/dist/agent/merge.js +23 -0
  28. package/dist/agent/part-list.d.ts.map +1 -1
  29. package/dist/agent/part-list.js +16 -6
  30. package/dist/agent/parts.d.ts +100 -21
  31. package/dist/agent/parts.d.ts.map +1 -1
  32. package/dist/agent/parts.js +128 -11
  33. package/dist/agent/transport.d.ts +39 -3
  34. package/dist/agent/transport.d.ts.map +1 -1
  35. package/dist/agent/transport.js +20 -17
  36. package/dist/agent/use-agent-stream.d.ts +2 -1
  37. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  38. package/dist/agent/use-agent-stream.js +5 -2
  39. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  40. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  41. package/dist/agent/use-agent-thread-runs.js +76 -23
  42. package/dist/agent-chat/index.d.ts +6 -3
  43. package/dist/agent-chat/index.d.ts.map +1 -1
  44. package/dist/agent-chat/index.js +2 -0
  45. package/dist/agent-chat/thread-message.d.ts +24 -7
  46. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  47. package/dist/agent-chat/thread-message.js +102 -43
  48. package/dist/agent-chat/tool-chip.d.ts +21 -0
  49. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  50. package/dist/agent-chat/tool-chip.js +120 -0
  51. package/dist/cli/index.d.ts +33 -0
  52. package/dist/cli/index.d.ts.map +1 -1
  53. package/dist/cli/index.js +131 -0
  54. package/dist/guard/index.d.ts.map +1 -1
  55. package/dist/guard/index.js +1 -3
  56. package/dist/index.d.ts +2 -2
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +3 -1
  59. package/dist/surfaces.d.ts +11 -1
  60. package/dist/surfaces.d.ts.map +1 -1
  61. package/dist/surfaces.js +22 -1
  62. package/llms.txt +10 -3
  63. package/package.json +6 -1
  64. package/src/agent/ai-sdk-transport.test.ts +567 -0
  65. package/src/agent/ai-sdk-transport.ts +206 -53
  66. package/src/agent/coalesce.test.ts +194 -0
  67. package/src/agent/coalesce.ts +118 -0
  68. package/src/agent/foreign.test.ts +47 -0
  69. package/src/agent/foreign.ts +140 -0
  70. package/src/agent/history.ts +2 -0
  71. package/src/agent/id.test.ts +71 -0
  72. package/src/agent/id.ts +43 -0
  73. package/src/agent/index.ts +26 -3
  74. package/src/agent/merge.test.ts +119 -0
  75. package/src/agent/merge.ts +67 -0
  76. package/src/agent/part-list.test.tsx +82 -0
  77. package/src/agent/part-list.tsx +31 -6
  78. package/src/agent/parts.test.ts +603 -0
  79. package/src/agent/parts.ts +262 -32
  80. package/src/agent/thread.test.ts +147 -0
  81. package/src/agent/transport.test.ts +70 -0
  82. package/src/agent/transport.ts +72 -20
  83. package/src/agent/use-agent-stream.test.tsx +261 -0
  84. package/src/agent/use-agent-stream.ts +13 -3
  85. package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
  86. package/src/agent/use-agent-thread-runs.resume.test.tsx +289 -0
  87. package/src/agent/use-agent-thread-runs.test.tsx +781 -0
  88. package/src/agent/use-agent-thread-runs.ts +177 -27
  89. package/src/agent/use-agent-thread-runs.wedge.test.tsx +301 -0
  90. package/src/agent-chat/index.ts +8 -3
  91. package/src/agent-chat/thread-message.test.tsx +211 -0
  92. package/src/agent-chat/thread-message.tsx +235 -62
  93. package/src/agent-chat/tool-chip.test.tsx +139 -0
  94. package/src/agent-chat/tool-chip.tsx +219 -0
  95. package/src/charts/primitives/ChartFrame.test.tsx +8 -5
  96. package/src/cli/index.ts +285 -0
  97. package/src/dashboard/stat-card.test.tsx +5 -3
  98. package/src/guard/check-source.test.ts +8 -4
  99. package/src/guard/index.ts +1 -3
  100. package/src/index.ts +2 -0
  101. package/src/provider/index.test.tsx +6 -5
  102. package/src/surfaces.ts +23 -1
  103. package/src/theme/use-basalt-spacing.test.tsx +7 -4
  104. package/src/tokens/build-fonts-css.test.ts +3 -2
@@ -112,6 +112,12 @@ const CODE_BLOCK_STYLE = {
112
112
  overflowX: 'auto',
113
113
  } as const
114
114
 
115
+ /** Terminal-state status line (tool-call error/denial) — plain text, no code-block chrome. */
116
+ const STATUS_LINE_STYLE = {
117
+ marginTop: 'var(--vx-space-agent-part-gap-top)',
118
+ color: VX.status.bad,
119
+ } as const
120
+
115
121
  function DefaultText({ part }: { part: TextPart; index: number }): JSX.Element {
116
122
  return (
117
123
  <div
@@ -151,11 +157,30 @@ function DefaultToolCall({ part }: { part: ToolCallPart; index: number }): JSX.E
151
157
  <pre className="basalt-agent-tool-input" style={CODE_BLOCK_STYLE}>
152
158
  {JSON.stringify(part.input, null, 2)}
153
159
  </pre>
154
- {part.output !== undefined && (
160
+ {/* output-error's input never validated — rawInput is the only surviving record of what was
161
+ actually sent, so show it whenever there is no validated input to show instead. */}
162
+ {part.state === 'output-error' && part.input === undefined && part.rawInput !== undefined && (
163
+ <pre className="basalt-agent-tool-raw-input" style={CODE_BLOCK_STYLE}>
164
+ {JSON.stringify(part.rawInput, null, 2)}
165
+ </pre>
166
+ )}
167
+ {part.state === 'output-available' && (
155
168
  <pre className="basalt-agent-tool-output" style={CODE_BLOCK_STYLE}>
156
169
  {JSON.stringify(part.output, null, 2)}
157
170
  </pre>
158
171
  )}
172
+ {/* A failed or denied call must stay visible after the seven-state split — neither carries
173
+ an `output`, so without this the call renders as name+input and nothing else. */}
174
+ {part.state === 'output-error' && (
175
+ <div className="basalt-agent-tool-error" role="alert" style={STATUS_LINE_STYLE}>
176
+ {part.errorText}
177
+ </div>
178
+ )}
179
+ {part.state === 'output-denied' && (
180
+ <div className="basalt-agent-tool-denied" style={STATUS_LINE_STYLE}>
181
+ Denied{part.approval.reason !== undefined ? `: ${part.approval.reason}` : ''}
182
+ </div>
183
+ )}
159
184
  </div>
160
185
  )
161
186
  }
@@ -251,7 +276,7 @@ export function PartList<TPart extends AgentPart = AgentPart>({
251
276
  case 'text': {
252
277
  const Render = renderers.text
253
278
  return (
254
- <Fragment key={index}>
279
+ <Fragment key={part.id}>
255
280
  <Render part={part as Extract<TPart, { type: 'text' }>} index={index} />
256
281
  </Fragment>
257
282
  )
@@ -259,7 +284,7 @@ export function PartList<TPart extends AgentPart = AgentPart>({
259
284
  case 'reasoning': {
260
285
  const Render = renderers.reasoning
261
286
  return (
262
- <Fragment key={index}>
287
+ <Fragment key={part.id}>
263
288
  <Render part={part as Extract<TPart, { type: 'reasoning' }>} index={index} />
264
289
  </Fragment>
265
290
  )
@@ -267,7 +292,7 @@ export function PartList<TPart extends AgentPart = AgentPart>({
267
292
  case 'tool': {
268
293
  const Render = renderers.tool
269
294
  return (
270
- <Fragment key={index}>
295
+ <Fragment key={part.id}>
271
296
  <Render part={part as Extract<TPart, { type: 'tool' }>} index={index} />
272
297
  </Fragment>
273
298
  )
@@ -275,7 +300,7 @@ export function PartList<TPart extends AgentPart = AgentPart>({
275
300
  case 'source': {
276
301
  const Render = renderers.source
277
302
  return (
278
- <Fragment key={index}>
303
+ <Fragment key={part.id}>
279
304
  <Render part={part as Extract<TPart, { type: 'source' }>} index={index} />
280
305
  </Fragment>
281
306
  )
@@ -283,7 +308,7 @@ export function PartList<TPart extends AgentPart = AgentPart>({
283
308
  case 'error': {
284
309
  const Render = renderers.error
285
310
  return (
286
- <Fragment key={index}>
311
+ <Fragment key={part.id}>
287
312
  <Render part={part as Extract<TPart, { type: 'error' }>} index={index} />
288
313
  </Fragment>
289
314
  )
@@ -0,0 +1,603 @@
1
+ /**
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.
4
+ *
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).
10
+ */
11
+ import { describe, expect, test } from 'bun:test'
12
+ import { isStartPart, isToolCallSettled, parseAgentPart } from './parts'
13
+ import type { AgentPart, ToolCallPart } from './parts'
14
+
15
+ /** JSON round-trip a value through JSON.stringify → JSON.parse, mirroring wire transport. */
16
+ function roundTrip(value: unknown): unknown {
17
+ return JSON.parse(JSON.stringify(value))
18
+ }
19
+
20
+ describe('parseAgentPart', () => {
21
+ describe('start', () => {
22
+ test('parses a minimal start part (no resumeToken)', () => {
23
+ const raw = { id: 'p1', type: 'start', runId: 'run-1' }
24
+ expect(parseAgentPart(raw)).toEqual({ id: 'p1', type: 'start', runId: 'run-1' })
25
+ })
26
+
27
+ test('parses a start part with resumeToken', () => {
28
+ const raw = { id: 'p1', type: 'start', runId: 'run-1', resumeToken: 'tok-1' }
29
+ expect(parseAgentPart(raw)).toEqual({
30
+ id: 'p1',
31
+ type: 'start',
32
+ runId: 'run-1',
33
+ resumeToken: 'tok-1',
34
+ })
35
+ })
36
+
37
+ test('drops a non-string resumeToken rather than passing it through', () => {
38
+ const raw = { id: 'p1', type: 'start', runId: 'run-1', resumeToken: 42 }
39
+ expect(parseAgentPart(raw)).toEqual({ id: 'p1', type: 'start', runId: 'run-1' })
40
+ })
41
+
42
+ test('rejects a missing runId', () => {
43
+ expect(parseAgentPart({ id: 'p1', type: 'start' })).toBeNull()
44
+ })
45
+
46
+ test('rejects a non-string runId', () => {
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()
52
+ })
53
+
54
+ test('round-trips through JSON', () => {
55
+ const part: AgentPart = { id: 'p1', type: 'start', runId: 'run-1', resumeToken: 'tok-1' }
56
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
57
+ })
58
+ })
59
+
60
+ describe('text', () => {
61
+ test('parses a text part', () => {
62
+ expect(parseAgentPart({ id: 'p1', type: 'text', text: 'hello' })).toEqual({
63
+ id: 'p1',
64
+ type: 'text',
65
+ text: 'hello',
66
+ })
67
+ })
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
+
74
+ test('rejects a missing text field', () => {
75
+ expect(parseAgentPart({ id: 'p1', type: 'text' })).toBeNull()
76
+ })
77
+
78
+ test('rejects a non-string text field', () => {
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()
84
+ })
85
+
86
+ test('round-trips through JSON', () => {
87
+ const part: AgentPart = { id: 'p1', type: 'text', text: 'hello', offset: 0 }
88
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
89
+ })
90
+ })
91
+
92
+ describe('reasoning', () => {
93
+ test('parses a reasoning part', () => {
94
+ expect(parseAgentPart({ id: 'p1', type: 'reasoning', text: 'thinking...' })).toEqual({
95
+ id: 'p1',
96
+ type: 'reasoning',
97
+ text: 'thinking...',
98
+ })
99
+ })
100
+
101
+ test('rejects a missing text field', () => {
102
+ expect(parseAgentPart({ id: 'p1', type: 'reasoning' })).toBeNull()
103
+ })
104
+
105
+ test('round-trips through JSON', () => {
106
+ const part: AgentPart = { id: 'p1', type: 'reasoning', text: 'thinking...' }
107
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
108
+ })
109
+ })
110
+
111
+ describe('tool', () => {
112
+ test('rejects a missing toolCallId', () => {
113
+ const raw = {
114
+ id: 'p1',
115
+ type: 'tool',
116
+ toolName: 'search',
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()
149
+ })
150
+
151
+ test('rejects the pre-1.11.0 flat shape (no `state` field at all)', () => {
152
+ const raw = {
153
+ id: 'p1',
154
+ type: 'tool',
155
+ toolName: 'search',
156
+ toolCallId: 'call-1',
157
+ input: { q: 'x' },
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',
169
+ toolCallId: 'call-1',
170
+ toolName: 'search',
171
+ state: 'input-streaming',
172
+ }
173
+ expect(parseAgentPart(raw)).toEqual({
174
+ id: 'p1',
175
+ type: 'tool',
176
+ toolCallId: 'call-1',
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',
189
+ input: { q: 'x' },
190
+ }
191
+ expect(parseAgentPart(raw)).toEqual({
192
+ id: 'p1',
193
+ type: 'tool',
194
+ toolCallId: 'call-1',
195
+ toolName: 'search',
196
+ state: 'input-streaming',
197
+ input: { q: 'x' },
198
+ })
199
+ })
200
+
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()
210
+ })
211
+
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)
222
+ })
223
+
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)
235
+ })
236
+
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()
247
+ })
248
+
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()
260
+ })
261
+
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',
304
+ type: 'tool',
305
+ toolCallId: 'call-1',
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',
334
+ input: { q: 'x' },
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',
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,
429
+ }
430
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
431
+ })
432
+ })
433
+
434
+ describe('source', () => {
435
+ test('parses a minimal source part (no title)', () => {
436
+ expect(parseAgentPart({ id: 'p1', type: 'source', url: 'https://example.com' })).toEqual({
437
+ id: 'p1',
438
+ type: 'source',
439
+ url: 'https://example.com',
440
+ })
441
+ })
442
+
443
+ test('parses a source part with title', () => {
444
+ const raw = { id: 'p1', type: 'source', url: 'https://example.com', title: 'Example' }
445
+ expect(parseAgentPart(raw)).toEqual({
446
+ id: 'p1',
447
+ type: 'source',
448
+ url: 'https://example.com',
449
+ title: 'Example',
450
+ })
451
+ })
452
+
453
+ test('rejects a missing url', () => {
454
+ expect(parseAgentPart({ id: 'p1', type: 'source' })).toBeNull()
455
+ })
456
+
457
+ test('drops a non-string title rather than passing it through', () => {
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' })
460
+ })
461
+
462
+ test('round-trips through JSON', () => {
463
+ const part: AgentPart = {
464
+ id: 'p1',
465
+ type: 'source',
466
+ url: 'https://example.com',
467
+ title: 'Example',
468
+ }
469
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
470
+ })
471
+ })
472
+
473
+ describe('error', () => {
474
+ test('parses an error part', () => {
475
+ expect(parseAgentPart({ id: 'p1', type: 'error', message: 'boom' })).toEqual({
476
+ id: 'p1',
477
+ type: 'error',
478
+ message: 'boom',
479
+ })
480
+ })
481
+
482
+ test('rejects a missing message', () => {
483
+ expect(parseAgentPart({ id: 'p1', type: 'error' })).toBeNull()
484
+ })
485
+
486
+ test('rejects a non-string message', () => {
487
+ expect(parseAgentPart({ id: 'p1', type: 'error', message: 500 })).toBeNull()
488
+ })
489
+
490
+ test('round-trips through JSON', () => {
491
+ const part: AgentPart = { id: 'p1', type: 'error', message: 'boom' }
492
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
493
+ })
494
+ })
495
+
496
+ describe('malformed input', () => {
497
+ test('rejects null', () => {
498
+ expect(parseAgentPart(null)).toBeNull()
499
+ })
500
+
501
+ test('rejects a non-object primitive (string)', () => {
502
+ expect(parseAgentPart('not a part')).toBeNull()
503
+ })
504
+
505
+ test('rejects a non-object primitive (number)', () => {
506
+ expect(parseAgentPart(42)).toBeNull()
507
+ })
508
+
509
+ test('rejects an array', () => {
510
+ // Arrays are `typeof 'object'` but have no `type` string key — falls through the same guard.
511
+ expect(parseAgentPart([1, 2, 3])).toBeNull()
512
+ })
513
+
514
+ test('rejects an object with no type field', () => {
515
+ expect(parseAgentPart({ id: 'p1', text: 'hello' })).toBeNull()
516
+ })
517
+
518
+ test('rejects an object with a non-string type field', () => {
519
+ expect(parseAgentPart({ id: 'p1', type: 1 })).toBeNull()
520
+ })
521
+
522
+ test('rejects an unknown type discriminant', () => {
523
+ expect(parseAgentPart({ id: 'p1', type: 'unknown-variant', text: 'hi' })).toBeNull()
524
+ })
525
+ })
526
+ })
527
+
528
+ describe('isStartPart', () => {
529
+ test('returns true for a StartPart', () => {
530
+ expect(isStartPart({ id: 'p1', type: 'start', runId: 'run-1' })).toBe(true)
531
+ })
532
+
533
+ test('returns false for a non-start AgentPart', () => {
534
+ expect(isStartPart({ id: 'p1', type: 'text', text: 'hi' })).toBe(false)
535
+ })
536
+
537
+ test('returns false for null', () => {
538
+ expect(isStartPart(null)).toBe(false)
539
+ })
540
+
541
+ test('returns false for a non-object primitive', () => {
542
+ expect(isStartPart('start')).toBe(false)
543
+ })
544
+
545
+ test('returns false for an object with no type field', () => {
546
+ expect(isStartPart({ runId: 'run-1' })).toBe(false)
547
+ })
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
+ })