basalt-ui 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +10 -0
- package/dist/agent/ai-sdk-transport.d.ts +3 -52
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +108 -24
- package/dist/agent/coalesce.d.ts +26 -0
- package/dist/agent/coalesce.d.ts.map +1 -0
- package/dist/agent/coalesce.js +69 -0
- package/dist/agent/foreign.d.ts +97 -0
- package/dist/agent/foreign.d.ts.map +1 -0
- package/dist/agent/foreign.js +18 -0
- package/dist/agent/history.d.ts +2 -0
- package/dist/agent/history.d.ts.map +1 -1
- package/dist/agent/id.d.ts +35 -0
- package/dist/agent/id.d.ts.map +1 -0
- package/dist/agent/id.js +14 -0
- package/dist/agent/index.d.ts +9 -4
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +15 -3
- package/dist/agent/merge.d.ts +8 -0
- package/dist/agent/merge.d.ts.map +1 -0
- package/dist/agent/merge.js +23 -0
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +16 -6
- package/dist/agent/parts.d.ts +100 -21
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +128 -11
- package/dist/agent/transport.d.ts +39 -3
- package/dist/agent/transport.d.ts.map +1 -1
- package/dist/agent/transport.js +20 -17
- package/dist/agent/use-agent-stream.d.ts +2 -1
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +5 -2
- package/dist/agent/use-agent-thread-runs.d.ts +9 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +75 -23
- package/dist/agent-chat/index.d.ts +2 -0
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/thread-message.d.ts +24 -7
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +102 -43
- package/dist/agent-chat/tool-chip.d.ts +21 -0
- package/dist/agent-chat/tool-chip.d.ts.map +1 -0
- package/dist/agent-chat/tool-chip.js +120 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +1 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/surfaces.d.ts +2 -2
- package/dist/surfaces.js +2 -2
- package/llms.txt +4 -4
- package/package.json +1 -1
- package/src/agent/ai-sdk-transport.test.ts +338 -40
- package/src/agent/ai-sdk-transport.ts +206 -53
- package/src/agent/coalesce.test.ts +194 -0
- package/src/agent/coalesce.ts +118 -0
- package/src/agent/foreign.test.ts +47 -0
- package/src/agent/foreign.ts +140 -0
- package/src/agent/history.ts +2 -0
- package/src/agent/id.test.ts +71 -0
- package/src/agent/id.ts +43 -0
- package/src/agent/index.ts +26 -3
- package/src/agent/merge.test.ts +119 -0
- package/src/agent/merge.ts +67 -0
- package/src/agent/part-list.test.tsx +82 -0
- package/src/agent/part-list.tsx +31 -6
- package/src/agent/parts.test.ts +401 -59
- package/src/agent/parts.ts +262 -32
- package/src/agent/transport.test.ts +70 -0
- package/src/agent/transport.ts +72 -20
- package/src/agent/use-agent-stream.test.tsx +47 -17
- package/src/agent/use-agent-stream.ts +13 -3
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +34 -6
- package/src/agent/use-agent-thread-runs.test.tsx +397 -14
- package/src/agent/use-agent-thread-runs.ts +170 -26
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +4 -2
- package/src/agent-chat/index.ts +4 -0
- package/src/agent-chat/thread-message.test.tsx +211 -0
- package/src/agent-chat/thread-message.tsx +235 -62
- package/src/agent-chat/tool-chip.test.tsx +139 -0
- package/src/agent-chat/tool-chip.tsx +219 -0
- package/src/guard/check-source.test.ts +8 -4
- package/src/guard/index.ts +1 -3
- package/src/index.ts +2 -0
- package/src/surfaces.ts +2 -2
package/src/agent/parts.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* explicitly and carry NO t.Object/t.Union response schema. Validate at yield-time. See
|
|
10
10
|
* agent/rules/basalt-agent.md for the full doctrine.
|
|
11
11
|
*
|
|
12
|
+
* Part identity: every variant carries a stable `id` (see `PartBase`). Transports are permitted to
|
|
13
|
+
* yield a DRAFT — `id` omitted — via `AgentPartDraft`; the consuming hooks (or `withPartIds`)
|
|
14
|
+
* normalize a draft into a fully-identified part before it is ever accumulated. `parseAgentPart`
|
|
15
|
+
* validates the FULL (identified) shape — a draft is a separate, narrower concern.
|
|
16
|
+
*
|
|
12
17
|
* @example
|
|
13
18
|
* import type { AgentPart } from 'basalt-ui/agent'
|
|
14
19
|
* function describe(p: AgentPart): string {
|
|
@@ -24,6 +29,11 @@
|
|
|
24
29
|
* }
|
|
25
30
|
*/
|
|
26
31
|
|
|
32
|
+
// ── Part identity ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** Every AgentPart variant carries a stable id — the addressing key for `mergePart`/`coalesceParts`. */
|
|
35
|
+
type PartBase = { readonly id: string }
|
|
36
|
+
|
|
27
37
|
// ── AgentPart discriminated union ────────────────────────────────────────────
|
|
28
38
|
|
|
29
39
|
/**
|
|
@@ -31,53 +41,144 @@
|
|
|
31
41
|
* resume this run after a disconnect. Not renderable content — UI code should treat it as a
|
|
32
42
|
* no-op signal, not conversation content.
|
|
33
43
|
*/
|
|
34
|
-
export type StartPart = {
|
|
44
|
+
export type StartPart = PartBase & {
|
|
35
45
|
readonly type: 'start'
|
|
36
46
|
readonly runId: string
|
|
37
47
|
readonly resumeToken?: string
|
|
38
48
|
}
|
|
39
49
|
|
|
40
|
-
/** A streamed text fragment from the assistant. */
|
|
41
|
-
export type TextPart = { readonly type: 'text'; readonly text: string }
|
|
42
|
-
|
|
43
|
-
/** An internal reasoning/thinking fragment (may be hidden in the UI). */
|
|
44
|
-
export type ReasoningPart = { readonly type: 'reasoning'; readonly text: string }
|
|
45
|
-
|
|
46
50
|
/**
|
|
47
|
-
* A
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* `toolCallId` (optional) identifies the specific invocation when a transport's wire format
|
|
51
|
-
* carries one (e.g. AI SDK's `ToolUIPart`) — useful for consumer-side coalescing of a tool call's
|
|
52
|
-
* input/output pair by id rather than by array position.
|
|
51
|
+
* A streamed text fragment from the assistant. `offset` is the character position of this delta
|
|
52
|
+
* inside its part (undefined = append at the tail) — the mechanism `mergePart` uses to splice a
|
|
53
|
+
* replayed delta in place rather than duplicating it.
|
|
53
54
|
*/
|
|
54
|
-
export type
|
|
55
|
+
export type TextPart = PartBase & {
|
|
56
|
+
readonly type: 'text'
|
|
57
|
+
readonly text: string
|
|
58
|
+
readonly offset?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** An internal reasoning/thinking fragment (may be hidden in the UI). Same `offset` mechanism as TextPart. */
|
|
62
|
+
export type ReasoningPart = PartBase & {
|
|
63
|
+
readonly type: 'reasoning'
|
|
64
|
+
readonly text: string
|
|
65
|
+
readonly offset?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── ToolCallPart — mirrors AI SDK v7's UIToolInvocation ─────────────────────
|
|
69
|
+
//
|
|
70
|
+
// Verified against installed ai@7.0.16 / 7.0.18 (byte-identical .d.ts). Seven states, no
|
|
71
|
+
// 'running' ('input-available' IS the running state — input complete, no output yet).
|
|
72
|
+
//
|
|
73
|
+
// The nested `approval` shape mirrors the SDK exactly (`{ id, approved?, reason?, isAutomatic?,
|
|
74
|
+
// signature? }`) rather than flattening to `approvalId`/`approved`/`reason` — flattening would
|
|
75
|
+
// silently drop `isAutomatic`/`signature`.
|
|
76
|
+
//
|
|
77
|
+
// `toolName` is NOT a field on the SDK's UIToolInvocation — for static tools it lives in the part
|
|
78
|
+
// discriminator `tool-${NAME}`. It IS a required field here (ours, not the SDK's) because the chip
|
|
79
|
+
// needs it; a transport mapping from the SDK must DERIVE it from the discriminator, not read it off
|
|
80
|
+
// the invocation.
|
|
81
|
+
|
|
82
|
+
/** The nested approval envelope — mirrors the SDK's shape verbatim, never flattened. */
|
|
83
|
+
type ToolApproval = {
|
|
84
|
+
readonly id: string
|
|
85
|
+
readonly approved?: boolean
|
|
86
|
+
readonly reason?: string
|
|
87
|
+
readonly isAutomatic?: boolean
|
|
88
|
+
readonly signature?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type ToolCallBase = PartBase & {
|
|
55
92
|
readonly type: 'tool'
|
|
93
|
+
/** Required on all seven states (was optional — see parts.ts history). */
|
|
94
|
+
readonly toolCallId: string
|
|
56
95
|
readonly toolName: string
|
|
57
|
-
|
|
58
|
-
readonly
|
|
59
|
-
readonly
|
|
96
|
+
/** Wall-clock time from first sighting of this toolCallId to its terminal state. */
|
|
97
|
+
readonly durationMs?: number
|
|
98
|
+
readonly providerExecuted?: boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type ToolCallPart =
|
|
102
|
+
| (ToolCallBase & { readonly state: 'input-streaming'; readonly input?: unknown })
|
|
103
|
+
| (ToolCallBase & { readonly state: 'input-available'; readonly input: unknown })
|
|
104
|
+
| (ToolCallBase & {
|
|
105
|
+
readonly state: 'approval-requested'
|
|
106
|
+
readonly input: unknown
|
|
107
|
+
readonly approval: ToolApproval & { readonly approved?: never; readonly reason?: never }
|
|
108
|
+
})
|
|
109
|
+
| (ToolCallBase & {
|
|
110
|
+
readonly state: 'approval-responded'
|
|
111
|
+
readonly input: unknown
|
|
112
|
+
readonly approval: ToolApproval & { readonly approved: boolean }
|
|
113
|
+
})
|
|
114
|
+
| (ToolCallBase & {
|
|
115
|
+
readonly state: 'output-available'
|
|
116
|
+
readonly input: unknown
|
|
117
|
+
readonly output: unknown
|
|
118
|
+
readonly preliminary?: boolean
|
|
119
|
+
readonly approval?: ToolApproval & { readonly approved?: true }
|
|
120
|
+
})
|
|
121
|
+
| (ToolCallBase & {
|
|
122
|
+
readonly state: 'output-error'
|
|
123
|
+
readonly input?: unknown
|
|
124
|
+
/** The SDK's field name — there is no field named `error` anywhere in the union. */
|
|
125
|
+
readonly errorText: string
|
|
126
|
+
/** The only place the raw, un-parsed input survives — what lets a UI render a call whose input never validated. */
|
|
127
|
+
readonly rawInput?: unknown
|
|
128
|
+
readonly approval?: ToolApproval & { readonly approved?: true }
|
|
129
|
+
})
|
|
130
|
+
| (ToolCallBase & {
|
|
131
|
+
readonly state: 'output-denied'
|
|
132
|
+
readonly input: unknown
|
|
133
|
+
readonly approval: ToolApproval & { readonly approved: false }
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
export type ToolCallState = ToolCallPart['state']
|
|
137
|
+
|
|
138
|
+
/** The three states a tool call can never leave — see `isToolCallSettled`. */
|
|
139
|
+
export const TERMINAL_TOOL_STATES = ['output-available', 'output-error', 'output-denied'] as const
|
|
140
|
+
|
|
141
|
+
/** True once a tool call has reached one of `TERMINAL_TOOL_STATES` — no further state transition. */
|
|
142
|
+
export function isToolCallSettled(part: ToolCallPart): boolean {
|
|
143
|
+
return (TERMINAL_TOOL_STATES as readonly ToolCallState[]).includes(part.state)
|
|
60
144
|
}
|
|
61
145
|
|
|
62
146
|
/** A cited source URL referenced by the assistant. */
|
|
63
|
-
export type SourcePart =
|
|
147
|
+
export type SourcePart = PartBase & {
|
|
148
|
+
readonly type: 'source'
|
|
149
|
+
readonly url: string
|
|
150
|
+
readonly title?: string
|
|
151
|
+
}
|
|
64
152
|
|
|
65
153
|
/** A fatal or recoverable error emitted in-stream by the agent. */
|
|
66
|
-
export type ErrorPart = { readonly type: 'error'; readonly message: string }
|
|
154
|
+
export type ErrorPart = PartBase & { readonly type: 'error'; readonly message: string }
|
|
67
155
|
|
|
68
156
|
/**
|
|
69
157
|
* The complete discriminated union of all structured agent stream parts.
|
|
70
158
|
*
|
|
71
159
|
* @example
|
|
72
|
-
* const part: AgentPart = { type: 'text', text: 'Hello' }
|
|
160
|
+
* const part: AgentPart = { id: 'p1', type: 'text', text: 'Hello' }
|
|
73
161
|
*/
|
|
74
162
|
export type AgentPart = StartPart | TextPart | ReasoningPart | ToolCallPart | SourcePart | ErrorPart
|
|
75
163
|
|
|
164
|
+
// ── AgentPartDraft — what a transport is allowed to yield ────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* An AgentPart with `id` optional — the shape a transport is allowed to yield. The naked type
|
|
168
|
+
* parameter (not `AgentPart extends unknown ? ... : never` written some other way) is deliberate:
|
|
169
|
+
* it makes the conditional type distribute over the AgentPart union member-by-member, so
|
|
170
|
+
* `Drafted<AgentPart>` is `Drafted<StartPart> | Drafted<TextPart> | ...` rather than one flattened
|
|
171
|
+
* (and wrong) shape.
|
|
172
|
+
*/
|
|
173
|
+
export type Drafted<T> = T extends unknown ? Omit<T, 'id'> & { readonly id?: string } : never
|
|
174
|
+
export type AgentPartDraft = Drafted<AgentPart>
|
|
175
|
+
|
|
76
176
|
// ── parseAgentPart ───────────────────────────────────────────────────────────
|
|
77
177
|
|
|
78
178
|
/**
|
|
79
179
|
* Runtime type-narrowing guard: validates an unknown value against the AgentPart discriminated
|
|
80
|
-
* union and returns the narrowed type or null when validation fails.
|
|
180
|
+
* union and returns the narrowed type or null when validation fails. Validates the FULL
|
|
181
|
+
* (identified) shape — an `id`-less draft is a separate concern handled by `withPartIds`.
|
|
81
182
|
*
|
|
82
183
|
* @example
|
|
83
184
|
* const raw = JSON.parse(line)
|
|
@@ -88,46 +189,175 @@ export function parseAgentPart(raw: unknown): AgentPart | null {
|
|
|
88
189
|
if (raw === null || typeof raw !== 'object') return null
|
|
89
190
|
const obj = raw as Record<string, unknown>
|
|
90
191
|
if (typeof obj['type'] !== 'string') return null
|
|
192
|
+
if (typeof obj['id'] !== 'string') return null
|
|
193
|
+
const id = obj['id']
|
|
91
194
|
|
|
92
195
|
switch (obj['type']) {
|
|
93
196
|
case 'start':
|
|
94
197
|
if (typeof obj['runId'] !== 'string') return null
|
|
95
198
|
return {
|
|
199
|
+
id,
|
|
96
200
|
type: 'start',
|
|
97
201
|
runId: obj['runId'],
|
|
98
202
|
...(typeof obj['resumeToken'] === 'string' ? { resumeToken: obj['resumeToken'] } : {}),
|
|
99
203
|
}
|
|
100
204
|
case 'text':
|
|
101
205
|
if (typeof obj['text'] !== 'string') return null
|
|
102
|
-
return {
|
|
206
|
+
return {
|
|
207
|
+
id,
|
|
208
|
+
type: 'text',
|
|
209
|
+
text: obj['text'],
|
|
210
|
+
...(typeof obj['offset'] === 'number' ? { offset: obj['offset'] } : {}),
|
|
211
|
+
}
|
|
103
212
|
case 'reasoning':
|
|
104
213
|
if (typeof obj['text'] !== 'string') return null
|
|
105
|
-
return { type: 'reasoning', text: obj['text'] }
|
|
106
|
-
case 'tool':
|
|
107
|
-
if (typeof obj['toolName'] !== 'string') return null
|
|
108
|
-
if (!('input' in obj)) return null
|
|
109
214
|
return {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
...(obj['
|
|
114
|
-
...(typeof obj['toolCallId'] === 'string' ? { toolCallId: obj['toolCallId'] } : {}),
|
|
215
|
+
id,
|
|
216
|
+
type: 'reasoning',
|
|
217
|
+
text: obj['text'],
|
|
218
|
+
...(typeof obj['offset'] === 'number' ? { offset: obj['offset'] } : {}),
|
|
115
219
|
}
|
|
220
|
+
case 'tool':
|
|
221
|
+
return parseToolCallPart(id, obj)
|
|
116
222
|
case 'source':
|
|
117
223
|
if (typeof obj['url'] !== 'string') return null
|
|
118
224
|
return {
|
|
225
|
+
id,
|
|
119
226
|
type: 'source',
|
|
120
227
|
url: obj['url'],
|
|
121
228
|
...(typeof obj['title'] === 'string' ? { title: obj['title'] } : {}),
|
|
122
229
|
}
|
|
123
230
|
case 'error':
|
|
124
231
|
if (typeof obj['message'] !== 'string') return null
|
|
125
|
-
return { type: 'error', message: obj['message'] }
|
|
232
|
+
return { id, type: 'error', message: obj['message'] }
|
|
233
|
+
default:
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Validates the state-discriminated `tool` part shape. Rejects the pre-1.11.0 flat shape
|
|
239
|
+
* (`{type:'tool', output}` with no `state`) and any unrecognized `state` value. */
|
|
240
|
+
function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPart | null {
|
|
241
|
+
if (typeof obj['toolCallId'] !== 'string') return null
|
|
242
|
+
if (typeof obj['toolName'] !== 'string') return null
|
|
243
|
+
if (typeof obj['state'] !== 'string') return null
|
|
244
|
+
|
|
245
|
+
const { toolCallId, toolName } = obj as { toolCallId: string; toolName: string }
|
|
246
|
+
const common = {
|
|
247
|
+
id,
|
|
248
|
+
type: 'tool' as const,
|
|
249
|
+
toolCallId,
|
|
250
|
+
toolName,
|
|
251
|
+
...(typeof obj['durationMs'] === 'number' ? { durationMs: obj['durationMs'] } : {}),
|
|
252
|
+
...(typeof obj['providerExecuted'] === 'boolean'
|
|
253
|
+
? { providerExecuted: obj['providerExecuted'] }
|
|
254
|
+
: {}),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
switch (obj['state']) {
|
|
258
|
+
case 'input-streaming':
|
|
259
|
+
return {
|
|
260
|
+
...common,
|
|
261
|
+
state: 'input-streaming',
|
|
262
|
+
...('input' in obj ? { input: obj['input'] } : {}),
|
|
263
|
+
}
|
|
264
|
+
case 'input-available':
|
|
265
|
+
if (!('input' in obj)) return null
|
|
266
|
+
return { ...common, state: 'input-available', input: obj['input'] }
|
|
267
|
+
case 'approval-requested': {
|
|
268
|
+
if (!('input' in obj)) return null
|
|
269
|
+
const approval = parseApproval(obj['approval'])
|
|
270
|
+
// The type declares this state's approval as `{ approved?: never; reason?: never }` — a
|
|
271
|
+
// request that already carries a verdict or a reason is a contradiction, not a pending
|
|
272
|
+
// request, so reject it rather than silently casting past the invariant.
|
|
273
|
+
if (approval === null || approval.approved !== undefined || approval.reason !== undefined) {
|
|
274
|
+
return null
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
...common,
|
|
278
|
+
state: 'approval-requested',
|
|
279
|
+
input: obj['input'],
|
|
280
|
+
approval: approval as ToolApproval & { approved?: never; reason?: never },
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
case 'approval-responded': {
|
|
284
|
+
if (!('input' in obj)) return null
|
|
285
|
+
const approval = parseApproval(obj['approval'])
|
|
286
|
+
if (approval === null || typeof approval.approved !== 'boolean') return null
|
|
287
|
+
return {
|
|
288
|
+
...common,
|
|
289
|
+
state: 'approval-responded',
|
|
290
|
+
input: obj['input'],
|
|
291
|
+
approval: approval as ToolApproval & { approved: boolean },
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
case 'output-denied': {
|
|
295
|
+
if (!('input' in obj)) return null
|
|
296
|
+
const approval = parseApproval(obj['approval'])
|
|
297
|
+
if (approval === null || approval.approved !== false) return null
|
|
298
|
+
return {
|
|
299
|
+
...common,
|
|
300
|
+
state: 'output-denied',
|
|
301
|
+
input: obj['input'],
|
|
302
|
+
approval: approval as ToolApproval & { approved: false },
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
case 'output-available': {
|
|
306
|
+
if (!('input' in obj)) return null
|
|
307
|
+
if (!('output' in obj)) return null
|
|
308
|
+
const approval = obj['approval'] === undefined ? null : parseApproval(obj['approval'])
|
|
309
|
+
// The type declares this state's approval as `{ approved?: true }` — output present
|
|
310
|
+
// alongside `approved: false` is a denial masquerading as a result, not a valid "approved"
|
|
311
|
+
// carry-through. A present-but-malformed approval is rejected the same way (not silently
|
|
312
|
+
// dropped), matching output-denied/approval-responded's precedent above.
|
|
313
|
+
if (obj['approval'] !== undefined && (approval === null || approval.approved === false)) {
|
|
314
|
+
return null
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
...common,
|
|
318
|
+
state: 'output-available',
|
|
319
|
+
input: obj['input'],
|
|
320
|
+
output: obj['output'],
|
|
321
|
+
...(typeof obj['preliminary'] === 'boolean' ? { preliminary: obj['preliminary'] } : {}),
|
|
322
|
+
...(approval !== null ? { approval: approval as ToolApproval & { approved?: true } } : {}),
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
case 'output-error': {
|
|
326
|
+
if (typeof obj['errorText'] !== 'string') return null
|
|
327
|
+
const approval = obj['approval'] === undefined ? null : parseApproval(obj['approval'])
|
|
328
|
+
// Same invariant as output-available above: an error carrying `approved: false` is a
|
|
329
|
+
// denial, not an error whose approval "happens to carry through".
|
|
330
|
+
if (obj['approval'] !== undefined && (approval === null || approval.approved === false)) {
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
...common,
|
|
335
|
+
state: 'output-error',
|
|
336
|
+
errorText: obj['errorText'],
|
|
337
|
+
...('input' in obj ? { input: obj['input'] } : {}),
|
|
338
|
+
...('rawInput' in obj ? { rawInput: obj['rawInput'] } : {}),
|
|
339
|
+
...(approval !== null ? { approval: approval as ToolApproval & { approved?: true } } : {}),
|
|
340
|
+
}
|
|
341
|
+
}
|
|
126
342
|
default:
|
|
127
343
|
return null
|
|
128
344
|
}
|
|
129
345
|
}
|
|
130
346
|
|
|
347
|
+
/** Validates the nested `approval` envelope. Returns null when absent/malformed. */
|
|
348
|
+
function parseApproval(raw: unknown): ToolApproval | null {
|
|
349
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
350
|
+
const obj = raw as Record<string, unknown>
|
|
351
|
+
if (typeof obj['id'] !== 'string') return null
|
|
352
|
+
return {
|
|
353
|
+
id: obj['id'],
|
|
354
|
+
...(typeof obj['approved'] === 'boolean' ? { approved: obj['approved'] } : {}),
|
|
355
|
+
...(typeof obj['reason'] === 'string' ? { reason: obj['reason'] } : {}),
|
|
356
|
+
...(typeof obj['isAutomatic'] === 'boolean' ? { isAutomatic: obj['isAutomatic'] } : {}),
|
|
357
|
+
...(typeof obj['signature'] === 'string' ? { signature: obj['signature'] } : {}),
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
131
361
|
// ── isStartPart ──────────────────────────────────────────────────────────────
|
|
132
362
|
|
|
133
363
|
/** Type guard: narrows an unknown value to StartPart. */
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transport — `isResumable` and `edenTransport`'s opt-in `idempotentReplay` flag.
|
|
3
|
+
*
|
|
4
|
+
* `resume` alone is NOT enough for a transport to be treated as resumable: `isResumable` also
|
|
5
|
+
* requires the literal `idempotentReplay: true` assertion, and `edenTransport` only sets it when
|
|
6
|
+
* the caller opts in explicitly via a third argument — never inferred from `resumeCall` alone.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test'
|
|
9
|
+
import { edenTransport, isResumable } from './transport'
|
|
10
|
+
import type { AgentTransport } from './transport'
|
|
11
|
+
import type { AgentPart } from './parts'
|
|
12
|
+
|
|
13
|
+
describe('isResumable', () => {
|
|
14
|
+
test('false for a transport with no resume() at all', () => {
|
|
15
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
16
|
+
async *stream() {},
|
|
17
|
+
}
|
|
18
|
+
expect(isResumable(transport)).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('false for a transport with resume() but WITHOUT idempotentReplay — resume alone is not enough', () => {
|
|
22
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
23
|
+
async *stream() {},
|
|
24
|
+
async *resume() {
|
|
25
|
+
yield { id: 'p1', type: 'text', text: 'resumed' }
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
expect(isResumable(transport)).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('true only once BOTH resume() and idempotentReplay: true are present', () => {
|
|
32
|
+
const transport = {
|
|
33
|
+
async *stream() {},
|
|
34
|
+
async *resume() {
|
|
35
|
+
yield { id: 'p1', type: 'text', text: 'resumed' }
|
|
36
|
+
},
|
|
37
|
+
idempotentReplay: true as const,
|
|
38
|
+
}
|
|
39
|
+
expect(isResumable(transport)).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('edenTransport — idempotentReplay is opt-in, never inferred', () => {
|
|
44
|
+
test('no resumeCall at all → the returned object has NO resume key', () => {
|
|
45
|
+
const transport = edenTransport<AgentPart>(async () => ({
|
|
46
|
+
data: (async function* () {})(),
|
|
47
|
+
error: null,
|
|
48
|
+
}))
|
|
49
|
+
expect('resume' in transport).toBe(false)
|
|
50
|
+
expect(isResumable(transport)).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('resumeCall supplied but NO options → resume() exists but isResumable is still false', () => {
|
|
54
|
+
const transport = edenTransport<AgentPart>(
|
|
55
|
+
async () => ({ data: (async function* () {})(), error: null }),
|
|
56
|
+
async () => ({ data: (async function* () {})(), error: null }),
|
|
57
|
+
)
|
|
58
|
+
expect(typeof transport.resume).toBe('function')
|
|
59
|
+
expect(isResumable(transport)).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('resumeCall + { idempotentReplay: true } → isResumable is true', () => {
|
|
63
|
+
const transport = edenTransport<AgentPart>(
|
|
64
|
+
async () => ({ data: (async function* () {})(), error: null }),
|
|
65
|
+
async () => ({ data: (async function* () {})(), error: null }),
|
|
66
|
+
{ idempotentReplay: true },
|
|
67
|
+
)
|
|
68
|
+
expect(isResumable(transport)).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
})
|
package/src/agent/transport.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* api.chat.post({ body: { message: input }, fetch: { signal } }),
|
|
30
30
|
* )
|
|
31
31
|
*/
|
|
32
|
-
import type { AgentPart } from './parts'
|
|
32
|
+
import type { AgentPart, AgentPartDraft } from './parts'
|
|
33
33
|
|
|
34
34
|
// ── AgentTransport ────────────────────────────────────────────────────────────
|
|
35
35
|
|
|
@@ -37,6 +37,11 @@ import type { AgentPart } from './parts'
|
|
|
37
37
|
* The injected transport seam. `stream(input, signal)` returns an async generator of parts.
|
|
38
38
|
* Implement this interface for any backend — Eden, fetch-based SSE, AI SDK, or a mock.
|
|
39
39
|
*
|
|
40
|
+
* The default `TPart` is `AgentPartDraft` (not `AgentPart`): transports yield DRAFTS — `id`
|
|
41
|
+
* optional — and the consuming hooks (or `withPartIds`) normalize a draft into a fully-identified
|
|
42
|
+
* part before it is ever accumulated. Pass an explicit `AgentPart` generic when a transport mints
|
|
43
|
+
* its own ids directly (as `aiSdkTransport` does).
|
|
44
|
+
*
|
|
40
45
|
* @example
|
|
41
46
|
* // Minimal mock transport for tests / playground:
|
|
42
47
|
* const mockTransport: AgentTransport = {
|
|
@@ -45,12 +50,48 @@ import type { AgentPart } from './parts'
|
|
|
45
50
|
* },
|
|
46
51
|
* }
|
|
47
52
|
*/
|
|
48
|
-
export type AgentTransport<TPart =
|
|
53
|
+
export type AgentTransport<TPart = AgentPartDraft, TInput = string> = {
|
|
49
54
|
stream: (input: TInput, signal?: AbortSignal) => AsyncGenerator<TPart>
|
|
50
55
|
/** Optional: resume a previously-started run from its resumeToken (see StartPart). Transports that don't support resumption simply omit this. */
|
|
51
56
|
resume?: (resumeToken: string, signal?: AbortSignal) => AsyncGenerator<TPart>
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
// ── ResumableAgentTransport ───────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A transport whose replay is safe to run over an already-rendered turn. Implementing `resume`
|
|
63
|
+
* alone is no longer enough: `idempotentReplay: true` is a literal-typed assertion that every
|
|
64
|
+
* text/reasoning part this transport emits carries an authoritative `offset`, so mergePart
|
|
65
|
+
* rewrites instead of appending.
|
|
66
|
+
*/
|
|
67
|
+
export type ResumableAgentTransport<TPart = AgentPartDraft, TInput = string> = AgentTransport<
|
|
68
|
+
TPart,
|
|
69
|
+
TInput
|
|
70
|
+
> & {
|
|
71
|
+
readonly resume: (resumeToken: string, signal?: AbortSignal) => AsyncGenerator<TPart>
|
|
72
|
+
readonly idempotentReplay: true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Type guard: true when `t` both implements `resume` AND has explicitly asserted
|
|
77
|
+
* `idempotentReplay: true`. A transport with `resume` but no `idempotentReplay` is NOT resumable
|
|
78
|
+
* — the mount-time reconcile in `useAgentThreadRuns` falls back to `'interrupted'` for it rather
|
|
79
|
+
* than risk duplicating already-rendered content.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* if (isResumable(transport)) {
|
|
83
|
+
* for await (const part of transport.resume(resumeToken)) { ... }
|
|
84
|
+
* }
|
|
85
|
+
*/
|
|
86
|
+
export function isResumable<TPart, TInput>(
|
|
87
|
+
t: AgentTransport<TPart, TInput>,
|
|
88
|
+
): t is ResumableAgentTransport<TPart, TInput> {
|
|
89
|
+
return (
|
|
90
|
+
typeof t.resume === 'function' &&
|
|
91
|
+
(t as Partial<ResumableAgentTransport<TPart, TInput>>).idempotentReplay === true
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
54
95
|
// ── edenTransport ─────────────────────────────────────────────────────────────
|
|
55
96
|
|
|
56
97
|
/**
|
|
@@ -70,6 +111,12 @@ export type AgentTransport<TPart = AgentPart, TInput = string> = {
|
|
|
70
111
|
* object has NO `resume` key at all (not `resume: undefined`) — consumers that don't wire a
|
|
71
112
|
* resumable backend get a transport that behaves exactly as before.
|
|
72
113
|
*
|
|
114
|
+
* `resumeCall` alone does NOT make the transport resumable per `isResumable` — pass
|
|
115
|
+
* `{ idempotentReplay: true }` as the third argument to opt in EXPLICITLY once the server-side
|
|
116
|
+
* `resumeCall` route is verified to re-emit every text/reasoning delta with an authoritative
|
|
117
|
+
* `offset` (never a bare append). This is an opt-in, not an inference: a `resumeCall` whose deltas
|
|
118
|
+
* aren't offset-accurate would double-render on replay if treated as resumable.
|
|
119
|
+
*
|
|
73
120
|
* @example
|
|
74
121
|
* import { edenTransport, type AgentPart } from 'basalt-ui/agent'
|
|
75
122
|
*
|
|
@@ -78,6 +125,7 @@ export type AgentTransport<TPart = AgentPart, TInput = string> = {
|
|
|
78
125
|
* const transport = edenTransport<AgentPart>(
|
|
79
126
|
* (input, signal) => api.chat.post({ body: { message: input }, fetch: { signal } }),
|
|
80
127
|
* (resumeToken, signal) => api.chat.resume.post({ body: { resumeToken }, fetch: { signal } }),
|
|
128
|
+
* { idempotentReplay: true },
|
|
81
129
|
* )
|
|
82
130
|
*
|
|
83
131
|
* // Wire into useAgentStream:
|
|
@@ -92,23 +140,27 @@ export function edenTransport<TPart = AgentPart, TInput = string>(
|
|
|
92
140
|
resumeToken: string,
|
|
93
141
|
signal?: AbortSignal,
|
|
94
142
|
) => Promise<{ data: AsyncGenerator<TPart> | null; error: unknown }>,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
143
|
+
options?: { readonly idempotentReplay?: true },
|
|
144
|
+
): AgentTransport<TPart, TInput> | ResumableAgentTransport<TPart, TInput> {
|
|
145
|
+
const stream = async function* (input: TInput, signal?: AbortSignal): AsyncGenerator<TPart> {
|
|
146
|
+
const { data, error } = await call(input, signal)
|
|
147
|
+
if (error) throw error
|
|
148
|
+
if (data === null) return
|
|
149
|
+
yield* data
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (resumeCall === undefined) return { stream }
|
|
153
|
+
|
|
154
|
+
const resume = async function* (
|
|
155
|
+
resumeToken: string,
|
|
156
|
+
signal?: AbortSignal,
|
|
157
|
+
): AsyncGenerator<TPart> {
|
|
158
|
+
const { data, error } = await resumeCall(resumeToken, signal)
|
|
159
|
+
if (error) throw error
|
|
160
|
+
if (data === null) return
|
|
161
|
+
yield* data
|
|
113
162
|
}
|
|
163
|
+
|
|
164
|
+
if (options?.idempotentReplay !== true) return { stream, resume }
|
|
165
|
+
return { stream, resume, idempotentReplay: true }
|
|
114
166
|
}
|