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.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +18 -0
- package/README.md +64 -34
- package/agent/rules/basalt-agent.md +49 -6
- package/configs/oxlint-plugin.js +279 -0
- package/configs/oxlint-plugin.test.ts +216 -1
- package/configs/oxlint.json +5 -2
- 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 +76 -23
- package/dist/agent-chat/index.d.ts +6 -3
- 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/cli/index.d.ts +33 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +131 -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 +11 -1
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +22 -1
- package/llms.txt +10 -3
- package/package.json +6 -1
- package/src/agent/ai-sdk-transport.test.ts +567 -0
- 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 +603 -0
- package/src/agent/parts.ts +262 -32
- package/src/agent/thread.test.ts +147 -0
- package/src/agent/transport.test.ts +70 -0
- package/src/agent/transport.ts +72 -20
- package/src/agent/use-agent-stream.test.tsx +261 -0
- 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 +289 -0
- package/src/agent/use-agent-thread-runs.test.tsx +781 -0
- package/src/agent/use-agent-thread-runs.ts +177 -27
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +301 -0
- package/src/agent-chat/index.ts +8 -3
- 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/charts/primitives/ChartFrame.test.tsx +8 -5
- package/src/cli/index.ts +285 -0
- package/src/dashboard/stat-card.test.tsx +5 -3
- package/src/guard/check-source.test.ts +8 -4
- package/src/guard/index.ts +1 -3
- package/src/index.ts +2 -0
- package/src/provider/index.test.tsx +6 -5
- package/src/surfaces.ts +23 -1
- package/src/theme/use-basalt-spacing.test.tsx +7 -4
- package/src/tokens/build-fonts-css.test.ts +3 -2
package/src/agent/part-list.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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
|
+
})
|