basalt-ui 1.9.0 → 1.10.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 +1 -0
- package/CHANGELOG.md +8 -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/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +1 -0
- package/dist/agent-chat/index.d.ts +4 -3
- package/dist/agent-chat/index.d.ts.map +1 -1
- 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/surfaces.d.ts +10 -0
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +21 -0
- package/llms.txt +9 -2
- package/package.json +6 -1
- package/src/agent/ai-sdk-transport.test.ts +269 -0
- package/src/agent/parts.test.ts +261 -0
- package/src/agent/thread.test.ts +147 -0
- package/src/agent/use-agent-stream.test.tsx +231 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +261 -0
- package/src/agent/use-agent-thread-runs.test.tsx +398 -0
- package/src/agent/use-agent-thread-runs.ts +7 -1
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +299 -0
- package/src/agent-chat/index.ts +4 -3
- 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/provider/index.test.tsx +6 -5
- package/src/surfaces.ts +22 -0
- package/src/theme/use-basalt-spacing.test.tsx +7 -4
- package/src/tokens/build-fonts-css.test.ts +3 -2
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiSdkTransport — snapshot→delta diffing (text/reasoning/source/tool), signal-abort mid-stream,
|
|
3
|
+
* and deterministic chat-id binding via `.forThread()`.
|
|
4
|
+
*
|
|
5
|
+
* Scope: the diffing behaviour AS IT EXISTS TODAY (B1) — no seven-state ToolCallPart, no
|
|
6
|
+
* `output-error` carrying a dedicated `error` field (today it is smuggled through `output`, see
|
|
7
|
+
* the `output-error` test below), no `durationMs` on terminal states. Those are 1.10.0 part-
|
|
8
|
+
* identity work; this file pins the CURRENT shape so that work has a baseline to diff against.
|
|
9
|
+
*
|
|
10
|
+
* Driving `aiSdkTransport` through a scripted HTTP response — the only way to exercise its public
|
|
11
|
+
* stream()/resume() diffing at all, since the diffing internals are not exported — requires a real
|
|
12
|
+
* web-streams `TransformStream` for `ai`'s HTTP transport and `eventsource-parser` to construct
|
|
13
|
+
* internally (`DefaultChatTransport.processResponseStream`, `EventSourceParserStream`). The DOM
|
|
14
|
+
* harness (`tests/setup/dom.ts`, preloaded once per `bun test` process) restores the native
|
|
15
|
+
* web-stream classes after happy-dom registration, so a plain static `import` here is safe
|
|
16
|
+
* regardless of file/module evaluation order.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, test } from 'bun:test'
|
|
19
|
+
import { JsonToSseTransformStream } from 'ai'
|
|
20
|
+
import { aiSdkTransport } from './ai-sdk-transport'
|
|
21
|
+
import type { UIMessageChunk } from 'ai'
|
|
22
|
+
import type { AgentPart } from './parts'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Builds a fetch-compatible Response whose body is the given low-level UIMessageChunks, SSE-
|
|
26
|
+
* encoded exactly as AI SDK's own `DefaultChatTransport` expects (real `JsonToSseTransformStream`,
|
|
27
|
+
* not a hand-rolled encoding) so this exercises the real parse/diff pipeline end to end.
|
|
28
|
+
*/
|
|
29
|
+
function scriptedResponse(chunks: UIMessageChunk[]): Response {
|
|
30
|
+
const source = new ReadableStream<UIMessageChunk>({
|
|
31
|
+
start(controller) {
|
|
32
|
+
for (const chunk of chunks) controller.enqueue(chunk)
|
|
33
|
+
controller.close()
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
const body = source
|
|
37
|
+
.pipeThrough(new JsonToSseTransformStream())
|
|
38
|
+
.pipeThrough(new TextEncoderStream())
|
|
39
|
+
return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Drains an aiSdkTransport `.stream()`/`.resume()` async generator into a plain array. */
|
|
43
|
+
async function collect(gen: AsyncGenerator<AgentPart>): Promise<AgentPart[]> {
|
|
44
|
+
const parts: AgentPart[] = []
|
|
45
|
+
for await (const part of gen) parts.push(part)
|
|
46
|
+
return parts
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
50
|
+
test('yields a synthesized start part first, then diffs text deltas across snapshots', async () => {
|
|
51
|
+
const chunks: UIMessageChunk[] = [
|
|
52
|
+
{ type: 'text-start', id: 't1' },
|
|
53
|
+
{ type: 'text-delta', id: 't1', delta: 'Hel' },
|
|
54
|
+
{ type: 'text-delta', id: 't1', delta: 'lo' },
|
|
55
|
+
{ type: 'text-end', id: 't1' },
|
|
56
|
+
]
|
|
57
|
+
const transport = aiSdkTransport({
|
|
58
|
+
api: '/api/chat',
|
|
59
|
+
fetch: async () => scriptedResponse(chunks),
|
|
60
|
+
})
|
|
61
|
+
const parts = await collect(transport.stream('hi'))
|
|
62
|
+
|
|
63
|
+
expect(parts[0]?.type).toBe('start')
|
|
64
|
+
const textParts = parts.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
|
|
65
|
+
// AI SDK's snapshot carries the FULL accumulated text at every write — the module's whole job
|
|
66
|
+
// is re-deriving the two incremental deltas ('Hel' then 'lo') rather than replaying 'Hello'
|
|
67
|
+
// twice or once whole.
|
|
68
|
+
expect(textParts).toEqual([
|
|
69
|
+
{ type: 'text', text: 'Hel' },
|
|
70
|
+
{ type: 'text', text: 'lo' },
|
|
71
|
+
])
|
|
72
|
+
expect(textParts.reduce((acc, p) => acc + p.text, '')).toBe('Hello')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('diffs reasoning deltas the same way as text', async () => {
|
|
76
|
+
const chunks: UIMessageChunk[] = [
|
|
77
|
+
{ type: 'reasoning-start', id: 'r1' },
|
|
78
|
+
{ type: 'reasoning-delta', id: 'r1', delta: 'thinking ' },
|
|
79
|
+
{ type: 'reasoning-delta', id: 'r1', delta: 'more' },
|
|
80
|
+
{ type: 'reasoning-end', id: 'r1' },
|
|
81
|
+
]
|
|
82
|
+
const transport = aiSdkTransport({
|
|
83
|
+
api: '/api/chat',
|
|
84
|
+
fetch: async () => scriptedResponse(chunks),
|
|
85
|
+
})
|
|
86
|
+
const parts = await collect(transport.stream('hi'))
|
|
87
|
+
|
|
88
|
+
const reasoningParts = parts.filter((p) => p.type === 'reasoning')
|
|
89
|
+
expect(reasoningParts).toEqual([
|
|
90
|
+
{ type: 'reasoning', text: 'thinking ' },
|
|
91
|
+
{ type: 'reasoning', text: 'more' },
|
|
92
|
+
])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('emits a source part exactly once for a source-url chunk', async () => {
|
|
96
|
+
const chunks: UIMessageChunk[] = [
|
|
97
|
+
{ type: 'source-url', sourceId: 's1', url: 'https://example.com', title: 'Example' },
|
|
98
|
+
]
|
|
99
|
+
const transport = aiSdkTransport({
|
|
100
|
+
api: '/api/chat',
|
|
101
|
+
fetch: async () => scriptedResponse(chunks),
|
|
102
|
+
})
|
|
103
|
+
const parts = await collect(transport.stream('hi'))
|
|
104
|
+
|
|
105
|
+
const sourceParts = parts.filter((p) => p.type === 'source')
|
|
106
|
+
expect(sourceParts).toEqual([{ type: 'source', url: 'https://example.com', title: 'Example' }])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('a tool call progresses input-available → output-available with the accumulated output', async () => {
|
|
110
|
+
const chunks: UIMessageChunk[] = [
|
|
111
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
|
|
112
|
+
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
|
|
113
|
+
{ type: 'tool-output-available', toolCallId: 'call-1', output: { hits: 3 } },
|
|
114
|
+
]
|
|
115
|
+
const transport = aiSdkTransport({
|
|
116
|
+
api: '/api/chat',
|
|
117
|
+
fetch: async () => scriptedResponse(chunks),
|
|
118
|
+
})
|
|
119
|
+
const parts = await collect(transport.stream('hi'))
|
|
120
|
+
|
|
121
|
+
const toolParts = parts.filter((p) => p.type === 'tool')
|
|
122
|
+
// input-streaming is never emitted (deliberate v1 simplification) — only input-available then
|
|
123
|
+
// output-available surface, one AgentPart each.
|
|
124
|
+
expect(toolParts).toEqual([
|
|
125
|
+
{ type: 'tool', toolName: 'search', toolCallId: 'call-1', input: { q: 'x' } },
|
|
126
|
+
{
|
|
127
|
+
type: 'tool',
|
|
128
|
+
toolName: 'search',
|
|
129
|
+
toolCallId: 'call-1',
|
|
130
|
+
input: { q: 'x' },
|
|
131
|
+
output: { hits: 3 },
|
|
132
|
+
},
|
|
133
|
+
])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('a repeated chunk carrying the SAME tool state is deduped — nothing new to report', async () => {
|
|
137
|
+
const chunks: UIMessageChunk[] = [
|
|
138
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
|
|
139
|
+
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
|
|
140
|
+
// Redundant re-send of the identical input-available state — must not double-emit.
|
|
141
|
+
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
|
|
142
|
+
{ type: 'tool-output-available', toolCallId: 'call-1', output: { hits: 3 } },
|
|
143
|
+
]
|
|
144
|
+
const transport = aiSdkTransport({
|
|
145
|
+
api: '/api/chat',
|
|
146
|
+
fetch: async () => scriptedResponse(chunks),
|
|
147
|
+
})
|
|
148
|
+
const parts = await collect(transport.stream('hi'))
|
|
149
|
+
|
|
150
|
+
const toolParts = parts.filter((p) => p.type === 'tool')
|
|
151
|
+
expect(toolParts).toHaveLength(2) // input-available once, output-available once — not three
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// 1.10.0 will give ToolCallPart a dedicated error state/field (the seven-state union named in
|
|
155
|
+
// this file's scope note). TODAY, ai-sdk-transport.ts:187-188 smuggles a tool failure through
|
|
156
|
+
// the ordinary `output` field as `{ error: errorText }` — this test pins THAT current shape so
|
|
157
|
+
// the 1.10.0 change has a documented baseline to diff against.
|
|
158
|
+
test('output-error is currently smuggled through `output`, not a dedicated error field', async () => {
|
|
159
|
+
const chunks: UIMessageChunk[] = [
|
|
160
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
|
|
161
|
+
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
|
|
162
|
+
{ type: 'tool-output-error', toolCallId: 'call-1', errorText: 'boom' },
|
|
163
|
+
]
|
|
164
|
+
const transport = aiSdkTransport({
|
|
165
|
+
api: '/api/chat',
|
|
166
|
+
fetch: async () => scriptedResponse(chunks),
|
|
167
|
+
})
|
|
168
|
+
const parts = await collect(transport.stream('hi'))
|
|
169
|
+
|
|
170
|
+
const toolParts = parts.filter((p) => p.type === 'tool')
|
|
171
|
+
const failed = toolParts.at(-1)
|
|
172
|
+
expect(failed).toEqual({
|
|
173
|
+
type: 'tool',
|
|
174
|
+
toolName: 'search',
|
|
175
|
+
toolCallId: 'call-1',
|
|
176
|
+
input: { q: 'x' },
|
|
177
|
+
output: { error: 'boom' },
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('aiSdkTransport — signal.aborted stops the yield', () => {
|
|
183
|
+
test('once the caller aborts, no further parts are yielded even though more remain', async () => {
|
|
184
|
+
const chunks: UIMessageChunk[] = [
|
|
185
|
+
{ type: 'text-start', id: 't1' },
|
|
186
|
+
{ type: 'text-delta', id: 't1', delta: 'a' },
|
|
187
|
+
{ type: 'text-delta', id: 't1', delta: 'b' },
|
|
188
|
+
{ type: 'text-end', id: 't1' },
|
|
189
|
+
]
|
|
190
|
+
const transport = aiSdkTransport({
|
|
191
|
+
api: '/api/chat',
|
|
192
|
+
fetch: async () => scriptedResponse(chunks),
|
|
193
|
+
})
|
|
194
|
+
const controller = new AbortController()
|
|
195
|
+
const gen = transport.stream('hi', controller.signal)
|
|
196
|
+
|
|
197
|
+
const start = await gen.next()
|
|
198
|
+
expect(start.value).toMatchObject({ type: 'start' })
|
|
199
|
+
const firstDelta = await gen.next()
|
|
200
|
+
expect(firstDelta.value).toEqual({ type: 'text', text: 'a' })
|
|
201
|
+
|
|
202
|
+
// Let AI SDK's background readUIMessageStream pump (kicked off inside diffChunkStream, and
|
|
203
|
+
// NOT gated on our own reads) fully settle before aborting — this is inherent to the library,
|
|
204
|
+
// not to the AgentTransport code under test: aborting while chunks are still in flight through
|
|
205
|
+
// that pump races diffChunkStream's early return against the pump's own stream teardown. A
|
|
206
|
+
// short real-time wait avoids exercising that unrelated race so this test isolates exactly
|
|
207
|
+
// ai-sdk-transport.ts:218's `if (signal?.aborted) return`.
|
|
208
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
209
|
+
|
|
210
|
+
controller.abort()
|
|
211
|
+
const afterAbort = await gen.next()
|
|
212
|
+
expect(afterAbort.done).toBe(true)
|
|
213
|
+
expect(afterAbort.value).toBeUndefined()
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('aiSdkTransport — deterministic chat-id binding', () => {
|
|
218
|
+
test('the fixed (non-forThread) transport mints its chat id ONCE, stable across repeated stream() calls', async () => {
|
|
219
|
+
// Never actually reached — the synthesized StartPart is yielded before any fetch call, so a
|
|
220
|
+
// generator that never advances past its first `.next()` never touches the network.
|
|
221
|
+
const transport = aiSdkTransport({
|
|
222
|
+
api: '/api/chat',
|
|
223
|
+
fetch: () => {
|
|
224
|
+
throw new Error('must not be called — only the first, pre-network yield is read')
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const first = await transport.stream('a').next()
|
|
229
|
+
const second = await transport.stream('b').next()
|
|
230
|
+
expect(first.value).toEqual(second.value)
|
|
231
|
+
expect((first.value as { runId: string }).runId).toBe((second.value as { runId: string }).runId)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('.forThread(id) binds the StartPart runId/resumeToken deterministically to the given id', async () => {
|
|
235
|
+
const transport = aiSdkTransport({
|
|
236
|
+
api: '/api/chat',
|
|
237
|
+
fetch: () => {
|
|
238
|
+
throw new Error('must not be called — only the first, pre-network yield is read')
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
const bound = transport.forThread('caller-supplied-thread-id')
|
|
242
|
+
|
|
243
|
+
const first = await bound.stream('first turn').next()
|
|
244
|
+
const second = await bound.stream('second turn').next()
|
|
245
|
+
|
|
246
|
+
expect(first.value).toEqual({
|
|
247
|
+
type: 'start',
|
|
248
|
+
runId: 'caller-supplied-thread-id',
|
|
249
|
+
resumeToken: 'caller-supplied-thread-id',
|
|
250
|
+
})
|
|
251
|
+
// Same across repeated turns on the same bound transport — the id is NOT re-minted per call.
|
|
252
|
+
expect(second.value).toEqual(first.value)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('two different .forThread() ids produce two different StartPart runIds', async () => {
|
|
256
|
+
const transport = aiSdkTransport({
|
|
257
|
+
api: '/api/chat',
|
|
258
|
+
fetch: () => {
|
|
259
|
+
throw new Error('must not be called — only the first, pre-network yield is read')
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
const a = await transport.forThread('thread-a').stream('hi').next()
|
|
264
|
+
const b = await transport.forThread('thread-b').stream('hi').next()
|
|
265
|
+
|
|
266
|
+
expect(a.value).toEqual({ type: 'start', runId: 'thread-a', resumeToken: 'thread-a' })
|
|
267
|
+
expect(b.value).toEqual({ type: 'start', runId: 'thread-b', resumeToken: 'thread-b' })
|
|
268
|
+
})
|
|
269
|
+
})
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parseAgentPart / isStartPart — the runtime narrowing guard over the AgentPart wire format.
|
|
3
|
+
*
|
|
4
|
+
* Scope: the parser AS IT EXISTS TODAY (B1) — no required `id`/`toolCallId`, no `AgentPartDraft`,
|
|
5
|
+
* no seven-state ToolCallPart union; those are 1.10.0 part-identity work landing on top of this
|
|
6
|
+
* currently-untested parser (see the sibling `ai-sdk-transport.test.ts`'s scope note for the same
|
|
7
|
+
* cutoff). Every AgentPart variant below is validated against parts.ts:87's `parseAgentPart` and
|
|
8
|
+
* parts.ts:134's `isStartPart` as they read right now.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from 'bun:test'
|
|
11
|
+
import { isStartPart, parseAgentPart } from './parts'
|
|
12
|
+
import type { AgentPart } from './parts'
|
|
13
|
+
|
|
14
|
+
/** JSON round-trip a value through JSON.stringify → JSON.parse, mirroring wire transport. */
|
|
15
|
+
function roundTrip(value: unknown): unknown {
|
|
16
|
+
return JSON.parse(JSON.stringify(value))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('parseAgentPart', () => {
|
|
20
|
+
describe('start', () => {
|
|
21
|
+
test('parses a minimal start part (no resumeToken)', () => {
|
|
22
|
+
const raw = { type: 'start', runId: 'run-1' }
|
|
23
|
+
expect(parseAgentPart(raw)).toEqual({ type: 'start', runId: 'run-1' })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('parses a start part with resumeToken', () => {
|
|
27
|
+
const raw = { type: 'start', runId: 'run-1', resumeToken: 'tok-1' }
|
|
28
|
+
expect(parseAgentPart(raw)).toEqual({
|
|
29
|
+
type: 'start',
|
|
30
|
+
runId: 'run-1',
|
|
31
|
+
resumeToken: 'tok-1',
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('drops a non-string resumeToken rather than passing it through', () => {
|
|
36
|
+
const raw = { type: 'start', runId: 'run-1', resumeToken: 42 }
|
|
37
|
+
expect(parseAgentPart(raw)).toEqual({ type: 'start', runId: 'run-1' })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('rejects a missing runId', () => {
|
|
41
|
+
expect(parseAgentPart({ type: 'start' })).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('rejects a non-string runId', () => {
|
|
45
|
+
expect(parseAgentPart({ type: 'start', runId: 42 })).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('round-trips through JSON', () => {
|
|
49
|
+
const part: AgentPart = { type: 'start', runId: 'run-1', resumeToken: 'tok-1' }
|
|
50
|
+
expect(parseAgentPart(roundTrip(part))).toEqual(part)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('text', () => {
|
|
55
|
+
test('parses a text part', () => {
|
|
56
|
+
expect(parseAgentPart({ type: 'text', text: 'hello' })).toEqual({
|
|
57
|
+
type: 'text',
|
|
58
|
+
text: 'hello',
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('rejects a missing text field', () => {
|
|
63
|
+
expect(parseAgentPart({ type: 'text' })).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('rejects a non-string text field', () => {
|
|
67
|
+
expect(parseAgentPart({ type: 'text', text: 1 })).toBeNull()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('round-trips through JSON', () => {
|
|
71
|
+
const part: AgentPart = { type: 'text', text: 'hello' }
|
|
72
|
+
expect(parseAgentPart(roundTrip(part))).toEqual(part)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('reasoning', () => {
|
|
77
|
+
test('parses a reasoning part', () => {
|
|
78
|
+
expect(parseAgentPart({ type: 'reasoning', text: 'thinking...' })).toEqual({
|
|
79
|
+
type: 'reasoning',
|
|
80
|
+
text: 'thinking...',
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('rejects a missing text field', () => {
|
|
85
|
+
expect(parseAgentPart({ type: 'reasoning' })).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('round-trips through JSON', () => {
|
|
89
|
+
const part: AgentPart = { type: 'reasoning', text: 'thinking...' }
|
|
90
|
+
expect(parseAgentPart(roundTrip(part))).toEqual(part)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('tool', () => {
|
|
95
|
+
test('parses a minimal tool part (input only, no output/toolCallId)', () => {
|
|
96
|
+
const raw = { type: 'tool', toolName: 'search', input: { q: 'x' } }
|
|
97
|
+
expect(parseAgentPart(raw)).toEqual({
|
|
98
|
+
type: 'tool',
|
|
99
|
+
toolName: 'search',
|
|
100
|
+
input: { q: 'x' },
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('parses a tool part with output and toolCallId', () => {
|
|
105
|
+
const raw = {
|
|
106
|
+
type: 'tool',
|
|
107
|
+
toolName: 'search',
|
|
108
|
+
input: { q: 'x' },
|
|
109
|
+
output: { hits: 3 },
|
|
110
|
+
toolCallId: 'call-1',
|
|
111
|
+
}
|
|
112
|
+
expect(parseAgentPart(raw)).toEqual({
|
|
113
|
+
type: 'tool',
|
|
114
|
+
toolName: 'search',
|
|
115
|
+
input: { q: 'x' },
|
|
116
|
+
output: { hits: 3 },
|
|
117
|
+
toolCallId: 'call-1',
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('rejects a missing toolName', () => {
|
|
122
|
+
expect(parseAgentPart({ type: 'tool', input: {} })).toBeNull()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('rejects a non-string toolName', () => {
|
|
126
|
+
expect(parseAgentPart({ type: 'tool', toolName: 1, input: {} })).toBeNull()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('rejects a missing input key entirely (undefined input is a valid value, absent key is not)', () => {
|
|
130
|
+
expect(parseAgentPart({ type: 'tool', toolName: 'search' })).toBeNull()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('accepts an explicit `input: undefined` (the key is present)', () => {
|
|
134
|
+
const raw = { type: 'tool', toolName: 'search', input: undefined }
|
|
135
|
+
expect(parseAgentPart(raw)).toEqual({ type: 'tool', toolName: 'search', input: undefined })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('drops a non-string toolCallId rather than passing it through', () => {
|
|
139
|
+
const raw = { type: 'tool', toolName: 'search', input: {}, toolCallId: 7 }
|
|
140
|
+
expect(parseAgentPart(raw)).toEqual({ type: 'tool', toolName: 'search', input: {} })
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('round-trips through JSON', () => {
|
|
144
|
+
const part: AgentPart = {
|
|
145
|
+
type: 'tool',
|
|
146
|
+
toolName: 'search',
|
|
147
|
+
input: { q: 'x' },
|
|
148
|
+
output: { hits: 3 },
|
|
149
|
+
toolCallId: 'call-1',
|
|
150
|
+
}
|
|
151
|
+
expect(parseAgentPart(roundTrip(part))).toEqual(part)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('source', () => {
|
|
156
|
+
test('parses a minimal source part (no title)', () => {
|
|
157
|
+
expect(parseAgentPart({ type: 'source', url: 'https://example.com' })).toEqual({
|
|
158
|
+
type: 'source',
|
|
159
|
+
url: 'https://example.com',
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('parses a source part with title', () => {
|
|
164
|
+
const raw = { type: 'source', url: 'https://example.com', title: 'Example' }
|
|
165
|
+
expect(parseAgentPart(raw)).toEqual({
|
|
166
|
+
type: 'source',
|
|
167
|
+
url: 'https://example.com',
|
|
168
|
+
title: 'Example',
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('rejects a missing url', () => {
|
|
173
|
+
expect(parseAgentPart({ type: 'source' })).toBeNull()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('drops a non-string title rather than passing it through', () => {
|
|
177
|
+
const raw = { type: 'source', url: 'https://example.com', title: 7 }
|
|
178
|
+
expect(parseAgentPart(raw)).toEqual({ type: 'source', url: 'https://example.com' })
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('round-trips through JSON', () => {
|
|
182
|
+
const part: AgentPart = { type: 'source', url: 'https://example.com', title: 'Example' }
|
|
183
|
+
expect(parseAgentPart(roundTrip(part))).toEqual(part)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('error', () => {
|
|
188
|
+
test('parses an error part', () => {
|
|
189
|
+
expect(parseAgentPart({ type: 'error', message: 'boom' })).toEqual({
|
|
190
|
+
type: 'error',
|
|
191
|
+
message: 'boom',
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('rejects a missing message', () => {
|
|
196
|
+
expect(parseAgentPart({ type: 'error' })).toBeNull()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('rejects a non-string message', () => {
|
|
200
|
+
expect(parseAgentPart({ type: 'error', message: 500 })).toBeNull()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('round-trips through JSON', () => {
|
|
204
|
+
const part: AgentPart = { type: 'error', message: 'boom' }
|
|
205
|
+
expect(parseAgentPart(roundTrip(part))).toEqual(part)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('malformed input', () => {
|
|
210
|
+
test('rejects null', () => {
|
|
211
|
+
expect(parseAgentPart(null)).toBeNull()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('rejects a non-object primitive (string)', () => {
|
|
215
|
+
expect(parseAgentPart('not a part')).toBeNull()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('rejects a non-object primitive (number)', () => {
|
|
219
|
+
expect(parseAgentPart(42)).toBeNull()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('rejects an array', () => {
|
|
223
|
+
// Arrays are `typeof 'object'` but have no `type` string key — falls through the same guard.
|
|
224
|
+
expect(parseAgentPart([1, 2, 3])).toBeNull()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('rejects an object with no type field', () => {
|
|
228
|
+
expect(parseAgentPart({ text: 'hello' })).toBeNull()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('rejects an object with a non-string type field', () => {
|
|
232
|
+
expect(parseAgentPart({ type: 1 })).toBeNull()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('rejects an unknown type discriminant', () => {
|
|
236
|
+
expect(parseAgentPart({ type: 'unknown-variant', text: 'hi' })).toBeNull()
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe('isStartPart', () => {
|
|
242
|
+
test('returns true for a StartPart', () => {
|
|
243
|
+
expect(isStartPart({ type: 'start', runId: 'run-1' })).toBe(true)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('returns false for a non-start AgentPart', () => {
|
|
247
|
+
expect(isStartPart({ type: 'text', text: 'hi' })).toBe(false)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('returns false for null', () => {
|
|
251
|
+
expect(isStartPart(null)).toBe(false)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('returns false for a non-object primitive', () => {
|
|
255
|
+
expect(isStartPart('start')).toBe(false)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('returns false for an object with no type field', () => {
|
|
259
|
+
expect(isStartPart({ runId: 'run-1' })).toBe(false)
|
|
260
|
+
})
|
|
261
|
+
})
|
|
@@ -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
|
+
})
|