basalt-ui 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +10 -0
  3. package/dist/agent/ai-sdk-transport.d.ts +3 -52
  4. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  5. package/dist/agent/ai-sdk-transport.js +108 -24
  6. package/dist/agent/coalesce.d.ts +26 -0
  7. package/dist/agent/coalesce.d.ts.map +1 -0
  8. package/dist/agent/coalesce.js +69 -0
  9. package/dist/agent/foreign.d.ts +97 -0
  10. package/dist/agent/foreign.d.ts.map +1 -0
  11. package/dist/agent/foreign.js +18 -0
  12. package/dist/agent/history.d.ts +2 -0
  13. package/dist/agent/history.d.ts.map +1 -1
  14. package/dist/agent/id.d.ts +35 -0
  15. package/dist/agent/id.d.ts.map +1 -0
  16. package/dist/agent/id.js +14 -0
  17. package/dist/agent/index.d.ts +9 -4
  18. package/dist/agent/index.d.ts.map +1 -1
  19. package/dist/agent/index.js +15 -3
  20. package/dist/agent/merge.d.ts +8 -0
  21. package/dist/agent/merge.d.ts.map +1 -0
  22. package/dist/agent/merge.js +23 -0
  23. package/dist/agent/part-list.d.ts.map +1 -1
  24. package/dist/agent/part-list.js +16 -6
  25. package/dist/agent/parts.d.ts +100 -21
  26. package/dist/agent/parts.d.ts.map +1 -1
  27. package/dist/agent/parts.js +128 -11
  28. package/dist/agent/transport.d.ts +39 -3
  29. package/dist/agent/transport.d.ts.map +1 -1
  30. package/dist/agent/transport.js +20 -17
  31. package/dist/agent/use-agent-stream.d.ts +2 -1
  32. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  33. package/dist/agent/use-agent-stream.js +5 -2
  34. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  35. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  36. package/dist/agent/use-agent-thread-runs.js +75 -23
  37. package/dist/agent-chat/index.d.ts +2 -0
  38. package/dist/agent-chat/index.d.ts.map +1 -1
  39. package/dist/agent-chat/index.js +2 -0
  40. package/dist/agent-chat/thread-message.d.ts +24 -7
  41. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  42. package/dist/agent-chat/thread-message.js +102 -43
  43. package/dist/agent-chat/tool-chip.d.ts +21 -0
  44. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  45. package/dist/agent-chat/tool-chip.js +120 -0
  46. package/dist/guard/index.d.ts.map +1 -1
  47. package/dist/guard/index.js +1 -3
  48. package/dist/index.d.ts +2 -2
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +3 -1
  51. package/dist/surfaces.d.ts +2 -2
  52. package/dist/surfaces.js +2 -2
  53. package/llms.txt +4 -4
  54. package/package.json +1 -1
  55. package/src/agent/ai-sdk-transport.test.ts +338 -40
  56. package/src/agent/ai-sdk-transport.ts +206 -53
  57. package/src/agent/coalesce.test.ts +194 -0
  58. package/src/agent/coalesce.ts +118 -0
  59. package/src/agent/foreign.test.ts +47 -0
  60. package/src/agent/foreign.ts +140 -0
  61. package/src/agent/history.ts +2 -0
  62. package/src/agent/id.test.ts +71 -0
  63. package/src/agent/id.ts +43 -0
  64. package/src/agent/index.ts +26 -3
  65. package/src/agent/merge.test.ts +119 -0
  66. package/src/agent/merge.ts +67 -0
  67. package/src/agent/part-list.test.tsx +82 -0
  68. package/src/agent/part-list.tsx +31 -6
  69. package/src/agent/parts.test.ts +401 -59
  70. package/src/agent/parts.ts +262 -32
  71. package/src/agent/transport.test.ts +70 -0
  72. package/src/agent/transport.ts +72 -20
  73. package/src/agent/use-agent-stream.test.tsx +47 -17
  74. package/src/agent/use-agent-stream.ts +13 -3
  75. package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
  76. package/src/agent/use-agent-thread-runs.resume.test.tsx +34 -6
  77. package/src/agent/use-agent-thread-runs.test.tsx +397 -14
  78. package/src/agent/use-agent-thread-runs.ts +170 -26
  79. package/src/agent/use-agent-thread-runs.wedge.test.tsx +4 -2
  80. package/src/agent-chat/index.ts +4 -0
  81. package/src/agent-chat/thread-message.test.tsx +211 -0
  82. package/src/agent-chat/thread-message.tsx +235 -62
  83. package/src/agent-chat/tool-chip.test.tsx +139 -0
  84. package/src/agent-chat/tool-chip.tsx +219 -0
  85. package/src/guard/check-source.test.ts +8 -4
  86. package/src/guard/index.ts +1 -3
  87. package/src/index.ts +2 -0
  88. package/src/surfaces.ts +2 -2
@@ -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 tool invocation — input is the parameters sent, output (optional) is the result once
48
- * the tool has completed. Streams arrive with output undefined; it may be appended later.
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 ToolCallPart = {
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
- readonly input: unknown
58
- readonly output?: unknown
59
- readonly toolCallId?: string
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 = { readonly type: 'source'; readonly url: string; readonly title?: string }
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 { type: 'text', text: obj['text'] }
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
- type: 'tool',
111
- toolName: obj['toolName'],
112
- input: obj['input'],
113
- ...(obj['output'] !== undefined ? { output: obj['output'] } : {}),
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
+ })
@@ -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 = AgentPart, TInput = string> = {
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
- ): AgentTransport<TPart, TInput> {
96
- return {
97
- async *stream(input: TInput, signal?: AbortSignal): AsyncGenerator<TPart> {
98
- const { data, error } = await call(input, signal)
99
- if (error) throw error
100
- if (data === null) return
101
- yield* data
102
- },
103
- ...(resumeCall !== undefined
104
- ? {
105
- async *resume(resumeToken: string, signal?: AbortSignal): AsyncGenerator<TPart> {
106
- const { data, error } = await resumeCall(resumeToken, signal)
107
- if (error) throw error
108
- if (data === null) return
109
- yield* data
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
  }