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.
Files changed (104) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +64 -34
  4. package/agent/rules/basalt-agent.md +49 -6
  5. package/configs/oxlint-plugin.js +279 -0
  6. package/configs/oxlint-plugin.test.ts +216 -1
  7. package/configs/oxlint.json +5 -2
  8. package/dist/agent/ai-sdk-transport.d.ts +3 -52
  9. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  10. package/dist/agent/ai-sdk-transport.js +108 -24
  11. package/dist/agent/coalesce.d.ts +26 -0
  12. package/dist/agent/coalesce.d.ts.map +1 -0
  13. package/dist/agent/coalesce.js +69 -0
  14. package/dist/agent/foreign.d.ts +97 -0
  15. package/dist/agent/foreign.d.ts.map +1 -0
  16. package/dist/agent/foreign.js +18 -0
  17. package/dist/agent/history.d.ts +2 -0
  18. package/dist/agent/history.d.ts.map +1 -1
  19. package/dist/agent/id.d.ts +35 -0
  20. package/dist/agent/id.d.ts.map +1 -0
  21. package/dist/agent/id.js +14 -0
  22. package/dist/agent/index.d.ts +9 -4
  23. package/dist/agent/index.d.ts.map +1 -1
  24. package/dist/agent/index.js +15 -3
  25. package/dist/agent/merge.d.ts +8 -0
  26. package/dist/agent/merge.d.ts.map +1 -0
  27. package/dist/agent/merge.js +23 -0
  28. package/dist/agent/part-list.d.ts.map +1 -1
  29. package/dist/agent/part-list.js +16 -6
  30. package/dist/agent/parts.d.ts +100 -21
  31. package/dist/agent/parts.d.ts.map +1 -1
  32. package/dist/agent/parts.js +128 -11
  33. package/dist/agent/transport.d.ts +39 -3
  34. package/dist/agent/transport.d.ts.map +1 -1
  35. package/dist/agent/transport.js +20 -17
  36. package/dist/agent/use-agent-stream.d.ts +2 -1
  37. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  38. package/dist/agent/use-agent-stream.js +5 -2
  39. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  40. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  41. package/dist/agent/use-agent-thread-runs.js +76 -23
  42. package/dist/agent-chat/index.d.ts +6 -3
  43. package/dist/agent-chat/index.d.ts.map +1 -1
  44. package/dist/agent-chat/index.js +2 -0
  45. package/dist/agent-chat/thread-message.d.ts +24 -7
  46. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  47. package/dist/agent-chat/thread-message.js +102 -43
  48. package/dist/agent-chat/tool-chip.d.ts +21 -0
  49. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  50. package/dist/agent-chat/tool-chip.js +120 -0
  51. package/dist/cli/index.d.ts +33 -0
  52. package/dist/cli/index.d.ts.map +1 -1
  53. package/dist/cli/index.js +131 -0
  54. package/dist/guard/index.d.ts.map +1 -1
  55. package/dist/guard/index.js +1 -3
  56. package/dist/index.d.ts +2 -2
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +3 -1
  59. package/dist/surfaces.d.ts +11 -1
  60. package/dist/surfaces.d.ts.map +1 -1
  61. package/dist/surfaces.js +22 -1
  62. package/llms.txt +10 -3
  63. package/package.json +6 -1
  64. package/src/agent/ai-sdk-transport.test.ts +567 -0
  65. package/src/agent/ai-sdk-transport.ts +206 -53
  66. package/src/agent/coalesce.test.ts +194 -0
  67. package/src/agent/coalesce.ts +118 -0
  68. package/src/agent/foreign.test.ts +47 -0
  69. package/src/agent/foreign.ts +140 -0
  70. package/src/agent/history.ts +2 -0
  71. package/src/agent/id.test.ts +71 -0
  72. package/src/agent/id.ts +43 -0
  73. package/src/agent/index.ts +26 -3
  74. package/src/agent/merge.test.ts +119 -0
  75. package/src/agent/merge.ts +67 -0
  76. package/src/agent/part-list.test.tsx +82 -0
  77. package/src/agent/part-list.tsx +31 -6
  78. package/src/agent/parts.test.ts +603 -0
  79. package/src/agent/parts.ts +262 -32
  80. package/src/agent/thread.test.ts +147 -0
  81. package/src/agent/transport.test.ts +70 -0
  82. package/src/agent/transport.ts +72 -20
  83. package/src/agent/use-agent-stream.test.tsx +261 -0
  84. package/src/agent/use-agent-stream.ts +13 -3
  85. package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
  86. package/src/agent/use-agent-thread-runs.resume.test.tsx +289 -0
  87. package/src/agent/use-agent-thread-runs.test.tsx +781 -0
  88. package/src/agent/use-agent-thread-runs.ts +177 -27
  89. package/src/agent/use-agent-thread-runs.wedge.test.tsx +301 -0
  90. package/src/agent-chat/index.ts +8 -3
  91. package/src/agent-chat/thread-message.test.tsx +211 -0
  92. package/src/agent-chat/thread-message.tsx +235 -62
  93. package/src/agent-chat/tool-chip.test.tsx +139 -0
  94. package/src/agent-chat/tool-chip.tsx +219 -0
  95. package/src/charts/primitives/ChartFrame.test.tsx +8 -5
  96. package/src/cli/index.ts +285 -0
  97. package/src/dashboard/stat-card.test.tsx +5 -3
  98. package/src/guard/check-source.test.ts +8 -4
  99. package/src/guard/index.ts +1 -3
  100. package/src/index.ts +2 -0
  101. package/src/provider/index.test.tsx +6 -5
  102. package/src/surfaces.ts +23 -1
  103. package/src/theme/use-basalt-spacing.test.tsx +7 -4
  104. 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
+ })
@@ -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": "off",
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 { AgentTransport } from './transport.js';
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> = AgentTransport<TPart, string> & {
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) => AgentTransport<TPart, 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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,OAAO,KAAK,EAAE,SAAS,EAA4B,MAAM,SAAS,CAAA;AAClE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAYjD,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,cAAc,CAAC,KAAK,EAAE,MAAM,CAAC,GAAG;IAC9E;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,cAAc,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;CACtE,CAAA;AA8ID;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,KAAK,GAAG,SAAS,EAC9C,OAAO,EAAE,qBAAqB,GAC7B,cAAc,CAAC,KAAK,CAAC,CAuCvB"}
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.type.startsWith("tool-") ? diffToolPart(prevPart, currPart) : [];
38
+ return isToolLikePart(currPart) ? diffToolPart(prevPart, currPart, toolStartTimes) : [];
37
39
  }
38
40
  }
39
- function diffToolPart(prevPart, currPart) {
40
- const curr = currPart;
41
- if (curr.state === "input-streaming") return [];
42
- const prev = prevPart !== void 0 && prevPart.type === currPart.type ? prevPart : void 0;
43
- if (prev?.state === curr.state) return [];
44
- const toolName = curr.type.slice("tool-".length);
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
- toolName,
48
- toolCallId: curr.toolCallId,
49
- input: curr.input
68
+ toolCallId: currPart.toolCallId,
69
+ toolName: deriveToolName(currPart),
70
+ ...currPart.providerExecuted !== void 0 ? { providerExecuted: currPart.providerExecuted } : {},
71
+ ...durationMs !== void 0 ? { durationMs } : {}
50
72
  };
51
- if (curr.state === "output-available") return [{ ...base, output: curr.output }];
52
- if (curr.state === "output-error") return [{ ...base, output: { error: curr.errorText } }];
53
- return [base];
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
+ };