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
|
@@ -47,13 +47,22 @@
|
|
|
47
47
|
* resolveOutcome: heuristicOutcome,
|
|
48
48
|
* })
|
|
49
49
|
*/
|
|
50
|
-
import
|
|
51
|
-
import
|
|
50
|
+
import { assertNever } from '../register'
|
|
51
|
+
import { TERMINAL_TOOL_STATES } from './parts'
|
|
52
|
+
import type { AgentPart, AgentPartDraft } from './parts'
|
|
53
|
+
import type { ResumableAgentTransport } from './transport'
|
|
52
54
|
|
|
53
55
|
// ── ai (optional peer) — TYPE-ONLY imports, erased at runtime ────────────────
|
|
54
56
|
// verbatimModuleSyntax-safe: these never trigger a runtime resolution of 'ai'. The actual runtime
|
|
55
57
|
// access goes through the memoized `import('ai')` inside resolveAiSdk() below.
|
|
56
|
-
import type {
|
|
58
|
+
import type {
|
|
59
|
+
DefaultChatTransport,
|
|
60
|
+
DynamicToolUIPart,
|
|
61
|
+
ToolUIPart,
|
|
62
|
+
UIMessage,
|
|
63
|
+
UIMessageChunk,
|
|
64
|
+
readUIMessageStream,
|
|
65
|
+
} from 'ai'
|
|
57
66
|
|
|
58
67
|
/** The element type of an AI SDK UIMessage's `parts` array (defaults applied). */
|
|
59
68
|
type AiPart = UIMessage['parts'][number]
|
|
@@ -76,13 +85,13 @@ export type AiSdkTransportOptions = {
|
|
|
76
85
|
|
|
77
86
|
// ── AiSdkTransport ─────────────────────────────────────────────────────────────
|
|
78
87
|
|
|
79
|
-
export type AiSdkTransport<TPart = AgentPart> =
|
|
88
|
+
export type AiSdkTransport<TPart = AgentPart> = ResumableAgentTransport<TPart, string> & {
|
|
80
89
|
/**
|
|
81
90
|
* Returns an AgentTransport bound to a stable chat id (e.g. a basalt-ui thread id) instead of
|
|
82
91
|
* this transport's own fixed one. Use this for useAgentThreadRuns's per-thread transport-factory
|
|
83
92
|
* form so each thread's turns share one continuous AI SDK chat session.
|
|
84
93
|
*/
|
|
85
|
-
readonly forThread: (chatId: string) =>
|
|
94
|
+
readonly forThread: (chatId: string) => ResumableAgentTransport<TPart, string>
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
// ── Lazy, memoized 'ai' resolution ────────────────────────────────────────────
|
|
@@ -120,80 +129,209 @@ function createAiSdkResolver(options: AiSdkTransportOptions): () => Promise<Reso
|
|
|
120
129
|
|
|
121
130
|
/**
|
|
122
131
|
* Diffs one AI SDK part (by array index) against its previous snapshot at that same index,
|
|
123
|
-
* returning zero or more new AgentPart
|
|
132
|
+
* returning zero or more new AgentPart drafts, each carrying a deterministic id.
|
|
124
133
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
134
|
+
* Index-addressed parts (text, reasoning, source) get `${chatId}#${index}` — stable across
|
|
135
|
+
* replays of the same snapshot sequence, which is what lets `mergePart` rewrite instead of
|
|
136
|
+
* duplicate on a resumed/replayed stream. text/reasoning also carry an authoritative `offset`
|
|
137
|
+
* (the length of the previously-seen text for that part) so a full-length resend at offset 0
|
|
138
|
+
* splices over the existing content instead of appending — the literal mechanism
|
|
139
|
+
* `idempotentReplay: true` asserts.
|
|
140
|
+
*
|
|
141
|
+
* source-document, file, reasoning-file, data-*, step-start, and custom parts have no AgentPart
|
|
142
|
+
* equivalent yet — deliberate v1 gap, skipped rather than inventing new variants.
|
|
127
143
|
*/
|
|
128
|
-
function diffPart(
|
|
144
|
+
function diffPart(
|
|
145
|
+
chatId: string,
|
|
146
|
+
index: number,
|
|
147
|
+
prevPart: AiPart | undefined,
|
|
148
|
+
currPart: AiPart,
|
|
149
|
+
toolStartTimes: Map<string, number>,
|
|
150
|
+
): AgentPartDraft[] {
|
|
129
151
|
switch (currPart.type) {
|
|
130
152
|
case 'text': {
|
|
131
153
|
const prevText = prevPart?.type === 'text' ? prevPart.text : ''
|
|
132
154
|
const delta = currPart.text.slice(prevText.length)
|
|
133
|
-
return delta.length === 0
|
|
155
|
+
return delta.length === 0
|
|
156
|
+
? []
|
|
157
|
+
: [{ id: `${chatId}#${index}`, type: 'text', text: delta, offset: prevText.length }]
|
|
134
158
|
}
|
|
135
159
|
case 'reasoning': {
|
|
136
160
|
const prevText = prevPart?.type === 'reasoning' ? prevPart.text : ''
|
|
137
161
|
const delta = currPart.text.slice(prevText.length)
|
|
138
|
-
return delta.length === 0
|
|
162
|
+
return delta.length === 0
|
|
163
|
+
? []
|
|
164
|
+
: [{ id: `${chatId}#${index}`, type: 'reasoning', text: delta, offset: prevText.length }]
|
|
139
165
|
}
|
|
140
166
|
case 'source-url': {
|
|
141
167
|
// Emit once, the first time this part appears at this index.
|
|
142
168
|
if (prevPart !== undefined) return []
|
|
143
|
-
const source:
|
|
169
|
+
const source: AgentPartDraft = { id: `${chatId}#${index}`, type: 'source', url: currPart.url }
|
|
144
170
|
return [currPart.title !== undefined ? { ...source, title: currPart.title } : source]
|
|
145
171
|
}
|
|
146
172
|
default:
|
|
147
|
-
return currPart
|
|
173
|
+
return isToolLikePart(currPart) ? diffToolPart(prevPart, currPart, toolStartTimes) : []
|
|
148
174
|
}
|
|
149
175
|
}
|
|
150
176
|
|
|
177
|
+
/** True for a `tool-${name}` static part OR a `dynamic-tool` part — the two AI SDK shapes basalt
|
|
178
|
+
* models as one `ToolCallPart`. `'dynamic-tool'` does NOT start with `'tool-'`, so this must be
|
|
179
|
+
* its own check, not a `.startsWith('tool-')` alone (that silently drops every dynamic tool call). */
|
|
180
|
+
function isToolLikePart(part: AiPart): part is ToolUIPart | DynamicToolUIPart {
|
|
181
|
+
return part.type === 'dynamic-tool' || part.type.startsWith('tool-')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Derives the tool's display name — mirrors AI SDK's own `getToolName`/`getStaticToolName`
|
|
185
|
+
* runtime (reimplemented locally rather than value-imported, so this module never resolves 'ai'
|
|
186
|
+
* at runtime; see the module docblock). Static tools carry their name only in the `tool-${name}`
|
|
187
|
+
* discriminator; dynamic tools carry it in `toolName`. */
|
|
188
|
+
function deriveToolName(part: ToolUIPart | DynamicToolUIPart): string {
|
|
189
|
+
return part.type === 'dynamic-tool' ? part.toolName : part.type.split('-').slice(1).join('-')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The `{ output, preliminary }` snapshot of an `output-available` tool part, or undefined for
|
|
193
|
+
* any other state. */
|
|
194
|
+
function outputSnapshot(
|
|
195
|
+
part: ToolUIPart | DynamicToolUIPart,
|
|
196
|
+
): { readonly output: unknown; readonly preliminary: boolean | undefined } | undefined {
|
|
197
|
+
return part.state === 'output-available'
|
|
198
|
+
? { output: part.output, preliminary: part.preliminary }
|
|
199
|
+
: undefined
|
|
200
|
+
}
|
|
201
|
+
|
|
151
202
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
203
|
+
* True when `curr` reports nothing new relative to `prev` — same state AND (for output-available)
|
|
204
|
+
* the same output/preliminary content. A `preliminary: true` result streams several
|
|
205
|
+
* output-available snapshots with the SAME `state` but a MUTATING `output`; naive state-equality
|
|
206
|
+
* would freeze on the first snapshot and drop every refinement. Content (not reference) equality:
|
|
207
|
+
* each snapshot from `readUIMessageStream` is a fresh `structuredClone`, so `===` on nested output
|
|
208
|
+
* objects would never match even when genuinely unchanged.
|
|
155
209
|
*/
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
210
|
+
function isSameToolState(
|
|
211
|
+
prev: ToolUIPart | DynamicToolUIPart,
|
|
212
|
+
curr: ToolUIPart | DynamicToolUIPart,
|
|
213
|
+
): boolean {
|
|
214
|
+
if (prev.state !== curr.state) return false
|
|
215
|
+
const prevOutput = outputSnapshot(prev)
|
|
216
|
+
const currOutput = outputSnapshot(curr)
|
|
217
|
+
if (prevOutput === undefined || currOutput === undefined) return true
|
|
218
|
+
return (
|
|
219
|
+
JSON.stringify(prevOutput.output) === JSON.stringify(currOutput.output) &&
|
|
220
|
+
prevOutput.preliminary === currOutput.preliminary
|
|
221
|
+
)
|
|
163
222
|
}
|
|
164
223
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Diffs one tool part across the seven-state `UIToolInvocation` union into a `ToolCallPart` draft,
|
|
226
|
+
* addressed by `tool#${toolCallId}` — stable across every state transition of one call, so
|
|
227
|
+
* `mergePart` rewrites the same part in place rather than stacking one entry per state.
|
|
228
|
+
*
|
|
229
|
+
* `durationMs` is basalt's own field (the SDK doesn't provide one): wall-clock from this
|
|
230
|
+
* toolCallId's first sighting (recorded in `toolStartTimes`, scoped to ONE stream() / resume()
|
|
231
|
+
* call — never module-global, so two concurrent threads never share timings) to a terminal state.
|
|
232
|
+
*/
|
|
233
|
+
function diffToolPart(
|
|
234
|
+
prevPart: AiPart | undefined,
|
|
235
|
+
currPart: ToolUIPart | DynamicToolUIPart,
|
|
236
|
+
toolStartTimes: Map<string, number>,
|
|
237
|
+
): AgentPartDraft[] {
|
|
238
|
+
if (!toolStartTimes.has(currPart.toolCallId)) {
|
|
239
|
+
toolStartTimes.set(currPart.toolCallId, Date.now())
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const prev = prevPart !== undefined && isToolLikePart(prevPart) ? prevPart : undefined
|
|
243
|
+
if (prev !== undefined && isSameToolState(prev, currPart)) return []
|
|
170
244
|
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// Already emitted this exact state for this tool call index — nothing new to report.
|
|
176
|
-
if (prev?.state === curr.state) return []
|
|
245
|
+
const settled = (TERMINAL_TOOL_STATES as readonly string[]).includes(currPart.state)
|
|
246
|
+
const durationMs = settled
|
|
247
|
+
? Date.now() - (toolStartTimes.get(currPart.toolCallId) ?? Date.now())
|
|
248
|
+
: undefined
|
|
177
249
|
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
type: 'tool',
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
250
|
+
const base = {
|
|
251
|
+
id: `tool#${currPart.toolCallId}`,
|
|
252
|
+
type: 'tool' as const,
|
|
253
|
+
toolCallId: currPart.toolCallId,
|
|
254
|
+
toolName: deriveToolName(currPart),
|
|
255
|
+
...(currPart.providerExecuted !== undefined
|
|
256
|
+
? { providerExecuted: currPart.providerExecuted }
|
|
257
|
+
: {}),
|
|
258
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
switch (currPart.state) {
|
|
262
|
+
case 'input-streaming':
|
|
263
|
+
return [
|
|
264
|
+
{
|
|
265
|
+
...base,
|
|
266
|
+
state: 'input-streaming',
|
|
267
|
+
...(currPart.input !== undefined ? { input: currPart.input } : {}),
|
|
268
|
+
},
|
|
269
|
+
]
|
|
270
|
+
case 'input-available':
|
|
271
|
+
return [{ ...base, state: 'input-available', input: currPart.input }]
|
|
272
|
+
case 'approval-requested':
|
|
273
|
+
return [
|
|
274
|
+
{
|
|
275
|
+
...base,
|
|
276
|
+
state: 'approval-requested',
|
|
277
|
+
input: currPart.input,
|
|
278
|
+
approval: currPart.approval,
|
|
279
|
+
},
|
|
280
|
+
]
|
|
281
|
+
case 'approval-responded':
|
|
282
|
+
return [
|
|
283
|
+
{
|
|
284
|
+
...base,
|
|
285
|
+
state: 'approval-responded',
|
|
286
|
+
input: currPart.input,
|
|
287
|
+
approval: currPart.approval,
|
|
288
|
+
},
|
|
289
|
+
]
|
|
290
|
+
case 'output-available':
|
|
291
|
+
return [
|
|
292
|
+
{
|
|
293
|
+
...base,
|
|
294
|
+
state: 'output-available',
|
|
295
|
+
input: currPart.input,
|
|
296
|
+
output: currPart.output,
|
|
297
|
+
...(currPart.preliminary !== undefined ? { preliminary: currPart.preliminary } : {}),
|
|
298
|
+
...(currPart.approval !== undefined ? { approval: currPart.approval } : {}),
|
|
299
|
+
},
|
|
300
|
+
]
|
|
301
|
+
case 'output-error': {
|
|
302
|
+
// rawInput only exists on the static ToolUIPart variant — a DynamicToolUIPart's output-error
|
|
303
|
+
// has no such field at all, so this must be read defensively behind the type discriminant.
|
|
304
|
+
const rawInput = currPart.type === 'dynamic-tool' ? undefined : currPart.rawInput
|
|
305
|
+
return [
|
|
306
|
+
{
|
|
307
|
+
...base,
|
|
308
|
+
state: 'output-error',
|
|
309
|
+
...(currPart.input !== undefined ? { input: currPart.input } : {}),
|
|
310
|
+
errorText: currPart.errorText,
|
|
311
|
+
...(rawInput !== undefined ? { rawInput } : {}),
|
|
312
|
+
...(currPart.approval !== undefined ? { approval: currPart.approval } : {}),
|
|
313
|
+
},
|
|
314
|
+
]
|
|
315
|
+
}
|
|
316
|
+
case 'output-denied':
|
|
317
|
+
return [
|
|
318
|
+
{ ...base, state: 'output-denied', input: currPart.input, approval: currPart.approval },
|
|
319
|
+
]
|
|
320
|
+
default:
|
|
321
|
+
return assertNever(currPart)
|
|
184
322
|
}
|
|
185
|
-
if (curr.state === 'output-available') return [{ ...base, output: curr.output }]
|
|
186
|
-
// output-error: still surface it as a tool part rather than silently dropping the failure — the
|
|
187
|
-
// error text becomes the output payload since ToolCallPart has no dedicated error field.
|
|
188
|
-
if (curr.state === 'output-error') return [{ ...base, output: { error: curr.errorText } }]
|
|
189
|
-
return [base]
|
|
190
323
|
}
|
|
191
324
|
|
|
192
325
|
/** Diffs every index of one UIMessage snapshot against the previous snapshot. */
|
|
193
|
-
function diffMessage(
|
|
194
|
-
|
|
326
|
+
function diffMessage(
|
|
327
|
+
chatId: string,
|
|
328
|
+
prev: UIMessage | undefined,
|
|
329
|
+
curr: UIMessage,
|
|
330
|
+
toolStartTimes: Map<string, number>,
|
|
331
|
+
): AgentPartDraft[] {
|
|
332
|
+
const deltas: AgentPartDraft[] = []
|
|
195
333
|
curr.parts.forEach((part, i) => {
|
|
196
|
-
deltas.push(...diffPart(prev?.parts[i], part))
|
|
334
|
+
deltas.push(...diffPart(chatId, i, prev?.parts[i], part, toolStartTimes))
|
|
197
335
|
})
|
|
198
336
|
return deltas
|
|
199
337
|
}
|
|
@@ -204,19 +342,28 @@ function diffMessage(prev: UIMessage | undefined, curr: UIMessage): AgentPart[]
|
|
|
204
342
|
* built-in AgentPart variants, so TPart must be — or be assignable from — AgentPart for a
|
|
205
343
|
* meaningful result; same documented-cast convention as useAgentThreadRuns' defaultToUserParts).
|
|
206
344
|
*
|
|
345
|
+
* `chatId` is the id-namespace for this diff pass — the caller's chat id on stream(), the
|
|
346
|
+
* resumeToken (which IS the chat id, see the module docblock's CHAT ID BINDING note) on resume() —
|
|
347
|
+
* so index-addressed ids line up with the original run's ids across a reconnect.
|
|
348
|
+
*
|
|
349
|
+
* A fresh `toolStartTimes` map is scoped to ONE diffChunkStream call (one stream()/resume()
|
|
350
|
+
* invocation), never shared across calls or threads.
|
|
351
|
+
*
|
|
207
352
|
* `resume()`'s underlying reconnectToStream call has no AbortSignal parameter in the current AI
|
|
208
353
|
* SDK API (unlike sendMessages), so we can't cancel the in-flight fetch directly — checking
|
|
209
354
|
* `signal.aborted` here at least stops yielding further deltas to an aborted caller promptly.
|
|
210
355
|
*/
|
|
211
356
|
async function* diffChunkStream<TPart>(
|
|
357
|
+
chatId: string,
|
|
212
358
|
chunkStream: ReadableStream<UIMessageChunk>,
|
|
213
359
|
readStream: typeof readUIMessageStream,
|
|
214
360
|
signal?: AbortSignal,
|
|
215
361
|
): AsyncGenerator<TPart> {
|
|
216
362
|
let prev: UIMessage | undefined
|
|
363
|
+
const toolStartTimes = new Map<string, number>()
|
|
217
364
|
for await (const curr of readStream<UIMessage>({ stream: chunkStream })) {
|
|
218
365
|
if (signal?.aborted) return
|
|
219
|
-
for (const part of diffMessage(prev, curr)) {
|
|
366
|
+
for (const part of diffMessage(chatId, prev, curr, toolStartTimes)) {
|
|
220
367
|
yield part as TPart
|
|
221
368
|
}
|
|
222
369
|
prev = curr
|
|
@@ -241,12 +388,16 @@ export function aiSdkTransport<TPart = AgentPart>(
|
|
|
241
388
|
): AiSdkTransport<TPart> {
|
|
242
389
|
const resolveAiSdk = createAiSdkResolver(options)
|
|
243
390
|
|
|
244
|
-
function makeTransport(chatId: string):
|
|
391
|
+
function makeTransport(chatId: string): ResumableAgentTransport<TPart, string> {
|
|
245
392
|
return {
|
|
393
|
+
// Every text/reasoning delta this transport emits carries an authoritative `offset` (see
|
|
394
|
+
// diffPart), so a replayed run rewrites in place instead of duplicating — the literal
|
|
395
|
+
// condition this assertion stands for.
|
|
396
|
+
idempotentReplay: true,
|
|
246
397
|
async *stream(input: string, signal?: AbortSignal): AsyncGenerator<TPart> {
|
|
247
398
|
// Synthesized locally, first — we already know the chat id client-side, so there is no
|
|
248
399
|
// need to wait on the server to hand us a run id (unlike a river-style protocol).
|
|
249
|
-
yield { type: 'start', runId: chatId, resumeToken: chatId } as TPart
|
|
400
|
+
yield { id: `${chatId}#start`, type: 'start', runId: chatId, resumeToken: chatId } as TPart
|
|
250
401
|
|
|
251
402
|
const { httpTransport, readUIMessageStream: readStream } = await resolveAiSdk()
|
|
252
403
|
const userMessage: UIMessage = {
|
|
@@ -261,13 +412,15 @@ export function aiSdkTransport<TPart = AgentPart>(
|
|
|
261
412
|
messageId: undefined,
|
|
262
413
|
abortSignal: signal,
|
|
263
414
|
})
|
|
264
|
-
yield* diffChunkStream<TPart>(chunkStream, readStream, signal)
|
|
415
|
+
yield* diffChunkStream<TPart>(chatId, chunkStream, readStream, signal)
|
|
265
416
|
},
|
|
266
417
|
async *resume(resumeToken: string, signal?: AbortSignal): AsyncGenerator<TPart> {
|
|
267
418
|
const { httpTransport, readUIMessageStream: readStream } = await resolveAiSdk()
|
|
268
419
|
const chunkStream = await httpTransport.reconnectToStream({ chatId: resumeToken })
|
|
269
420
|
if (chunkStream === null) return
|
|
270
|
-
|
|
421
|
+
// resumeToken IS the chat id (see CHAT ID BINDING above) — reusing it as the id-namespace
|
|
422
|
+
// here keeps replayed index-addressed ids identical to the ones the original run minted.
|
|
423
|
+
yield* diffChunkStream<TPart>(resumeToken, chunkStream, readStream, signal)
|
|
271
424
|
},
|
|
272
425
|
}
|
|
273
426
|
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coalesceParts — merges a rendered turn's parts for display: consecutive text/reasoning runs,
|
|
3
|
+
* and tool parts by toolCallId regardless of adjacency (later state wins, input/toolName from an
|
|
4
|
+
* earlier state survive a later state that omits them, first-occurrence position is kept). An
|
|
5
|
+
* approval-bearing part with no directly usable toolCallId resolves via the approvalId side index.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test'
|
|
8
|
+
import { coalesceParts } from './coalesce'
|
|
9
|
+
import type { AgentPart, ToolCallPart } from './parts'
|
|
10
|
+
|
|
11
|
+
describe('coalesceParts', () => {
|
|
12
|
+
test('merges consecutive text parts into one', () => {
|
|
13
|
+
const parts: AgentPart[] = [
|
|
14
|
+
{ id: 'p1', type: 'text', text: 'Hel' },
|
|
15
|
+
{ id: 'p2', type: 'text', text: 'lo' },
|
|
16
|
+
]
|
|
17
|
+
expect(coalesceParts(parts)).toEqual([{ id: 'p2', type: 'text', text: 'Hello' }])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('merges consecutive reasoning parts into one', () => {
|
|
21
|
+
const parts: AgentPart[] = [
|
|
22
|
+
{ id: 'p1', type: 'reasoning', text: 'thinking' },
|
|
23
|
+
{ id: 'p2', type: 'reasoning', text: ' more' },
|
|
24
|
+
]
|
|
25
|
+
expect(coalesceParts(parts)).toEqual([{ id: 'p2', type: 'reasoning', text: 'thinking more' }])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('does not merge text across a non-text part (a tool call interleaved)', () => {
|
|
29
|
+
const parts: AgentPart[] = [
|
|
30
|
+
{ id: 'p1', type: 'text', text: 'a' },
|
|
31
|
+
{
|
|
32
|
+
id: 'p2',
|
|
33
|
+
type: 'tool',
|
|
34
|
+
toolCallId: 'call-1',
|
|
35
|
+
toolName: 'search',
|
|
36
|
+
state: 'input-available',
|
|
37
|
+
input: {},
|
|
38
|
+
},
|
|
39
|
+
{ id: 'p3', type: 'text', text: 'b' },
|
|
40
|
+
]
|
|
41
|
+
const out = coalesceParts(parts)
|
|
42
|
+
expect(out).toHaveLength(3)
|
|
43
|
+
expect(out[0]?.type).toBe('text')
|
|
44
|
+
expect(out[2]?.type).toBe('text')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('merges tool parts by toolCallId across NON-ADJACENT positions', () => {
|
|
48
|
+
const inputAvailable: ToolCallPart = {
|
|
49
|
+
id: 'p1',
|
|
50
|
+
type: 'tool',
|
|
51
|
+
toolCallId: 'call-1',
|
|
52
|
+
toolName: 'search',
|
|
53
|
+
state: 'input-available',
|
|
54
|
+
input: { q: 'x' },
|
|
55
|
+
}
|
|
56
|
+
const outputAvailable: ToolCallPart = {
|
|
57
|
+
id: 'p3',
|
|
58
|
+
type: 'tool',
|
|
59
|
+
toolCallId: 'call-1',
|
|
60
|
+
toolName: 'search',
|
|
61
|
+
state: 'output-available',
|
|
62
|
+
input: { q: 'x' },
|
|
63
|
+
output: { hits: 3 },
|
|
64
|
+
}
|
|
65
|
+
const parts: AgentPart[] = [
|
|
66
|
+
inputAvailable,
|
|
67
|
+
{ id: 'p2', type: 'text', text: 'meanwhile...' },
|
|
68
|
+
outputAvailable,
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
const out = coalesceParts(parts)
|
|
72
|
+
expect(out).toHaveLength(2)
|
|
73
|
+
// The merged tool part keeps the position of its FIRST occurrence.
|
|
74
|
+
expect(out[0]?.type).toBe('tool')
|
|
75
|
+
expect((out[0] as ToolCallPart).state).toBe('output-available')
|
|
76
|
+
expect(out[1]?.type).toBe('text')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('the later terminal state wins over an earlier one for the same toolCallId', () => {
|
|
80
|
+
const outputAvailable: ToolCallPart = {
|
|
81
|
+
id: 'p1',
|
|
82
|
+
type: 'tool',
|
|
83
|
+
toolCallId: 'call-1',
|
|
84
|
+
toolName: 'search',
|
|
85
|
+
state: 'output-available',
|
|
86
|
+
input: { q: 'x' },
|
|
87
|
+
output: { hits: 3 },
|
|
88
|
+
}
|
|
89
|
+
const outputError: ToolCallPart = {
|
|
90
|
+
id: 'p2',
|
|
91
|
+
type: 'tool',
|
|
92
|
+
toolCallId: 'call-1',
|
|
93
|
+
toolName: 'search',
|
|
94
|
+
state: 'output-error',
|
|
95
|
+
errorText: 'timed out',
|
|
96
|
+
}
|
|
97
|
+
const out = coalesceParts([outputAvailable, outputError])
|
|
98
|
+
expect(out).toHaveLength(1)
|
|
99
|
+
expect((out[0] as ToolCallPart).state).toBe('output-error')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('input from an earlier state survives a later state that omits it', () => {
|
|
103
|
+
const inputAvailable: ToolCallPart = {
|
|
104
|
+
id: 'p1',
|
|
105
|
+
type: 'tool',
|
|
106
|
+
toolCallId: 'call-1',
|
|
107
|
+
toolName: 'search',
|
|
108
|
+
state: 'input-available',
|
|
109
|
+
input: { q: 'x' },
|
|
110
|
+
}
|
|
111
|
+
// Simulates the wire shape where `tool-output-available` carries only `output` — a raw
|
|
112
|
+
// pre-normalization shape our strict type doesn't allow, hence the cast.
|
|
113
|
+
const outputAvailableNoInput = {
|
|
114
|
+
id: 'p2',
|
|
115
|
+
type: 'tool',
|
|
116
|
+
toolCallId: 'call-1',
|
|
117
|
+
toolName: 'search',
|
|
118
|
+
state: 'output-available',
|
|
119
|
+
output: { hits: 3 },
|
|
120
|
+
} as unknown as ToolCallPart
|
|
121
|
+
|
|
122
|
+
const out = coalesceParts([inputAvailable, outputAvailableNoInput])
|
|
123
|
+
expect(out).toHaveLength(1)
|
|
124
|
+
const merged = out[0] as ToolCallPart & { output: unknown }
|
|
125
|
+
expect(merged.state).toBe('output-available')
|
|
126
|
+
expect(merged.input).toEqual({ q: 'x' })
|
|
127
|
+
expect(merged.output).toEqual({ hits: 3 })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('toolName from an earlier state survives a later state that omits it', () => {
|
|
131
|
+
const inputAvailable: ToolCallPart = {
|
|
132
|
+
id: 'p1',
|
|
133
|
+
type: 'tool',
|
|
134
|
+
toolCallId: 'call-1',
|
|
135
|
+
toolName: 'search',
|
|
136
|
+
state: 'input-available',
|
|
137
|
+
input: { q: 'x' },
|
|
138
|
+
}
|
|
139
|
+
const outputAvailableNoToolName = {
|
|
140
|
+
id: 'p2',
|
|
141
|
+
type: 'tool',
|
|
142
|
+
toolCallId: 'call-1',
|
|
143
|
+
toolName: '',
|
|
144
|
+
state: 'output-available',
|
|
145
|
+
input: { q: 'x' },
|
|
146
|
+
output: { hits: 3 },
|
|
147
|
+
} as unknown as ToolCallPart
|
|
148
|
+
|
|
149
|
+
const out = coalesceParts([inputAvailable, outputAvailableNoToolName])
|
|
150
|
+
expect((out[0] as ToolCallPart).toolName).toBe('search')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('an approval-responded part with no toolCallId is resolved via the approvalId side index', () => {
|
|
154
|
+
const approvalRequested: ToolCallPart = {
|
|
155
|
+
id: 'p1',
|
|
156
|
+
type: 'tool',
|
|
157
|
+
toolCallId: 'call-1',
|
|
158
|
+
toolName: 'delete_file',
|
|
159
|
+
state: 'approval-requested',
|
|
160
|
+
input: { path: '/etc/passwd' },
|
|
161
|
+
approval: { id: 'appr-1' },
|
|
162
|
+
}
|
|
163
|
+
// The SDK's raw approval-responded chunk arrives without its own toolCallId — resolved by
|
|
164
|
+
// reverse lookup on approval.id instead.
|
|
165
|
+
const approvalResponded = {
|
|
166
|
+
id: 'p2',
|
|
167
|
+
type: 'tool',
|
|
168
|
+
toolCallId: '',
|
|
169
|
+
toolName: 'delete_file',
|
|
170
|
+
state: 'approval-responded',
|
|
171
|
+
input: { path: '/etc/passwd' },
|
|
172
|
+
approval: { id: 'appr-1', approved: true },
|
|
173
|
+
} as unknown as ToolCallPart
|
|
174
|
+
|
|
175
|
+
const out = coalesceParts([approvalRequested, approvalResponded])
|
|
176
|
+
expect(out).toHaveLength(1)
|
|
177
|
+
expect((out[0] as ToolCallPart).state).toBe('approval-responded')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('a tool part unresolvable by either toolCallId or approvalId is kept as-is, never dropped', () => {
|
|
181
|
+
const orphan = {
|
|
182
|
+
id: 'p1',
|
|
183
|
+
type: 'tool',
|
|
184
|
+
toolCallId: '',
|
|
185
|
+
toolName: 'mystery',
|
|
186
|
+
state: 'approval-responded',
|
|
187
|
+
input: {},
|
|
188
|
+
approval: { id: 'unknown-approval', approved: true },
|
|
189
|
+
} as unknown as ToolCallPart
|
|
190
|
+
|
|
191
|
+
const out = coalesceParts([orphan])
|
|
192
|
+
expect(out).toEqual([orphan])
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coalesceParts — merges a rendered-turn's parts array for display.
|
|
3
|
+
*
|
|
4
|
+
* Merges consecutive `text`+`text` and `reasoning`+`reasoning` parts (streamed answers arrive as
|
|
5
|
+
* many small fragments; this renders a streaming reply as one flowing block instead of a stack of
|
|
6
|
+
* fragments). Merges `tool` parts BY `toolCallId` regardless of adjacency — the AI SDK re-emits a
|
|
7
|
+
* tool call's `input-available` then `output-available` as two separate parts, not necessarily
|
|
8
|
+
* adjacent once other parts interleave; without this rule a UI renders two stacked tool blocks for
|
|
9
|
+
* one call. The later state always wins; the merged part keeps the position of its FIRST
|
|
10
|
+
* occurrence (stable ordering); and an `input` (or `toolName`) supplied by an earlier state
|
|
11
|
+
* survives a later state that omits it — `tool-output-available` carries only `output` on the
|
|
12
|
+
* wire, the SDK re-supplies `input` by reading it back off the stored invocation, and a merge that
|
|
13
|
+
* replaces wholesale rather than accumulating would lose it outright.
|
|
14
|
+
*
|
|
15
|
+
* `approval`-bearing responses can arrive without a usable `toolCallId` (the SDK resolves them by
|
|
16
|
+
* reverse lookup on `approval.id` instead) — this maintains an `approvalId -> toolCallId` side
|
|
17
|
+
* index while folding and routes such a part through it. A part resolvable by neither key is kept
|
|
18
|
+
* as-is rather than dropped; never silently discard a part.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* import { coalesceParts } from 'basalt-ui/agent'
|
|
22
|
+
* <PartList parts={coalesceParts(parts)} />
|
|
23
|
+
*/
|
|
24
|
+
import type { AgentPart, ReasoningPart, TextPart, ToolCallPart } from './parts'
|
|
25
|
+
|
|
26
|
+
type TextLike = TextPart | ReasoningPart
|
|
27
|
+
|
|
28
|
+
function isTextLikeType(type: AgentPart['type']): type is TextLike['type'] {
|
|
29
|
+
return type === 'text' || type === 'reasoning'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Resolves a usable toolCallId for `part`, either off the part itself or (for an approval-bearing
|
|
33
|
+
* part missing one) via the approvalId -> toolCallId side index built while folding. */
|
|
34
|
+
function resolveToolCallId(
|
|
35
|
+
part: ToolCallPart,
|
|
36
|
+
toolCallIdByApprovalId: Map<string, string>,
|
|
37
|
+
): string | undefined {
|
|
38
|
+
if (typeof part.toolCallId === 'string' && part.toolCallId.length > 0) return part.toolCallId
|
|
39
|
+
const approval = 'approval' in part ? part.approval : undefined
|
|
40
|
+
return approval !== undefined ? toolCallIdByApprovalId.get(approval.id) : undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Registers this part's approval.id -> toolCallId mapping (when both are known) so a later
|
|
44
|
+
* approval-bearing part missing its own toolCallId can still resolve to the right tool call. */
|
|
45
|
+
function registerApprovalIndex(
|
|
46
|
+
part: ToolCallPart,
|
|
47
|
+
toolCallIdByApprovalId: Map<string, string>,
|
|
48
|
+
): void {
|
|
49
|
+
const approval = 'approval' in part ? part.approval : undefined
|
|
50
|
+
if (approval === undefined) return
|
|
51
|
+
if (typeof part.toolCallId !== 'string' || part.toolCallId.length === 0) return
|
|
52
|
+
toolCallIdByApprovalId.set(approval.id, part.toolCallId)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The later state always wins; `input`/`toolName` from the earlier state survive a later state
|
|
56
|
+
* that omits them. */
|
|
57
|
+
function mergeToolPart(existing: ToolCallPart, next: ToolCallPart): ToolCallPart {
|
|
58
|
+
const existingInput = 'input' in existing ? existing.input : undefined
|
|
59
|
+
const nextInput = 'input' in next ? next.input : undefined
|
|
60
|
+
const input = nextInput !== undefined ? nextInput : existingInput
|
|
61
|
+
const toolName = next.toolName.length > 0 ? next.toolName : existing.toolName
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
...existing,
|
|
65
|
+
...next,
|
|
66
|
+
toolName,
|
|
67
|
+
...(input !== undefined ? { input } : {}),
|
|
68
|
+
} as ToolCallPart
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function coalesceParts<TPart extends AgentPart>(parts: readonly TPart[]): TPart[] {
|
|
72
|
+
const out: TPart[] = []
|
|
73
|
+
const toolIndexByCallId = new Map<string, number>()
|
|
74
|
+
const toolCallIdByApprovalId = new Map<string, string>()
|
|
75
|
+
|
|
76
|
+
for (const part of parts) {
|
|
77
|
+
if (isTextLikeType(part.type)) {
|
|
78
|
+
const last = out[out.length - 1]
|
|
79
|
+
if (last !== undefined && last.type === part.type) {
|
|
80
|
+
const merged = {
|
|
81
|
+
...part,
|
|
82
|
+
text: (last as unknown as TextLike).text + (part as unknown as TextLike).text,
|
|
83
|
+
}
|
|
84
|
+
out[out.length - 1] = merged as TPart
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
out.push(part)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (part.type === 'tool') {
|
|
92
|
+
const toolPart = part as unknown as ToolCallPart
|
|
93
|
+
registerApprovalIndex(toolPart, toolCallIdByApprovalId)
|
|
94
|
+
const resolvedCallId = resolveToolCallId(toolPart, toolCallIdByApprovalId)
|
|
95
|
+
|
|
96
|
+
if (resolvedCallId === undefined) {
|
|
97
|
+
// Unresolvable by either key — never silently discard.
|
|
98
|
+
out.push(part)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const existingIndex = toolIndexByCallId.get(resolvedCallId)
|
|
103
|
+
if (existingIndex === undefined) {
|
|
104
|
+
toolIndexByCallId.set(resolvedCallId, out.length)
|
|
105
|
+
out.push(part)
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const existing = out[existingIndex] as unknown as ToolCallPart
|
|
110
|
+
out[existingIndex] = mergeToolPart(existing, toolPart) as unknown as TPart
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
out.push(part)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return out
|
|
118
|
+
}
|