basalt-ui 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +18 -0
- package/README.md +64 -34
- package/agent/rules/basalt-agent.md +49 -6
- package/configs/oxlint-plugin.js +279 -0
- package/configs/oxlint-plugin.test.ts +216 -1
- package/configs/oxlint.json +5 -2
- package/dist/agent/ai-sdk-transport.d.ts +3 -52
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +108 -24
- package/dist/agent/coalesce.d.ts +26 -0
- package/dist/agent/coalesce.d.ts.map +1 -0
- package/dist/agent/coalesce.js +69 -0
- package/dist/agent/foreign.d.ts +97 -0
- package/dist/agent/foreign.d.ts.map +1 -0
- package/dist/agent/foreign.js +18 -0
- package/dist/agent/history.d.ts +2 -0
- package/dist/agent/history.d.ts.map +1 -1
- package/dist/agent/id.d.ts +35 -0
- package/dist/agent/id.d.ts.map +1 -0
- package/dist/agent/id.js +14 -0
- package/dist/agent/index.d.ts +9 -4
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +15 -3
- package/dist/agent/merge.d.ts +8 -0
- package/dist/agent/merge.d.ts.map +1 -0
- package/dist/agent/merge.js +23 -0
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +16 -6
- package/dist/agent/parts.d.ts +100 -21
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +128 -11
- package/dist/agent/transport.d.ts +39 -3
- package/dist/agent/transport.d.ts.map +1 -1
- package/dist/agent/transport.js +20 -17
- package/dist/agent/use-agent-stream.d.ts +2 -1
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +5 -2
- package/dist/agent/use-agent-thread-runs.d.ts +9 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +76 -23
- package/dist/agent-chat/index.d.ts +6 -3
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/thread-message.d.ts +24 -7
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +102 -43
- package/dist/agent-chat/tool-chip.d.ts +21 -0
- package/dist/agent-chat/tool-chip.d.ts.map +1 -0
- package/dist/agent-chat/tool-chip.js +120 -0
- package/dist/cli/index.d.ts +33 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +131 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +1 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/surfaces.d.ts +11 -1
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +22 -1
- package/llms.txt +10 -3
- package/package.json +6 -1
- package/src/agent/ai-sdk-transport.test.ts +567 -0
- package/src/agent/ai-sdk-transport.ts +206 -53
- package/src/agent/coalesce.test.ts +194 -0
- package/src/agent/coalesce.ts +118 -0
- package/src/agent/foreign.test.ts +47 -0
- package/src/agent/foreign.ts +140 -0
- package/src/agent/history.ts +2 -0
- package/src/agent/id.test.ts +71 -0
- package/src/agent/id.ts +43 -0
- package/src/agent/index.ts +26 -3
- package/src/agent/merge.test.ts +119 -0
- package/src/agent/merge.ts +67 -0
- package/src/agent/part-list.test.tsx +82 -0
- package/src/agent/part-list.tsx +31 -6
- package/src/agent/parts.test.ts +603 -0
- package/src/agent/parts.ts +262 -32
- package/src/agent/thread.test.ts +147 -0
- package/src/agent/transport.test.ts +70 -0
- package/src/agent/transport.ts +72 -20
- package/src/agent/use-agent-stream.test.tsx +261 -0
- package/src/agent/use-agent-stream.ts +13 -3
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +289 -0
- package/src/agent/use-agent-thread-runs.test.tsx +781 -0
- package/src/agent/use-agent-thread-runs.ts +177 -27
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +301 -0
- package/src/agent-chat/index.ts +8 -3
- package/src/agent-chat/thread-message.test.tsx +211 -0
- package/src/agent-chat/thread-message.tsx +235 -62
- package/src/agent-chat/tool-chip.test.tsx +139 -0
- package/src/agent-chat/tool-chip.tsx +219 -0
- package/src/charts/primitives/ChartFrame.test.tsx +8 -5
- package/src/cli/index.ts +285 -0
- package/src/dashboard/stat-card.test.tsx +5 -3
- package/src/guard/check-source.test.ts +8 -4
- package/src/guard/index.ts +1 -3
- package/src/index.ts +2 -0
- package/src/provider/index.test.tsx +6 -5
- package/src/surfaces.ts +23 -1
- package/src/theme/use-basalt-spacing.test.tsx +7 -4
- package/src/tokens/build-fonts-css.test.ts +3 -2
package/src/agent/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,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createThreadsStore — ring-buffer caps (maxThreads/maxMessagesPerThread), newest-first ordering,
|
|
3
|
+
* setResumeToken's delete-the-key-on-undefined semantics, and same-tick action accumulation.
|
|
4
|
+
*
|
|
5
|
+
* Uses the REAL localStorage-backed store (happy-dom provides `window.localStorage`), so every
|
|
6
|
+
* test uses its own unique `key` — the same convention as
|
|
7
|
+
* `router-tanstack/multi-search-param-store.test.ts` — to avoid cross-test bleed through the
|
|
8
|
+
* shared localStorage instance the process-wide DOM registration provides.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from 'bun:test'
|
|
11
|
+
import { act, renderHook } from '@testing-library/react'
|
|
12
|
+
import { createThreadsStore } from './thread'
|
|
13
|
+
import type { ChatMessage } from './history'
|
|
14
|
+
|
|
15
|
+
function makeMessage(id: string): ChatMessage {
|
|
16
|
+
return { id, role: 'user', parts: [{ type: 'text', text: id }], createdAt: Date.now() }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('createThreadsStore', () => {
|
|
20
|
+
test('caps at 50 threads, dropping the oldest (tail) when exceeded', () => {
|
|
21
|
+
const useThreads = createThreadsStore({ key: 'thread-cap-a', version: 1 })
|
|
22
|
+
const { result } = renderHook(() => useThreads())
|
|
23
|
+
|
|
24
|
+
const ids: string[] = []
|
|
25
|
+
act(() => {
|
|
26
|
+
for (let i = 0; i < 51; i++) {
|
|
27
|
+
ids.push(result.current.create())
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
expect(result.current.threads).toHaveLength(50)
|
|
32
|
+
// The very first created thread is the oldest — it must have been dropped.
|
|
33
|
+
expect(result.current.threads.some((t) => t.id === ids[0])).toBe(false)
|
|
34
|
+
// The very last created thread is the newest — it must be retained, at index 0.
|
|
35
|
+
expect(result.current.threads[0]?.id).toBe(ids[50])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('caps at 100 messages per thread, dropping the oldest first', () => {
|
|
39
|
+
const useThreads = createThreadsStore({ key: 'thread-cap-b', version: 1 })
|
|
40
|
+
const { result } = renderHook(() => useThreads())
|
|
41
|
+
|
|
42
|
+
let threadId = ''
|
|
43
|
+
act(() => {
|
|
44
|
+
threadId = result.current.create()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
act(() => {
|
|
48
|
+
for (let i = 0; i < 101; i++) {
|
|
49
|
+
result.current.appendMessage(threadId, makeMessage(`m${i}`))
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const thread = result.current.threads.find((t) => t.id === threadId)
|
|
54
|
+
expect(thread?.messages).toHaveLength(100)
|
|
55
|
+
// m0 (the oldest) must have been dropped; m1..m100 survive.
|
|
56
|
+
expect(thread?.messages[0]?.id).toBe('m1')
|
|
57
|
+
expect(thread?.messages.at(-1)?.id).toBe('m100')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('threads are stored newest-first: the most recently created thread is always index 0', () => {
|
|
61
|
+
const useThreads = createThreadsStore({ key: 'thread-order-a', version: 1 })
|
|
62
|
+
const { result } = renderHook(() => useThreads())
|
|
63
|
+
|
|
64
|
+
let idA = ''
|
|
65
|
+
let idB = ''
|
|
66
|
+
let idC = ''
|
|
67
|
+
act(() => {
|
|
68
|
+
idA = result.current.create()
|
|
69
|
+
})
|
|
70
|
+
act(() => {
|
|
71
|
+
idB = result.current.create()
|
|
72
|
+
})
|
|
73
|
+
act(() => {
|
|
74
|
+
idC = result.current.create()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(result.current.threads.map((t) => t.id)).toEqual([idC, idB, idA])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('setResumeToken(id, undefined) deletes the key rather than assigning undefined', () => {
|
|
81
|
+
const useThreads = createThreadsStore({ key: 'thread-resume-a', version: 1 })
|
|
82
|
+
const { result } = renderHook(() => useThreads())
|
|
83
|
+
|
|
84
|
+
let threadId = ''
|
|
85
|
+
act(() => {
|
|
86
|
+
threadId = result.current.create()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
act(() => {
|
|
90
|
+
result.current.setResumeToken(threadId, 'token-1')
|
|
91
|
+
})
|
|
92
|
+
const withToken = result.current.threads.find((t) => t.id === threadId)
|
|
93
|
+
expect(withToken?.resumeToken).toBe('token-1')
|
|
94
|
+
expect('resumeToken' in (withToken ?? {})).toBe(true)
|
|
95
|
+
// JSON round-trip: the key is present and serialized.
|
|
96
|
+
expect(JSON.parse(JSON.stringify(withToken)).resumeToken).toBe('token-1')
|
|
97
|
+
|
|
98
|
+
act(() => {
|
|
99
|
+
result.current.setResumeToken(threadId, undefined)
|
|
100
|
+
})
|
|
101
|
+
const cleared = result.current.threads.find((t) => t.id === threadId)
|
|
102
|
+
// The distinction this test pins: the key is ABSENT, not present-with-value-undefined —
|
|
103
|
+
// observable via `in` (survives a plain assignment of `undefined`) and via JSON round-trip
|
|
104
|
+
// (an `undefined`-valued key would also vanish from JSON, so `in` is the load-bearing check).
|
|
105
|
+
expect('resumeToken' in (cleared ?? {})).toBe(false)
|
|
106
|
+
expect(cleared?.resumeToken).toBeUndefined()
|
|
107
|
+
expect(JSON.stringify(cleared)).not.toContain('resumeToken')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('two synchronous actions called in the same tick accumulate rather than one clobbering the other', () => {
|
|
111
|
+
const useThreads = createThreadsStore({ key: 'thread-accumulate-a', version: 1 })
|
|
112
|
+
const { result } = renderHook(() => useThreads())
|
|
113
|
+
|
|
114
|
+
let threadId = ''
|
|
115
|
+
act(() => {
|
|
116
|
+
// Both calls happen before React re-renders and hands back a fresh `result.current` — the
|
|
117
|
+
// second call must still observe the first's effect via the ref mirror (thread.ts:178-190),
|
|
118
|
+
// not the stale pre-render `state` closure.
|
|
119
|
+
threadId = result.current.create()
|
|
120
|
+
result.current.appendMessage(threadId, makeMessage('accumulated'))
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const thread = result.current.threads.find((t) => t.id === threadId)
|
|
124
|
+
expect(thread).toBeDefined()
|
|
125
|
+
expect(thread?.messages).toHaveLength(1)
|
|
126
|
+
expect(thread?.messages[0]?.id).toBe('accumulated')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('two synchronous setStatus + setResumeToken calls in one tick both land, not just the last', () => {
|
|
130
|
+
const useThreads = createThreadsStore({ key: 'thread-accumulate-b', version: 1 })
|
|
131
|
+
const { result } = renderHook(() => useThreads())
|
|
132
|
+
|
|
133
|
+
let threadId = ''
|
|
134
|
+
act(() => {
|
|
135
|
+
threadId = result.current.create()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
act(() => {
|
|
139
|
+
result.current.setStatus(threadId, 'streaming')
|
|
140
|
+
result.current.setResumeToken(threadId, 'tok-accum')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const thread = result.current.threads.find((t) => t.id === threadId)
|
|
144
|
+
expect(thread?.status).toBe('streaming')
|
|
145
|
+
expect(thread?.resumeToken).toBe('tok-accum')
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -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
|
+
})
|