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
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* binary and returns the parsed set of `basalt/<rule>` diagnostics it printed.
|
|
9
9
|
*/
|
|
10
10
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
11
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
12
12
|
import { tmpdir } from 'node:os'
|
|
13
13
|
import { dirname, resolve } from 'node:path'
|
|
14
14
|
|
|
@@ -31,6 +31,9 @@ beforeEach(() => {
|
|
|
31
31
|
'basalt/visx-boundary': 'error',
|
|
32
32
|
'basalt/visx-tooltip': 'error',
|
|
33
33
|
'basalt/token-layer-boundary': 'error',
|
|
34
|
+
'basalt/agent-resume-guard': 'error',
|
|
35
|
+
'basalt/agent-no-raw-usechat': 'error',
|
|
36
|
+
'basalt/ai-sdk-major': 'error',
|
|
34
37
|
},
|
|
35
38
|
}),
|
|
36
39
|
)
|
|
@@ -232,3 +235,215 @@ describe('rule independence', () => {
|
|
|
232
235
|
expect(rules).toContain('visx-tooltip')
|
|
233
236
|
})
|
|
234
237
|
})
|
|
238
|
+
|
|
239
|
+
// ── agent-resume-guard ───────────────────────────────────────────────────────
|
|
240
|
+
// The two agent rules honour a SEPARATE escape token, `basalt-agent-allow`, not `theme-allow` — a
|
|
241
|
+
// color exemption must never be able to switch off a streaming guard. The `theme-allow` fixture
|
|
242
|
+
// below proves the wrong token does not suppress the finding.
|
|
243
|
+
|
|
244
|
+
describe('basalt/agent-resume-guard', () => {
|
|
245
|
+
it('flags useChat({ resume: true })', () => {
|
|
246
|
+
const { code, rules } = run(`const c = useChat({ id, resume: true })\n`)
|
|
247
|
+
expect(code).toBe(1)
|
|
248
|
+
expect(rules).toContain('agent-resume-guard')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('does NOT flag useChat({ resume: false })', () => {
|
|
252
|
+
const { code, rules } = run(`const c = useChat({ id, resume: false })\n`)
|
|
253
|
+
expect(code).toBe(0)
|
|
254
|
+
expect(rules).not.toContain('agent-resume-guard')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('does NOT flag a bare useChat({ id }) with no resume key', () => {
|
|
258
|
+
const { code, rules } = run(`const c = useChat({ id })\n`)
|
|
259
|
+
expect(code).toBe(0)
|
|
260
|
+
expect(rules).not.toContain('agent-resume-guard')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('flags chat.resumeStream() called from an effect', () => {
|
|
264
|
+
const { code, rules } = run(`useEffect(() => { chat.resumeStream() }, [])\n`)
|
|
265
|
+
expect(code).toBe(1)
|
|
266
|
+
expect(rules).toContain('agent-resume-guard')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('does NOT flag chat.resumeStream() marked basalt-agent-allow', () => {
|
|
270
|
+
const { code, rules } = run(`// basalt-agent-allow\nchat.resumeStream()\n`)
|
|
271
|
+
expect(code).toBe(0)
|
|
272
|
+
expect(rules).not.toContain('agent-resume-guard')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('STILL flags chat.resumeStream() marked only theme-allow (wrong escape token)', () => {
|
|
276
|
+
const { code, rules } = run(`// theme-allow\nchat.resumeStream()\n`)
|
|
277
|
+
expect(code).toBe(1)
|
|
278
|
+
expect(rules).toContain('agent-resume-guard')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('does NOT flag an unrelated call like runs.start(id, text)', () => {
|
|
282
|
+
const { code, rules } = run(`runs.start(id, text)\n`)
|
|
283
|
+
expect(code).toBe(0)
|
|
284
|
+
expect(rules).not.toContain('agent-resume-guard')
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
// ── agent-no-raw-usechat ─────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
describe('basalt/agent-no-raw-usechat', () => {
|
|
291
|
+
it('flags importing useChat from @ai-sdk/react', () => {
|
|
292
|
+
const { code, rules } = run(`import { useChat } from '@ai-sdk/react'\n`, 'lib.ts')
|
|
293
|
+
expect(code).toBe(1)
|
|
294
|
+
expect(rules).toContain('agent-no-raw-usechat')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('flags importing useCompletion from @ai-sdk/react', () => {
|
|
298
|
+
const { code, rules } = run(`import { useCompletion } from '@ai-sdk/react'\n`, 'lib.ts')
|
|
299
|
+
expect(code).toBe(1)
|
|
300
|
+
expect(rules).toContain('agent-no-raw-usechat')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('does NOT flag a type-only import of UIMessage from the same module', () => {
|
|
304
|
+
const { code, rules } = run(`import type { UIMessage } from '@ai-sdk/react'\n`, 'lib.ts')
|
|
305
|
+
expect(code).toBe(0)
|
|
306
|
+
expect(rules).not.toContain('agent-no-raw-usechat')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('does NOT flag an unrelated import from ai', () => {
|
|
310
|
+
const { code, rules } = run(`import { DefaultChatTransport } from 'ai'\n`, 'lib.ts')
|
|
311
|
+
expect(code).toBe(0)
|
|
312
|
+
expect(rules).not.toContain('agent-no-raw-usechat')
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('does NOT flag useChat imported from a local module', () => {
|
|
316
|
+
const { code, rules } = run(`import { useChat } from './my-hooks'\n`, 'lib.ts')
|
|
317
|
+
expect(code).toBe(0)
|
|
318
|
+
expect(rules).not.toContain('agent-no-raw-usechat')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
// The matcher covers TWO source specifiers (RAW_USE_CHAT_SOURCES: '@ai-sdk/react' AND
|
|
322
|
+
// 'ai/react') — every fixture above only exercises '@ai-sdk/react'; this pins the other half.
|
|
323
|
+
it("flags importing useChat from 'ai/react' (the other matched source)", () => {
|
|
324
|
+
const { code, rules } = run(`import { useChat } from 'ai/react'\n`, 'lib.ts')
|
|
325
|
+
expect(code).toBe(1)
|
|
326
|
+
expect(rules).toContain('agent-no-raw-usechat')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('does NOT flag an import marked basalt-agent-allow', () => {
|
|
330
|
+
const { code, rules } = run(
|
|
331
|
+
`// basalt-agent-allow\nimport { useChat } from '@ai-sdk/react'\n`,
|
|
332
|
+
'lib.ts',
|
|
333
|
+
)
|
|
334
|
+
expect(code).toBe(0)
|
|
335
|
+
expect(rules).not.toContain('agent-no-raw-usechat')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('STILL flags an import marked only theme-allow (wrong escape token)', () => {
|
|
339
|
+
const { code, rules } = run(
|
|
340
|
+
`// theme-allow\nimport { useChat } from '@ai-sdk/react'\n`,
|
|
341
|
+
'lib.ts',
|
|
342
|
+
)
|
|
343
|
+
expect(code).toBe(1)
|
|
344
|
+
expect(rules).toContain('agent-no-raw-usechat')
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
// ── ai-sdk-major ─────────────────────────────────────────────────────────────
|
|
349
|
+
// The lint rule compares the LINTED FILE's nearest package.json against basalt-ui's own declared
|
|
350
|
+
// `ai` peer major (read from packages/basalt-ui/package.json — the real file, since the plugin
|
|
351
|
+
// resolves it relative to its own module location regardless of where it's invoked from). basalt-ui
|
|
352
|
+
// declares "ai": "^7.0.15" (major 7) in both devDependencies and peerDependencies.
|
|
353
|
+
|
|
354
|
+
describe('basalt/ai-sdk-major', () => {
|
|
355
|
+
it("flags when the nearest package.json declares a different ai major (5 vs basalt's 7)", () => {
|
|
356
|
+
writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
|
|
357
|
+
const { code, rules } = run(`import { streamText } from 'ai'\n`, 'lib.ts')
|
|
358
|
+
expect(code).toBe(1)
|
|
359
|
+
expect(rules).toContain('ai-sdk-major')
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('does NOT flag when the nearest package.json declares the same major (^7.0.18)', () => {
|
|
363
|
+
writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '^7.0.18' } }))
|
|
364
|
+
const { code, rules } = run(`import { streamText } from 'ai'\n`, 'lib.ts')
|
|
365
|
+
expect(code).toBe(0)
|
|
366
|
+
expect(rules).not.toContain('ai-sdk-major')
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('does NOT flag when there is no ai in the nearest package.json', () => {
|
|
370
|
+
writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: {} }))
|
|
371
|
+
const { code, rules } = run(`import { streamText } from 'ai'\n`, 'lib.ts')
|
|
372
|
+
expect(code).toBe(0)
|
|
373
|
+
expect(rules).not.toContain('ai-sdk-major')
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('does NOT flag an unrelated import even with a skewed nearest package.json', () => {
|
|
377
|
+
writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
|
|
378
|
+
const { code, rules } = run(`import { useState } from 'react'\n`, 'lib.ts')
|
|
379
|
+
expect(code).toBe(0)
|
|
380
|
+
expect(rules).not.toContain('ai-sdk-major')
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
// The matcher covers TWO forms (source === 'ai' OR source.startsWith('@ai-sdk/')) — every
|
|
384
|
+
// fixture above only exercises the bare 'ai' specifier; this pins the scoped-package half.
|
|
385
|
+
it("flags a scoped '@ai-sdk/*' import when the nearest package.json declares a different ai major", () => {
|
|
386
|
+
writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
|
|
387
|
+
const { code, rules } = run(`import { openai } from '@ai-sdk/openai'\n`, 'lib.ts')
|
|
388
|
+
expect(code).toBe(1)
|
|
389
|
+
expect(rules).toContain('ai-sdk-major')
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it("does NOT flag a scoped '@ai-sdk/*' import when the nearest package.json declares the same major", () => {
|
|
393
|
+
writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '^7.0.18' } }))
|
|
394
|
+
const { code, rules } = run(`import { openai } from '@ai-sdk/openai'\n`, 'lib.ts')
|
|
395
|
+
expect(code).toBe(0)
|
|
396
|
+
expect(rules).not.toContain('ai-sdk-major')
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
// Unlike the other rules in this file, ai-sdk-major honours `basalt-agent-allow` — the SAME
|
|
400
|
+
// token as its two agent-chat siblings above — not `theme-allow`. A skewed producer/consumer
|
|
401
|
+
// import that is genuinely intentional (see doctor's `aiMajorSkewReason`) can mark the line.
|
|
402
|
+
it('does NOT flag an import marked basalt-agent-allow, even with a skewed nearest package.json', () => {
|
|
403
|
+
writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
|
|
404
|
+
const { code, rules } = run(
|
|
405
|
+
`// basalt-agent-allow\nimport { streamText } from 'ai'\n`,
|
|
406
|
+
'lib.ts',
|
|
407
|
+
)
|
|
408
|
+
expect(code).toBe(0)
|
|
409
|
+
expect(rules).not.toContain('ai-sdk-major')
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it('STILL flags an import marked only theme-allow (wrong escape token)', () => {
|
|
413
|
+
writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
|
|
414
|
+
const { code, rules } = run(`// theme-allow\nimport { streamText } from 'ai'\n`, 'lib.ts')
|
|
415
|
+
expect(code).toBe(1)
|
|
416
|
+
expect(rules).toContain('ai-sdk-major')
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
// ── raw-scroll-container — promotion (off → warn shipped) ────────────────────
|
|
421
|
+
|
|
422
|
+
describe('basalt/raw-scroll-container promotion', () => {
|
|
423
|
+
it('ships "warn" (not "off") in the consumer preset', () => {
|
|
424
|
+
const shipped = JSON.parse(
|
|
425
|
+
readFileSync(resolve(import.meta.dirname, 'oxlint.json'), 'utf8'),
|
|
426
|
+
) as { rules: Record<string, unknown> }
|
|
427
|
+
expect(shipped.rules['basalt/raw-scroll-container']).toBe('warn')
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('stays "warn" repo-local', () => {
|
|
431
|
+
const repoLocal = JSON.parse(
|
|
432
|
+
readFileSync(resolve(import.meta.dirname, '..', '..', '..', '.oxlintrc.json'), 'utf8'),
|
|
433
|
+
) as { rules: Record<string, unknown> }
|
|
434
|
+
expect(repoLocal.rules['basalt/raw-scroll-container']).toBe('warn')
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('still flags a raw overflow:auto style property at the shipped level', () => {
|
|
438
|
+
writeFileSync(
|
|
439
|
+
resolve(dir, '.oxlintrc.json'),
|
|
440
|
+
JSON.stringify({
|
|
441
|
+
plugins: [],
|
|
442
|
+
jsPlugins: [PLUGIN_PATH],
|
|
443
|
+
rules: { 'basalt/raw-scroll-container': 'warn' },
|
|
444
|
+
}),
|
|
445
|
+
)
|
|
446
|
+
const { rules } = run(`const s = { overflowY: 'auto' }\n`, 'lib.ts')
|
|
447
|
+
expect(rules).toContain('raw-scroll-container')
|
|
448
|
+
})
|
|
449
|
+
})
|
package/configs/oxlint.json
CHANGED
|
@@ -20,9 +20,12 @@
|
|
|
20
20
|
"basalt/raw-size-literal": "warn",
|
|
21
21
|
"basalt/card-inset": "error",
|
|
22
22
|
"basalt/chart-in-raw-surface": "error",
|
|
23
|
-
"basalt/raw-scroll-container": "
|
|
23
|
+
"basalt/raw-scroll-container": "warn",
|
|
24
24
|
"basalt/visx-boundary": "error",
|
|
25
|
-
"basalt/visx-tooltip": "error"
|
|
25
|
+
"basalt/visx-tooltip": "error",
|
|
26
|
+
"basalt/agent-resume-guard": "warn",
|
|
27
|
+
"basalt/agent-no-raw-usechat": "warn",
|
|
28
|
+
"basalt/ai-sdk-major": "warn"
|
|
26
29
|
},
|
|
27
30
|
"overrides": [
|
|
28
31
|
{
|
|
@@ -1,54 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* aiSdkTransport — an AgentTransport backed by Vercel AI SDK's native streaming + resumption
|
|
3
|
-
* primitives (the `ai` npm package). This is the RECOMMENDED DEFAULT transport for LLM chat use
|
|
4
|
-
* cases — edenTransport remains the zero-extra-dependency alternative (see agent/rules/basalt-agent.md).
|
|
5
|
-
*
|
|
6
|
-
* OPTIONAL PEER: `ai` is not a required dependency. Importing 'basalt-ui/agent' (and calling this
|
|
7
|
-
* factory) does NOT eagerly resolve it — the package is loaded via a memoized dynamic import()
|
|
8
|
-
* only when the first stream()/resume() call actually runs, mirroring the lazy-optional-peer
|
|
9
|
-
* contract used by BasaltStickToBottom. Unlike that, there is no meaningful "plain text" fallback
|
|
10
|
-
* for a missing transport: if 'ai' is not installed, the dynamic import
|
|
11
|
-
* rejects and the error propagates through the async generator to the consumer's EXISTING error
|
|
12
|
-
* handling (useAgentStream sets status: 'error'; useAgentThreadRuns falls back to its
|
|
13
|
-
* onFailureStatus) — no separate crash path is introduced.
|
|
14
|
-
*
|
|
15
|
-
* Install the optional peer:
|
|
16
|
-
* bun add ai
|
|
17
|
-
*
|
|
18
|
-
* WHY DIFFING IS NEEDED: AI SDK's `readUIMessageStream` yields the FULL accumulated `UIMessage`
|
|
19
|
-
* snapshot (a growing `parts` array, with existing parts' content growing in place) on every
|
|
20
|
-
* update — it is not itself a delta stream. basalt-ui's whole part-accumulation model
|
|
21
|
-
* (useAgentStream / useAgentThreadRuns, and every consumer's own coalescing) is delta-based: many
|
|
22
|
-
* small parts merged at render time. This module diffs consecutive snapshots and yields only the
|
|
23
|
-
* new deltas as AgentParts, so nothing is double-rendered.
|
|
24
|
-
*
|
|
25
|
-
* CHAT ID BINDING: AI SDK's convention is that the chat id is known client-side before the
|
|
26
|
-
* request (unlike a river/server-minted id) — `aiSdkTransport(options)` mints ONE stable chat id
|
|
27
|
-
* at construction time, so the returned object is immediately usable with useAgentStream (a single
|
|
28
|
-
* ongoing conversation). `.forThread(threadId)` returns a transport bound to an arbitrary
|
|
29
|
-
* caller-supplied id instead — pass a basalt-ui thread id here to plug into
|
|
30
|
-
* useAgentThreadRuns's per-thread transport-factory form, so a thread's turns (and its mount-time
|
|
31
|
-
* resume) share one continuous AI SDK chat session:
|
|
32
|
-
*
|
|
33
|
-
* @example
|
|
34
|
-
* import { useAgentStream, aiSdkTransport } from 'basalt-ui/agent'
|
|
35
|
-
*
|
|
36
|
-
* const transport = aiSdkTransport({ api: '/api/chat' })
|
|
37
|
-
* const { parts, status, send } = useAgentStream({ transport })
|
|
38
|
-
*
|
|
39
|
-
* @example
|
|
40
|
-
* import { useAgentThreadRuns, aiSdkTransport, createThreadsStore, heuristicOutcome } from 'basalt-ui/agent'
|
|
41
|
-
*
|
|
42
|
-
* const useThreads = createThreadsStore({ key: 'main-threads', version: 1 })
|
|
43
|
-
* const transport = aiSdkTransport({ api: '/api/chat' })
|
|
44
|
-
* const { runs, start } = useAgentThreadRuns({
|
|
45
|
-
* transport: (threadId) => transport.forThread(threadId),
|
|
46
|
-
* store: useThreads(),
|
|
47
|
-
* resolveOutcome: heuristicOutcome,
|
|
48
|
-
* })
|
|
49
|
-
*/
|
|
50
1
|
import type { AgentPart } from './parts.js';
|
|
51
|
-
import type {
|
|
2
|
+
import type { ResumableAgentTransport } from './transport.js';
|
|
52
3
|
export type AiSdkTransportOptions = {
|
|
53
4
|
/** The API URL AI SDK's transport POSTs new turns to and GETs reconnects from. */
|
|
54
5
|
readonly api: string;
|
|
@@ -62,13 +13,13 @@ export type AiSdkTransportOptions = {
|
|
|
62
13
|
*/
|
|
63
14
|
readonly fetch?: typeof globalThis.fetch;
|
|
64
15
|
};
|
|
65
|
-
export type AiSdkTransport<TPart = AgentPart> =
|
|
16
|
+
export type AiSdkTransport<TPart = AgentPart> = ResumableAgentTransport<TPart, string> & {
|
|
66
17
|
/**
|
|
67
18
|
* Returns an AgentTransport bound to a stable chat id (e.g. a basalt-ui thread id) instead of
|
|
68
19
|
* this transport's own fixed one. Use this for useAgentThreadRuns's per-thread transport-factory
|
|
69
20
|
* form so each thread's turns share one continuous AI SDK chat session.
|
|
70
21
|
*/
|
|
71
|
-
readonly forThread: (chatId: string) =>
|
|
22
|
+
readonly forThread: (chatId: string) => ResumableAgentTransport<TPart, string>;
|
|
72
23
|
};
|
|
73
24
|
/**
|
|
74
25
|
* Wraps AI SDK's `DefaultChatTransport` + `readUIMessageStream` into an AgentTransport.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ai-sdk-transport.d.ts","sourceRoot":"","sources":["../../src/agent/ai-sdk-transport.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ai-sdk-transport.d.ts","sourceRoot":"","sources":["../../src/agent/ai-sdk-transport.ts"],"names":[],"mappings":"AAmDA,OAAO,KAAK,EAAE,SAAS,EAAkB,MAAM,SAAS,CAAA;AACxD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAA;AAmB1D,MAAM,MAAM,qBAAqB,GAAG;IAClC,kFAAkF;IAClF,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,+CAA+C;IAC/C,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAA;IACnD,oFAAoF;IACpF,QAAQ,CAAC,WAAW,CAAC,EAAE,kBAAkB,CAAA;IACzC;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAA;CACzC,CAAA;AAID,MAAM,MAAM,cAAc,CAAC,KAAK,GAAG,SAAS,IAAI,uBAAuB,CAAC,KAAK,EAAE,MAAM,CAAC,GAAG;IACvF;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,uBAAuB,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;CAC/E,CAAA;AAwRD;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,KAAK,GAAG,SAAS,EAC9C,OAAO,EAAE,qBAAqB,GAC7B,cAAc,CAAC,KAAK,CAAC,CA6CvB"}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { assertNever } from "../register.js";
|
|
2
|
+
import { TERMINAL_TOOL_STATES } from "./parts.js";
|
|
1
3
|
function createAiSdkResolver(options) {
|
|
2
4
|
let resolved;
|
|
3
5
|
return function resolveAiSdk() {
|
|
@@ -15,55 +17,133 @@ function createAiSdkResolver(options) {
|
|
|
15
17
|
return resolved;
|
|
16
18
|
};
|
|
17
19
|
}
|
|
18
|
-
function diffPart(prevPart, currPart) {
|
|
20
|
+
function diffPart(chatId, index, prevPart, currPart, toolStartTimes) {
|
|
19
21
|
switch (currPart.type) {
|
|
20
22
|
case "text": {
|
|
21
23
|
const prevText = prevPart?.type === "text" ? prevPart.text : "";
|
|
22
24
|
const delta = currPart.text.slice(prevText.length);
|
|
23
|
-
return delta.length === 0 ? [] : [{ type: "text", text: delta }];
|
|
25
|
+
return delta.length === 0 ? [] : [{ id: `${chatId}#${index}`, type: "text", text: delta, offset: prevText.length }];
|
|
24
26
|
}
|
|
25
27
|
case "reasoning": {
|
|
26
28
|
const prevText = prevPart?.type === "reasoning" ? prevPart.text : "";
|
|
27
29
|
const delta = currPart.text.slice(prevText.length);
|
|
28
|
-
return delta.length === 0 ? [] : [{ type: "reasoning", text: delta }];
|
|
30
|
+
return delta.length === 0 ? [] : [{ id: `${chatId}#${index}`, type: "reasoning", text: delta, offset: prevText.length }];
|
|
29
31
|
}
|
|
30
32
|
case "source-url": {
|
|
31
33
|
if (prevPart !== void 0) return [];
|
|
32
|
-
const source = { type: "source", url: currPart.url };
|
|
34
|
+
const source = { id: `${chatId}#${index}`, type: "source", url: currPart.url };
|
|
33
35
|
return [currPart.title !== void 0 ? { ...source, title: currPart.title } : source];
|
|
34
36
|
}
|
|
35
37
|
default:
|
|
36
|
-
return currPart
|
|
38
|
+
return isToolLikePart(currPart) ? diffToolPart(prevPart, currPart, toolStartTimes) : [];
|
|
37
39
|
}
|
|
38
40
|
}
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
function isToolLikePart(part) {
|
|
42
|
+
return part.type === "dynamic-tool" || part.type.startsWith("tool-");
|
|
43
|
+
}
|
|
44
|
+
function deriveToolName(part) {
|
|
45
|
+
return part.type === "dynamic-tool" ? part.toolName : part.type.split("-").slice(1).join("-");
|
|
46
|
+
}
|
|
47
|
+
function outputSnapshot(part) {
|
|
48
|
+
return part.state === "output-available" ? { output: part.output, preliminary: part.preliminary } : void 0;
|
|
49
|
+
}
|
|
50
|
+
function isSameToolState(prev, curr) {
|
|
51
|
+
if (prev.state !== curr.state) return false;
|
|
52
|
+
const prevOutput = outputSnapshot(prev);
|
|
53
|
+
const currOutput = outputSnapshot(curr);
|
|
54
|
+
if (prevOutput === void 0 || currOutput === void 0) return true;
|
|
55
|
+
return JSON.stringify(prevOutput.output) === JSON.stringify(currOutput.output) && prevOutput.preliminary === currOutput.preliminary;
|
|
56
|
+
}
|
|
57
|
+
function diffToolPart(prevPart, currPart, toolStartTimes) {
|
|
58
|
+
if (!toolStartTimes.has(currPart.toolCallId)) {
|
|
59
|
+
toolStartTimes.set(currPart.toolCallId, Date.now());
|
|
60
|
+
}
|
|
61
|
+
const prev = prevPart !== void 0 && isToolLikePart(prevPart) ? prevPart : void 0;
|
|
62
|
+
if (prev !== void 0 && isSameToolState(prev, currPart)) return [];
|
|
63
|
+
const settled = TERMINAL_TOOL_STATES.includes(currPart.state);
|
|
64
|
+
const durationMs = settled ? Date.now() - (toolStartTimes.get(currPart.toolCallId) ?? Date.now()) : void 0;
|
|
45
65
|
const base = {
|
|
66
|
+
id: `tool#${currPart.toolCallId}`,
|
|
46
67
|
type: "tool",
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
toolCallId: currPart.toolCallId,
|
|
69
|
+
toolName: deriveToolName(currPart),
|
|
70
|
+
...currPart.providerExecuted !== void 0 ? { providerExecuted: currPart.providerExecuted } : {},
|
|
71
|
+
...durationMs !== void 0 ? { durationMs } : {}
|
|
50
72
|
};
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
73
|
+
switch (currPart.state) {
|
|
74
|
+
case "input-streaming":
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
...base,
|
|
78
|
+
state: "input-streaming",
|
|
79
|
+
...currPart.input !== void 0 ? { input: currPart.input } : {}
|
|
80
|
+
}
|
|
81
|
+
];
|
|
82
|
+
case "input-available":
|
|
83
|
+
return [{ ...base, state: "input-available", input: currPart.input }];
|
|
84
|
+
case "approval-requested":
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
...base,
|
|
88
|
+
state: "approval-requested",
|
|
89
|
+
input: currPart.input,
|
|
90
|
+
approval: currPart.approval
|
|
91
|
+
}
|
|
92
|
+
];
|
|
93
|
+
case "approval-responded":
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
...base,
|
|
97
|
+
state: "approval-responded",
|
|
98
|
+
input: currPart.input,
|
|
99
|
+
approval: currPart.approval
|
|
100
|
+
}
|
|
101
|
+
];
|
|
102
|
+
case "output-available":
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
...base,
|
|
106
|
+
state: "output-available",
|
|
107
|
+
input: currPart.input,
|
|
108
|
+
output: currPart.output,
|
|
109
|
+
...currPart.preliminary !== void 0 ? { preliminary: currPart.preliminary } : {},
|
|
110
|
+
...currPart.approval !== void 0 ? { approval: currPart.approval } : {}
|
|
111
|
+
}
|
|
112
|
+
];
|
|
113
|
+
case "output-error": {
|
|
114
|
+
const rawInput = currPart.type === "dynamic-tool" ? void 0 : currPart.rawInput;
|
|
115
|
+
return [
|
|
116
|
+
{
|
|
117
|
+
...base,
|
|
118
|
+
state: "output-error",
|
|
119
|
+
...currPart.input !== void 0 ? { input: currPart.input } : {},
|
|
120
|
+
errorText: currPart.errorText,
|
|
121
|
+
...rawInput !== void 0 ? { rawInput } : {},
|
|
122
|
+
...currPart.approval !== void 0 ? { approval: currPart.approval } : {}
|
|
123
|
+
}
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
case "output-denied":
|
|
127
|
+
return [
|
|
128
|
+
{ ...base, state: "output-denied", input: currPart.input, approval: currPart.approval }
|
|
129
|
+
];
|
|
130
|
+
default:
|
|
131
|
+
return assertNever(currPart);
|
|
132
|
+
}
|
|
54
133
|
}
|
|
55
|
-
function diffMessage(prev, curr) {
|
|
134
|
+
function diffMessage(chatId, prev, curr, toolStartTimes) {
|
|
56
135
|
const deltas = [];
|
|
57
136
|
curr.parts.forEach((part, i) => {
|
|
58
|
-
deltas.push(...diffPart(prev?.parts[i], part));
|
|
137
|
+
deltas.push(...diffPart(chatId, i, prev?.parts[i], part, toolStartTimes));
|
|
59
138
|
});
|
|
60
139
|
return deltas;
|
|
61
140
|
}
|
|
62
|
-
async function* diffChunkStream(chunkStream, readStream, signal) {
|
|
141
|
+
async function* diffChunkStream(chatId, chunkStream, readStream, signal) {
|
|
63
142
|
let prev;
|
|
143
|
+
const toolStartTimes = /* @__PURE__ */ new Map();
|
|
64
144
|
for await (const curr of readStream({ stream: chunkStream })) {
|
|
65
145
|
if (signal?.aborted) return;
|
|
66
|
-
for (const part of diffMessage(prev, curr)) {
|
|
146
|
+
for (const part of diffMessage(chatId, prev, curr, toolStartTimes)) {
|
|
67
147
|
yield part;
|
|
68
148
|
}
|
|
69
149
|
prev = curr;
|
|
@@ -73,8 +153,12 @@ function aiSdkTransport(options) {
|
|
|
73
153
|
const resolveAiSdk = createAiSdkResolver(options);
|
|
74
154
|
function makeTransport(chatId) {
|
|
75
155
|
return {
|
|
156
|
+
// Every text/reasoning delta this transport emits carries an authoritative `offset` (see
|
|
157
|
+
// diffPart), so a replayed run rewrites in place instead of duplicating — the literal
|
|
158
|
+
// condition this assertion stands for.
|
|
159
|
+
idempotentReplay: true,
|
|
76
160
|
async *stream(input, signal) {
|
|
77
|
-
yield { type: "start", runId: chatId, resumeToken: chatId };
|
|
161
|
+
yield { id: `${chatId}#start`, type: "start", runId: chatId, resumeToken: chatId };
|
|
78
162
|
const { httpTransport, readUIMessageStream: readStream } = await resolveAiSdk();
|
|
79
163
|
const userMessage = {
|
|
80
164
|
id: crypto.randomUUID(),
|
|
@@ -88,13 +172,13 @@ function aiSdkTransport(options) {
|
|
|
88
172
|
messageId: void 0,
|
|
89
173
|
abortSignal: signal
|
|
90
174
|
});
|
|
91
|
-
yield* diffChunkStream(chunkStream, readStream, signal);
|
|
175
|
+
yield* diffChunkStream(chatId, chunkStream, readStream, signal);
|
|
92
176
|
},
|
|
93
177
|
async *resume(resumeToken, signal) {
|
|
94
178
|
const { httpTransport, readUIMessageStream: readStream } = await resolveAiSdk();
|
|
95
179
|
const chunkStream = await httpTransport.reconnectToStream({ chatId: resumeToken });
|
|
96
180
|
if (chunkStream === null) return;
|
|
97
|
-
yield* diffChunkStream(chunkStream, readStream, signal);
|
|
181
|
+
yield* diffChunkStream(resumeToken, chunkStream, readStream, signal);
|
|
98
182
|
}
|
|
99
183
|
};
|
|
100
184
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coalesceParts — merges a rendered-turn's parts array for display.
|
|
3
|
+
*
|
|
4
|
+
* Merges consecutive `text`+`text` and `reasoning`+`reasoning` parts (streamed answers arrive as
|
|
5
|
+
* many small fragments; this renders a streaming reply as one flowing block instead of a stack of
|
|
6
|
+
* fragments). Merges `tool` parts BY `toolCallId` regardless of adjacency — the AI SDK re-emits a
|
|
7
|
+
* tool call's `input-available` then `output-available` as two separate parts, not necessarily
|
|
8
|
+
* adjacent once other parts interleave; without this rule a UI renders two stacked tool blocks for
|
|
9
|
+
* one call. The later state always wins; the merged part keeps the position of its FIRST
|
|
10
|
+
* occurrence (stable ordering); and an `input` (or `toolName`) supplied by an earlier state
|
|
11
|
+
* survives a later state that omits it — `tool-output-available` carries only `output` on the
|
|
12
|
+
* wire, the SDK re-supplies `input` by reading it back off the stored invocation, and a merge that
|
|
13
|
+
* replaces wholesale rather than accumulating would lose it outright.
|
|
14
|
+
*
|
|
15
|
+
* `approval`-bearing responses can arrive without a usable `toolCallId` (the SDK resolves them by
|
|
16
|
+
* reverse lookup on `approval.id` instead) — this maintains an `approvalId -> toolCallId` side
|
|
17
|
+
* index while folding and routes such a part through it. A part resolvable by neither key is kept
|
|
18
|
+
* as-is rather than dropped; never silently discard a part.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* import { coalesceParts } from 'basalt-ui/agent'
|
|
22
|
+
* <PartList parts={coalesceParts(parts)} />
|
|
23
|
+
*/
|
|
24
|
+
import type { AgentPart } from './parts.js';
|
|
25
|
+
export declare function coalesceParts<TPart extends AgentPart>(parts: readonly TPart[]): TPart[];
|
|
26
|
+
//# sourceMappingURL=coalesce.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"coalesce.d.ts","sourceRoot":"","sources":["../../src/agent/coalesce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,KAAK,EAAE,SAAS,EAAyC,MAAM,SAAS,CAAA;AA+C/E,wBAAgB,aAAa,CAAC,KAAK,SAAS,SAAS,EAAE,KAAK,EAAE,SAAS,KAAK,EAAE,GAAG,KAAK,EAAE,CA+CvF"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function isTextLikeType(type) {
|
|
2
|
+
return type === "text" || type === "reasoning";
|
|
3
|
+
}
|
|
4
|
+
function resolveToolCallId(part, toolCallIdByApprovalId) {
|
|
5
|
+
if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) return part.toolCallId;
|
|
6
|
+
const approval = "approval" in part ? part.approval : void 0;
|
|
7
|
+
return approval !== void 0 ? toolCallIdByApprovalId.get(approval.id) : void 0;
|
|
8
|
+
}
|
|
9
|
+
function registerApprovalIndex(part, toolCallIdByApprovalId) {
|
|
10
|
+
const approval = "approval" in part ? part.approval : void 0;
|
|
11
|
+
if (approval === void 0) return;
|
|
12
|
+
if (typeof part.toolCallId !== "string" || part.toolCallId.length === 0) return;
|
|
13
|
+
toolCallIdByApprovalId.set(approval.id, part.toolCallId);
|
|
14
|
+
}
|
|
15
|
+
function mergeToolPart(existing, next) {
|
|
16
|
+
const existingInput = "input" in existing ? existing.input : void 0;
|
|
17
|
+
const nextInput = "input" in next ? next.input : void 0;
|
|
18
|
+
const input = nextInput !== void 0 ? nextInput : existingInput;
|
|
19
|
+
const toolName = next.toolName.length > 0 ? next.toolName : existing.toolName;
|
|
20
|
+
return {
|
|
21
|
+
...existing,
|
|
22
|
+
...next,
|
|
23
|
+
toolName,
|
|
24
|
+
...input !== void 0 ? { input } : {}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function coalesceParts(parts) {
|
|
28
|
+
const out = [];
|
|
29
|
+
const toolIndexByCallId = /* @__PURE__ */ new Map();
|
|
30
|
+
const toolCallIdByApprovalId = /* @__PURE__ */ new Map();
|
|
31
|
+
for (const part of parts) {
|
|
32
|
+
if (isTextLikeType(part.type)) {
|
|
33
|
+
const last = out[out.length - 1];
|
|
34
|
+
if (last !== void 0 && last.type === part.type) {
|
|
35
|
+
const merged = {
|
|
36
|
+
...part,
|
|
37
|
+
text: last.text + part.text
|
|
38
|
+
};
|
|
39
|
+
out[out.length - 1] = merged;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
out.push(part);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (part.type === "tool") {
|
|
46
|
+
const toolPart = part;
|
|
47
|
+
registerApprovalIndex(toolPart, toolCallIdByApprovalId);
|
|
48
|
+
const resolvedCallId = resolveToolCallId(toolPart, toolCallIdByApprovalId);
|
|
49
|
+
if (resolvedCallId === void 0) {
|
|
50
|
+
out.push(part);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const existingIndex = toolIndexByCallId.get(resolvedCallId);
|
|
54
|
+
if (existingIndex === void 0) {
|
|
55
|
+
toolIndexByCallId.set(resolvedCallId, out.length);
|
|
56
|
+
out.push(part);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const existing = out[existingIndex];
|
|
60
|
+
out[existingIndex] = mergeToolPart(existing, toolPart);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
out.push(part);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
export {
|
|
68
|
+
coalesceParts
|
|
69
|
+
};
|