basalt-ui 1.11.0 → 1.12.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 +21 -21
- package/CHANGELOG.md +15 -0
- package/README.md +36 -27
- package/agent/rules/basalt-content.md +16 -3
- package/dist/agent/adapter.d.ts +146 -0
- package/dist/agent/adapter.d.ts.map +1 -0
- package/dist/agent/adapter.js +473 -0
- package/dist/agent/adapter.test.d.ts +2 -0
- package/dist/agent/adapter.test.d.ts.map +1 -0
- package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
- package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
- package/dist/agent/coalesce.d.ts +20 -4
- package/dist/agent/coalesce.d.ts.map +1 -1
- package/dist/agent/coalesce.js +76 -8
- package/dist/agent/coalesce.test.d.ts +2 -0
- package/dist/agent/coalesce.test.d.ts.map +1 -0
- package/dist/agent/foreign.test.d.ts +2 -0
- package/dist/agent/foreign.test.d.ts.map +1 -0
- package/dist/agent/id.d.ts +30 -0
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +16 -0
- package/dist/agent/id.test.d.ts +2 -0
- package/dist/agent/id.test.d.ts.map +1 -0
- package/dist/agent/index.d.ts +2 -0
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +3 -0
- package/dist/agent/merge.d.ts.map +1 -1
- package/dist/agent/merge.js +19 -7
- package/dist/agent/merge.test.d.ts +2 -0
- package/dist/agent/merge.test.d.ts.map +1 -0
- package/dist/agent/part-list.d.ts +49 -34
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +42 -6
- package/dist/agent/part-list.test.d.ts +2 -0
- package/dist/agent/part-list.test.d.ts.map +1 -0
- package/dist/agent/parts.d.ts +46 -10
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +16 -6
- package/dist/agent/parts.test.d.ts +2 -0
- package/dist/agent/parts.test.d.ts.map +1 -0
- package/dist/agent/thread.d.ts +14 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/thread.js +8 -2
- package/dist/agent/thread.test.d.ts +2 -0
- package/dist/agent/thread.test.d.ts.map +1 -0
- package/dist/agent/transport.test.d.ts +2 -0
- package/dist/agent/transport.test.d.ts.map +1 -0
- package/dist/agent/use-agent-stream.test.d.ts +2 -0
- package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
- package/dist/agent-chat/composer.d.ts +130 -8
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +189 -23
- package/dist/agent-chat/composer.test.d.ts +2 -0
- package/dist/agent-chat/composer.test.d.ts.map +1 -0
- package/dist/agent-chat/index.d.ts +1 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +14 -3
- package/dist/agent-chat/thread-message.test.d.ts +2 -0
- package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-workspace.js +1 -1
- package/dist/agent-chat/tool-chip.test.d.ts +2 -0
- package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
- package/dist/charts/utils/domain.test.d.ts +2 -0
- package/dist/charts/utils/domain.test.d.ts.map +1 -0
- package/dist/cli/check-theme.test.d.ts +2 -0
- package/dist/cli/check-theme.test.d.ts.map +1 -0
- package/dist/cli/help.test.d.ts +2 -0
- package/dist/cli/help.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/init-sync.test.d.ts +2 -0
- package/dist/cli/init-sync.test.d.ts.map +1 -0
- package/dist/cli/placement-engine.test.d.ts +2 -0
- package/dist/cli/placement-engine.test.d.ts.map +1 -0
- package/dist/cli/tarball-parity.test.d.ts +2 -0
- package/dist/cli/tarball-parity.test.d.ts.map +1 -0
- package/dist/cli/template-format.test.d.ts +2 -0
- package/dist/cli/template-format.test.d.ts.map +1 -0
- package/dist/cli/template-ledger.test.d.ts +2 -0
- package/dist/cli/template-ledger.test.d.ts.map +1 -0
- package/dist/cli/tokens-css.test.d.ts +2 -0
- package/dist/cli/tokens-css.test.d.ts.map +1 -0
- package/dist/content/article-actions.test.d.ts +2 -0
- package/dist/content/article-actions.test.d.ts.map +1 -0
- package/dist/content/article-model.test.d.ts +2 -0
- package/dist/content/article-model.test.d.ts.map +1 -0
- package/dist/content/block-split.test.d.ts +2 -0
- package/dist/content/block-split.test.d.ts.map +1 -0
- package/dist/content/fence-block.d.ts +37 -10
- package/dist/content/fence-block.d.ts.map +1 -1
- package/dist/content/fence-block.js +80 -14
- package/dist/content/heading-components.d.ts.map +1 -1
- package/dist/content/heading-components.js +7 -4
- package/dist/content/heading-components.test.d.ts +2 -0
- package/dist/content/heading-components.test.d.ts.map +1 -0
- package/dist/content/index.d.ts +6 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +5 -1
- package/dist/content/markdown.d.ts +101 -14
- package/dist/content/markdown.d.ts.map +1 -1
- package/dist/content/markdown.js +141 -28
- package/dist/content/markdown.test.d.ts +2 -0
- package/dist/content/markdown.test.d.ts.map +1 -0
- package/dist/content/prose.module.css.test.d.ts +2 -0
- package/dist/content/prose.module.css.test.d.ts.map +1 -0
- package/dist/content/sanitize.d.ts +170 -0
- package/dist/content/sanitize.d.ts.map +1 -0
- package/dist/content/sanitize.js +178 -0
- package/dist/content/sanitize.test.d.ts +2 -0
- package/dist/content/sanitize.test.d.ts.map +1 -0
- package/dist/content/slug.test.d.ts +2 -0
- package/dist/content/slug.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.test.d.ts +2 -0
- package/dist/dashboard/stat-card.test.d.ts.map +1 -0
- package/dist/guard/check-source.test.d.ts +2 -0
- package/dist/guard/check-source.test.d.ts.map +1 -0
- package/dist/guard/guard-hook.test.d.ts +2 -0
- package/dist/guard/guard-hook.test.d.ts.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/provider/index.test.d.ts +2 -0
- package/dist/provider/index.test.d.ts.map +1 -0
- package/dist/provider/lab-theme.test.d.ts +2 -0
- package/dist/provider/lab-theme.test.d.ts.map +1 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
- package/dist/styles.floor.test.d.ts +2 -0
- package/dist/styles.floor.test.d.ts.map +1 -0
- package/dist/surfaces.d.ts +4 -4
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +12 -3
- package/dist/theme/border-coverage.test.d.ts +18 -0
- package/dist/theme/border-coverage.test.d.ts.map +1 -0
- package/dist/theme/contrast.test.d.ts +2 -0
- package/dist/theme/contrast.test.d.ts.map +1 -0
- package/dist/theme/density-option.test.d.ts +2 -0
- package/dist/theme/density-option.test.d.ts.map +1 -0
- package/dist/theme/density-relations.test.d.ts +2 -0
- package/dist/theme/density-relations.test.d.ts.map +1 -0
- package/dist/theme/derive-theme.test.d.ts +2 -0
- package/dist/theme/derive-theme.test.d.ts.map +1 -0
- package/dist/theme/fonts-option.test.d.ts +2 -0
- package/dist/theme/fonts-option.test.d.ts.map +1 -0
- package/dist/theme/radius-option.test.d.ts +2 -0
- package/dist/theme/radius-option.test.d.ts.map +1 -0
- package/dist/theme/radius.test.d.ts +2 -0
- package/dist/theme/radius.test.d.ts.map +1 -0
- package/dist/theme/shadow-surfaces.test.d.ts +26 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
- package/dist/theme/spacing.test.d.ts +2 -0
- package/dist/theme/spacing.test.d.ts.map +1 -0
- package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
- package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-controls.test.d.ts +2 -0
- package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-state.test.d.ts +2 -0
- package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
- package/dist/theme-lab/theme-lab.test.d.ts +2 -0
- package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
- package/dist/tokens/build-fonts-css.test.d.ts +2 -0
- package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
- package/dist/tokens/build-radius-css.test.d.ts +2 -0
- package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
- package/dist/tokens/density.test.d.ts +2 -0
- package/dist/tokens/density.test.d.ts.map +1 -0
- package/dist/tokens/derive-radius.test.d.ts +2 -0
- package/dist/tokens/derive-radius.test.d.ts.map +1 -0
- package/dist/tokens/derive.test.d.ts +2 -0
- package/dist/tokens/derive.test.d.ts.map +1 -0
- package/dist/tokens/palette.test.d.ts +2 -0
- package/dist/tokens/palette.test.d.ts.map +1 -0
- package/dist/vite-color-mix.test.d.ts +2 -0
- package/dist/vite-color-mix.test.d.ts.map +1 -0
- package/dist/vite.test.d.ts +2 -0
- package/dist/vite.test.d.ts.map +1 -0
- package/llms.txt +6 -6
- package/package.json +6 -1
- package/src/agent/adapter.test.ts +1403 -0
- package/src/agent/adapter.ts +887 -0
- package/src/agent/ai-sdk-transport.test.ts +53 -26
- package/src/agent/coalesce.test.ts +202 -9
- package/src/agent/coalesce.ts +126 -15
- package/src/agent/foreign.test.ts +5 -1
- package/src/agent/id.test.ts +6 -2
- package/src/agent/id.ts +48 -0
- package/src/agent/index.ts +8 -0
- package/src/agent/merge.test.ts +87 -0
- package/src/agent/merge.ts +57 -10
- package/src/agent/part-list.test.tsx +31 -1
- package/src/agent/part-list.tsx +77 -29
- package/src/agent/parts.test.ts +121 -4
- package/src/agent/parts.ts +78 -17
- package/src/agent/thread.test.ts +75 -2
- package/src/agent/thread.ts +21 -1
- package/src/agent/use-agent-stream.test.tsx +9 -5
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
- package/src/agent/use-agent-thread-runs.test.tsx +30 -17
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +13 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +3 -1
- package/src/agent-chat/thread-message.test.tsx +141 -1
- package/src/agent-chat/thread-message.tsx +29 -4
- package/src/agent-chat/thread-workspace.tsx +1 -1
- package/src/cli/index.ts +0 -1
- package/src/cli/template-format.test.ts +2 -2
- package/src/content/fence-block.tsx +242 -15
- package/src/content/heading-components.test.tsx +65 -0
- package/src/content/heading-components.tsx +26 -5
- package/src/content/index.ts +19 -3
- package/src/content/markdown.test.tsx +603 -0
- package/src/content/markdown.tsx +355 -35
- package/src/content/sanitize.test.ts +659 -0
- package/src/content/sanitize.ts +510 -0
- package/src/dashboard/stat-card.test.tsx +6 -1
- package/src/index.ts +13 -1
- package/src/surfaces.ts +12 -3
- package/src/theme/contrast.test.ts +1 -4
- package/src/theme/density-relations.test.ts +4 -2
- package/src/theme/fonts-option.test.ts +7 -1
- package/src/theme/shadow-surfaces.test.ts +12 -6
- package/src/theme/use-basalt-spacing.test.tsx +2 -1
package/src/agent/id.test.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { describe, expect, test } from 'bun:test'
|
|
6
6
|
import { withPartIds } from './id'
|
|
7
|
+
import type { AgentPartDraft } from './parts'
|
|
7
8
|
|
|
8
9
|
async function* gen<T>(values: T[]): AsyncGenerator<T> {
|
|
9
10
|
for (const value of values) yield value
|
|
@@ -17,7 +18,10 @@ async function collect<T>(source: AsyncGenerator<T>): Promise<T[]> {
|
|
|
17
18
|
|
|
18
19
|
describe('withPartIds', () => {
|
|
19
20
|
test('stamps a sequential id on every draft missing one', async () => {
|
|
20
|
-
|
|
21
|
+
// Annotated: all three literals uniformly omit `id`, so without this TS infers an `id`-less
|
|
22
|
+
// element type that has no properties in common with withPartIds' `{ id?: string }`
|
|
23
|
+
// constraint — a weak-type check failure, not a real shape mismatch.
|
|
24
|
+
const drafts: AgentPartDraft[] = [
|
|
21
25
|
{ type: 'text', text: 'a' },
|
|
22
26
|
{ type: 'text', text: 'b' },
|
|
23
27
|
{ type: 'text', text: 'c' },
|
|
@@ -37,7 +41,7 @@ describe('withPartIds', () => {
|
|
|
37
41
|
})
|
|
38
42
|
|
|
39
43
|
test('is idempotent: re-running withPartIds over already-identified parts changes nothing', async () => {
|
|
40
|
-
const drafts = [{ type: 'text', text: 'a' }]
|
|
44
|
+
const drafts: AgentPartDraft[] = [{ type: 'text', text: 'a' }]
|
|
41
45
|
const once = await collect(withPartIds('run-1', gen(drafts)))
|
|
42
46
|
const twice = await collect(withPartIds('run-1', gen(once)))
|
|
43
47
|
expect(twice).toEqual(once)
|
package/src/agent/id.ts
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Id-minting helpers shared by the thread stores: `mintThreadId` (a client-side thread id, used by
|
|
3
|
+
* both `./thread`'s `createThreadsStore` and `./adapter`'s `createAdapterThreadsStore`) and
|
|
4
|
+
* `withPartIds` (stamps sequence ids onto streamed parts). Related in theme — both mint an id a
|
|
5
|
+
* store needs — unrelated in mechanism; grouped here because they're the two id-minting leaves the
|
|
6
|
+
* two thread stores both sit above.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mints a client-side thread id. `crypto.randomUUID()` exists only in SECURE CONTEXTS (HTTPS or
|
|
11
|
+
* localhost) — a consumer served over plain HTTP on a LAN hostname (a homelab dashboard on a bare
|
|
12
|
+
* IP, a staging box) would otherwise have `create()`, about the most basic user action a thread
|
|
13
|
+
* store has, throw a plain TypeError. A store used from a browser must not throw on a user gesture
|
|
14
|
+
* — the same principle already applied twice this release (`spliceText` clamps rather than throws,
|
|
15
|
+
* `coalesceParts` degrades rather than throws).
|
|
16
|
+
*
|
|
17
|
+
* Fallback chain, each rung reached only when the one above it is unavailable:
|
|
18
|
+
* 1. `crypto.randomUUID()` — cryptographically random, RFC 4122 UUID. The normal path.
|
|
19
|
+
* 2. `crypto.getRandomValues()` — still cryptographically random; hand-assembled into a UUIDv4
|
|
20
|
+
* (the version/variant bits are set per RFC 4122, since this rung hands back raw bytes, not a
|
|
21
|
+
* formatted UUID).
|
|
22
|
+
* 3. No usable `crypto` at all (missing entirely, or missing both methods above — an old WebView,
|
|
23
|
+
* an SSR shim). NOT cryptographically random and NOT collision-resistant in general. This rung
|
|
24
|
+
* exists solely so `create()` never throws on such a host. It is acceptable ONLY because this
|
|
25
|
+
* id is minted a handful of times per client session and is not the idempotency key any write
|
|
26
|
+
* here depends on — `ThreadsStoreAdapter.appendMessage`'s idempotency key is the MESSAGE id,
|
|
27
|
+
* not this thread id, so a collision on this rung costs a locally merged/duplicated thread, not
|
|
28
|
+
* silent data loss on the append path. Do not reuse this rung for anything with a higher
|
|
29
|
+
* collision cost than that.
|
|
30
|
+
*/
|
|
31
|
+
export function mintThreadId(): string {
|
|
32
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
33
|
+
return crypto.randomUUID()
|
|
34
|
+
}
|
|
35
|
+
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
|
36
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
|
37
|
+
const hex = Array.from({ length: 16 }, (_, i) => {
|
|
38
|
+
const raw = bytes.at(i) ?? 0
|
|
39
|
+
// Version 4 (bits 12-15 of time_hi_and_version) and variant 10 (bits 6-7 of
|
|
40
|
+
// clock_seq_hi_and_reserved) — the only structure a UUIDv4 promises over raw random bytes.
|
|
41
|
+
const byte = i === 6 ? (raw & 0x0f) | 0x40 : i === 8 ? (raw & 0x3f) | 0x80 : raw
|
|
42
|
+
return byte.toString(16).padStart(2, '0')
|
|
43
|
+
}).join('')
|
|
44
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
45
|
+
}
|
|
46
|
+
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
47
|
+
}
|
|
48
|
+
|
|
1
49
|
/**
|
|
2
50
|
* withPartIds — stamps a stable id onto every draft part arriving without one.
|
|
3
51
|
*
|
package/src/agent/index.ts
CHANGED
|
@@ -113,3 +113,11 @@ export type { AgentThread, ThreadStatus, ThreadsStore, ThreadsStoreOptions } fro
|
|
|
113
113
|
// ── AgentOutcome + heuristicOutcome ────────────────────────────────────────────
|
|
114
114
|
export { heuristicOutcome } from './outcome'
|
|
115
115
|
export type { AgentOutcome, OutcomeResolver } from './outcome'
|
|
116
|
+
|
|
117
|
+
// ── createAdapterThreadsStore + threadsStoreAdapterContract ──────────────────
|
|
118
|
+
export { createAdapterThreadsStore, threadsStoreAdapterContract } from './adapter'
|
|
119
|
+
export type {
|
|
120
|
+
AdapterThreadsStoreOptions,
|
|
121
|
+
ThreadsStoreAdapter,
|
|
122
|
+
ThreadsStoreAdapterContractCase,
|
|
123
|
+
} from './adapter'
|
package/src/agent/merge.test.ts
CHANGED
|
@@ -57,6 +57,93 @@ describe('mergePart', () => {
|
|
|
57
57
|
expect(replayed).toEqual(firstPassResult.map((p) => ({ ...p, offset: 0 })))
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
+
// ── out-of-range offsets ───────────────────────────────────────────────────
|
|
61
|
+
// spliceText supports exactly two shapes (append at the end, in-range replace). Everything else
|
|
62
|
+
// is CLAMPED into [0, existing.text.length] with a dev warning — never thrown on, because a
|
|
63
|
+
// throw on the render path turns a transient wire anomaly into a permanently dead transcript.
|
|
64
|
+
|
|
65
|
+
test('an offset past the end of the existing text clamps to the tail (appends, leaves no hole)', () => {
|
|
66
|
+
const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hi' }]
|
|
67
|
+
const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: 10 })
|
|
68
|
+
expect(merged?.text).toBe('Hi!')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('a negative offset clamps to 0 rather than slicing from the end', () => {
|
|
72
|
+
const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hello world' }]
|
|
73
|
+
const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: 'Hey', offset: -3 })
|
|
74
|
+
expect(merged?.text).toBe('Heylo world')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('an out-of-order (already-covered) offset replaces in place — it never re-appends', () => {
|
|
78
|
+
// 'Hello world' is fully accumulated; a straggler delta for offset 6 arrives after the tail
|
|
79
|
+
// has moved past it. In-range, so it is honoured as a replace, not clamped.
|
|
80
|
+
const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hello world' }]
|
|
81
|
+
const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: 'WORLD', offset: 6 })
|
|
82
|
+
expect(merged?.text).toBe('Hello WORLD')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('a non-finite offset clamps to the tail rather than blanking the part', () => {
|
|
86
|
+
const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hi' }]
|
|
87
|
+
const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: Number.NaN })
|
|
88
|
+
expect(merged?.text).toBe('Hi!')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('a FIRST insertion with a nonzero offset clamps and warns like any other out-of-range one', () => {
|
|
92
|
+
// A first insertion splices against an EMPTY existing text, so the offset is out of range
|
|
93
|
+
// [0, 0]: mergePart never invents the 2 missing characters, it stores what arrived — but the
|
|
94
|
+
// anomaly is announced, exactly as it is for an existing part. One contract, one code path.
|
|
95
|
+
const warnings: string[] = []
|
|
96
|
+
const original = console.warn
|
|
97
|
+
console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
|
|
98
|
+
let first: TextPart[]
|
|
99
|
+
try {
|
|
100
|
+
first = mergePart<TextPart>([], { id: 'p1', type: 'text', text: 'llo', offset: 2 })
|
|
101
|
+
} finally {
|
|
102
|
+
console.warn = original
|
|
103
|
+
}
|
|
104
|
+
expect(warnings).toHaveLength(1)
|
|
105
|
+
expect(warnings[0]).toContain('out of range [0, 0]')
|
|
106
|
+
expect(first).toEqual([{ id: 'p1', type: 'text', text: 'llo', offset: 2 }])
|
|
107
|
+
|
|
108
|
+
// The offset rides along inert; the next delta for this id splices relative to the STORED text.
|
|
109
|
+
const [merged] = mergePart(first, { id: 'p1', type: 'text', text: ' there' })
|
|
110
|
+
expect(merged?.text).toBe('llo there')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('a FIRST insertion with an in-range (0 or undefined) offset stays silent and stores the text as-is', () => {
|
|
114
|
+
const warnings: string[] = []
|
|
115
|
+
const original = console.warn
|
|
116
|
+
console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
|
|
117
|
+
try {
|
|
118
|
+
expect(mergePart<TextPart>([], { id: 'p1', type: 'text', text: 'Hello', offset: 0 })).toEqual(
|
|
119
|
+
[{ id: 'p1', type: 'text', text: 'Hello', offset: 0 }],
|
|
120
|
+
)
|
|
121
|
+
expect(mergePart<TextPart>([], { id: 'p2', type: 'text', text: 'Hello' })).toEqual([
|
|
122
|
+
{ id: 'p2', type: 'text', text: 'Hello' },
|
|
123
|
+
])
|
|
124
|
+
} finally {
|
|
125
|
+
console.warn = original
|
|
126
|
+
}
|
|
127
|
+
expect(warnings).toHaveLength(0)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('warns in dev on an out-of-range offset, and stays silent on an in-range one', () => {
|
|
131
|
+
const warnings: string[] = []
|
|
132
|
+
const original = console.warn
|
|
133
|
+
console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
|
|
134
|
+
try {
|
|
135
|
+
const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hi' }]
|
|
136
|
+
mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: 1 })
|
|
137
|
+
expect(warnings).toHaveLength(0)
|
|
138
|
+
|
|
139
|
+
mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: 10 })
|
|
140
|
+
expect(warnings).toHaveLength(1)
|
|
141
|
+
expect(warnings[0]).toContain('out of range')
|
|
142
|
+
} finally {
|
|
143
|
+
console.warn = original
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
60
147
|
test('non-text/reasoning variants replace wholesale rather than merging fields', () => {
|
|
61
148
|
const parts: ToolCallPart[] = [
|
|
62
149
|
{
|
package/src/agent/merge.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* Appending a part whose `id` is already present in `parts` REWRITES that entry in place rather
|
|
5
5
|
* than pushing a duplicate — ordering stays stable (a rewritten part keeps its original position).
|
|
6
6
|
* `text`/`reasoning` splice the incoming delta into the existing text at `next.offset` (an
|
|
7
|
-
* undefined offset means "append at the tail"
|
|
8
|
-
*
|
|
7
|
+
* undefined offset means "append at the tail", and an out-of-range one is clamped rather than
|
|
8
|
+
* honoured — see `spliceText` for the exact supported shapes); every other variant — including any
|
|
9
|
+
* `ForeignPart`, which is never text-like — replaces wholesale. A FIRST insertion of a text-like
|
|
10
|
+
* part goes through the same splice against an empty text, so an offset that claims a prefix this
|
|
11
|
+
* accumulator never saw is clamped and warned about there too — never honoured, never padded.
|
|
9
12
|
*
|
|
10
13
|
* The invariant this holds, and the reason `offset` exists at all: replaying a whole run from
|
|
11
14
|
* character 0 rebuilds the identical parts array it produced the first time — it cannot double
|
|
@@ -41,26 +44,70 @@ function isTextLikeType(type: string): type is TextLike['type'] {
|
|
|
41
44
|
return type === 'text' || type === 'reasoning'
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Splices `next`'s text into `existingText` at `next.offset`. Exactly two shapes are supported,
|
|
49
|
+
* and they are the only two any basalt transport emits (`diffPart` always sets
|
|
50
|
+
* `offset = prevText.length`; `diffChunkStream` restarts its accumulator per call):
|
|
51
|
+
*
|
|
52
|
+
* 1. **append at the end** — `offset` undefined, or equal to `existingText.length`;
|
|
53
|
+
* 2. **in-range replace** — `0 <= offset <= existingText.length`, overwriting the span the
|
|
54
|
+
* incoming text covers (this is what makes an offset-0 full replay converge instead of double).
|
|
55
|
+
*
|
|
56
|
+
* An offset outside that range — negative, past the current length, or non-finite — is **clamped**
|
|
57
|
+
* into `[0, existingText.length]`, not honoured and not thrown on. A throw here would turn a
|
|
58
|
+
* transient wire anomaly into a permanently dead transcript; this is stream-reduction code on the
|
|
59
|
+
* render path. Each clamp is announced in dev.
|
|
60
|
+
*
|
|
61
|
+
* A FIRST insertion (no entry with this id yet) runs through here too, against an empty
|
|
62
|
+
* `existingText`: the stored text is the incoming text either way, but a nonzero offset — the wire
|
|
63
|
+
* claiming text this accumulator never saw — is clamped to 0 and announced like any other
|
|
64
|
+
* out-of-range value rather than passing silently. `mergePart` never invents the missing prefix.
|
|
65
|
+
*
|
|
66
|
+
* Note what is NOT special-cased: an out-of-order delta whose offset is still in range is an
|
|
67
|
+
* ordinary in-range replace, so it overwrites whatever already occupies that span. `mergePart`
|
|
68
|
+
* has no way to tell a stale delta from a deliberate rewrite, and inventing one would break the
|
|
69
|
+
* offset-0 replay convergence this whole mechanism exists for.
|
|
70
|
+
*/
|
|
71
|
+
function spliceText(existingText: string, next: TextLike): string {
|
|
72
|
+
const offset = clampOffset(next.offset ?? existingText.length, existingText.length)
|
|
73
|
+
return existingText.slice(0, offset) + next.text + existingText.slice(offset + next.text.length)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Clamps a wire offset into `[0, length]`, warning in dev when the raw value was out of range. */
|
|
77
|
+
function clampOffset(offset: number, length: number): number {
|
|
78
|
+
// A non-finite offset would make every slice() return '' and blank the part outright — it is
|
|
79
|
+
// out of range like any other, and appending is the safe reading of "position unknown".
|
|
80
|
+
const clamped = Number.isFinite(offset) ? Math.min(Math.max(offset, 0), length) : length
|
|
81
|
+
if (clamped !== offset && process.env['NODE_ENV'] !== 'production') {
|
|
82
|
+
console.warn(
|
|
83
|
+
`[basalt] mergePart: text offset ${offset} out of range [0, ${length}] — clamped to ${clamped}`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
return clamped
|
|
48
87
|
}
|
|
49
88
|
|
|
50
89
|
export function mergePart<TPart extends PartLike>(parts: readonly TPart[], next: TPart): TPart[] {
|
|
51
90
|
const index = parts.findIndex((part) => part.id === next.id)
|
|
52
|
-
|
|
91
|
+
const existing = index === -1 ? undefined : (parts[index] as TPart)
|
|
53
92
|
|
|
54
|
-
|
|
55
|
-
|
|
93
|
+
// A first insertion of a text-like part is spliced too — against an empty existing text — so the
|
|
94
|
+
// offset contract is one path with one set of rules: same clamp, same dev warning, and the same
|
|
95
|
+
// stored result (`'' + next.text`) an unconditional append would have produced.
|
|
96
|
+
const spliceable =
|
|
97
|
+
isTextLikeType(next.type) && (existing === undefined || existing.type === next.type)
|
|
56
98
|
|
|
57
99
|
const merged: TPart = spliceable
|
|
58
100
|
? ({
|
|
59
101
|
...next,
|
|
60
|
-
text: spliceText(
|
|
102
|
+
text: spliceText(
|
|
103
|
+
existing === undefined ? '' : (existing as unknown as TextLike).text,
|
|
104
|
+
next as unknown as TextLike,
|
|
105
|
+
),
|
|
61
106
|
} as TPart)
|
|
62
107
|
: next
|
|
63
108
|
|
|
109
|
+
if (existing === undefined) return [...parts, merged]
|
|
110
|
+
|
|
64
111
|
const out = parts.slice()
|
|
65
112
|
out[index] = merged
|
|
66
113
|
return out
|
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* `output` field, so the old `part.state === 'output-available'`-only body left a failed call
|
|
6
6
|
* rendering as name+input and nothing else — the failure was silently invisible. Same gap for
|
|
7
7
|
* `output-denied`, indistinguishable from a still-pending call.
|
|
8
|
+
*
|
|
9
|
+
* Plus the `settled` flag's forwarding contract — the field that lets a text renderer drop out of
|
|
10
|
+
* streaming mode once the message it belongs to has finished.
|
|
8
11
|
*/
|
|
9
12
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
13
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
11
14
|
import { PartList } from './part-list'
|
|
12
|
-
import type { ToolCallPart } from './parts'
|
|
15
|
+
import type { AgentPart, ToolCallPart } from './parts'
|
|
13
16
|
|
|
14
17
|
afterEach(cleanup)
|
|
15
18
|
|
|
@@ -80,3 +83,30 @@ describe('DefaultToolCall — terminal states stay visible', () => {
|
|
|
80
83
|
expect(screen.queryByText(/Denied/)).toBeNull()
|
|
81
84
|
})
|
|
82
85
|
})
|
|
86
|
+
|
|
87
|
+
describe('settled — forwarded to every renderer', () => {
|
|
88
|
+
const TEXT: AgentPart = { id: 't1', type: 'text', text: 'hello' }
|
|
89
|
+
|
|
90
|
+
function settledProbe() {
|
|
91
|
+
const seen: boolean[] = []
|
|
92
|
+
const components = {
|
|
93
|
+
text: ({ settled }: { settled: boolean }) => {
|
|
94
|
+
seen.push(settled)
|
|
95
|
+
return <div data-testid="probe">{String(settled)}</div>
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
return { seen, components }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test('defaults to true — a caller that knows nothing about a run renders a finished list', () => {
|
|
102
|
+
const { seen, components } = settledProbe()
|
|
103
|
+
render(<PartList parts={[TEXT]} components={components} />)
|
|
104
|
+
expect(seen).toEqual([true])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('an explicit settled={false} reaches the renderer', () => {
|
|
108
|
+
const { seen, components } = settledProbe()
|
|
109
|
+
render(<PartList parts={[TEXT]} components={components} settled={false} />)
|
|
110
|
+
expect(seen).toEqual([false])
|
|
111
|
+
})
|
|
112
|
+
})
|
package/src/agent/part-list.tsx
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
* <PartList
|
|
20
20
|
* parts={parts}
|
|
21
21
|
* components={{
|
|
22
|
-
* text: ({ part }) =>
|
|
22
|
+
* text: ({ part, settled }) => (
|
|
23
|
+
* <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown>
|
|
24
|
+
* ),
|
|
23
25
|
* }}
|
|
24
26
|
* />
|
|
25
27
|
*/
|
|
@@ -38,37 +40,48 @@ import type {
|
|
|
38
40
|
|
|
39
41
|
// ── Per-type renderer signatures ──────────────────────────────────────────────
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The per-renderer argument. `settled` is `false` only while the message these parts belong to is
|
|
45
|
+
* still streaming — it converges this map toward the `PartRenderContext` a foreign renderer
|
|
46
|
+
* already receives (`./foreign`), rather than adding a third shape. Additive and
|
|
47
|
+
* source-compatible: an existing `({ part }) => …` renderer simply ignores it.
|
|
48
|
+
*
|
|
49
|
+
* Settlement is MONOTONE — a message that has settled never un-settles.
|
|
50
|
+
*/
|
|
51
|
+
type PartRenderArgs<TPart> = {
|
|
52
|
+
part: TPart
|
|
43
53
|
index: number
|
|
44
|
-
|
|
54
|
+
settled: boolean
|
|
55
|
+
}
|
|
45
56
|
|
|
46
|
-
export type
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}) => JSX.Element | null
|
|
57
|
+
export type TextPartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
58
|
+
props: PartRenderArgs<Extract<TPart, { type: 'text' }>>,
|
|
59
|
+
) => JSX.Element | null
|
|
50
60
|
|
|
51
|
-
export type
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}) => JSX.Element | null
|
|
61
|
+
export type ReasoningPartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
62
|
+
props: PartRenderArgs<Extract<TPart, { type: 'reasoning' }>>,
|
|
63
|
+
) => JSX.Element | null
|
|
55
64
|
|
|
56
|
-
export type
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}) => JSX.Element | null
|
|
65
|
+
export type ToolCallPartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
66
|
+
props: PartRenderArgs<Extract<TPart, { type: 'tool' }>>,
|
|
67
|
+
) => JSX.Element | null
|
|
60
68
|
|
|
61
|
-
export type
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
export type SourcePartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
70
|
+
props: PartRenderArgs<Extract<TPart, { type: 'source' }>>,
|
|
71
|
+
) => JSX.Element | null
|
|
72
|
+
|
|
73
|
+
export type ErrorPartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
74
|
+
props: PartRenderArgs<Extract<TPart, { type: 'error' }>>,
|
|
75
|
+
) => JSX.Element | null
|
|
65
76
|
|
|
66
77
|
/**
|
|
67
78
|
* Partial map of per-type renderers. Any omitted key falls back to the headless default.
|
|
68
79
|
*
|
|
69
80
|
* @example
|
|
70
81
|
* const renderers: Partial<AgentPartRenderers> = {
|
|
71
|
-
* text: ({ part }) =>
|
|
82
|
+
* text: ({ part, settled }) => (
|
|
83
|
+
* <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown>
|
|
84
|
+
* ),
|
|
72
85
|
* }
|
|
73
86
|
*/
|
|
74
87
|
export type AgentPartRenderers<TPart extends AgentPart = AgentPart> = {
|
|
@@ -231,10 +244,17 @@ export type PartListProps<TPart extends AgentPart = AgentPart> = {
|
|
|
231
244
|
readonly parts: TPart[]
|
|
232
245
|
/**
|
|
233
246
|
* Override individual part renderers. Omitted keys fall back to the headless defaults.
|
|
234
|
-
* Pass `{ text: ({ part }) => <Markdown streaming density="chat">{part.text}</Markdown> }`
|
|
235
|
-
* enable rich markdown rendering.
|
|
247
|
+
* Pass `{ text: ({ part, settled }) => <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown> }`
|
|
248
|
+
* to enable rich markdown rendering.
|
|
236
249
|
*/
|
|
237
250
|
readonly components?: Partial<AgentPartRenderers<TPart>>
|
|
251
|
+
/**
|
|
252
|
+
* `false` while the message these parts belong to is still streaming — forwarded to every
|
|
253
|
+
* renderer so a text renderer can drop out of streaming mode (and a fenced mermaid/diagram can
|
|
254
|
+
* upgrade) once the turn finishes. Defaults to `true`: a caller that knows nothing about a run
|
|
255
|
+
* is rendering a finished list, and un-settled must be opted into by the thing that does know.
|
|
256
|
+
*/
|
|
257
|
+
readonly settled?: boolean
|
|
238
258
|
}
|
|
239
259
|
|
|
240
260
|
/**
|
|
@@ -251,11 +271,19 @@ export type PartListProps<TPart extends AgentPart = AgentPart> = {
|
|
|
251
271
|
* // With basalt's own Markdown for the text renderer:
|
|
252
272
|
* import { PartList } from 'basalt-ui/agent'
|
|
253
273
|
* import { Markdown } from 'basalt-ui/content'
|
|
254
|
-
* <PartList
|
|
274
|
+
* <PartList
|
|
275
|
+
* parts={parts}
|
|
276
|
+
* components={{
|
|
277
|
+
* text: ({ part, settled }) => (
|
|
278
|
+
* <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown>
|
|
279
|
+
* ),
|
|
280
|
+
* }}
|
|
281
|
+
* />
|
|
255
282
|
*/
|
|
256
283
|
export function PartList<TPart extends AgentPart = AgentPart>({
|
|
257
284
|
parts,
|
|
258
285
|
components,
|
|
286
|
+
settled = true,
|
|
259
287
|
}: PartListProps<TPart>): JSX.Element {
|
|
260
288
|
// Memoised to avoid rebuilding the renderer map on every streaming re-render (hot path).
|
|
261
289
|
// Cast required because DEFAULT_RENDERERS is typed for the base AgentPart, not the generic TPart.
|
|
@@ -277,7 +305,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
|
|
|
277
305
|
const Render = renderers.text
|
|
278
306
|
return (
|
|
279
307
|
<Fragment key={part.id}>
|
|
280
|
-
<Render
|
|
308
|
+
<Render
|
|
309
|
+
part={part as Extract<TPart, { type: 'text' }>}
|
|
310
|
+
index={index}
|
|
311
|
+
settled={settled}
|
|
312
|
+
/>
|
|
281
313
|
</Fragment>
|
|
282
314
|
)
|
|
283
315
|
}
|
|
@@ -285,7 +317,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
|
|
|
285
317
|
const Render = renderers.reasoning
|
|
286
318
|
return (
|
|
287
319
|
<Fragment key={part.id}>
|
|
288
|
-
<Render
|
|
320
|
+
<Render
|
|
321
|
+
part={part as Extract<TPart, { type: 'reasoning' }>}
|
|
322
|
+
index={index}
|
|
323
|
+
settled={settled}
|
|
324
|
+
/>
|
|
289
325
|
</Fragment>
|
|
290
326
|
)
|
|
291
327
|
}
|
|
@@ -293,7 +329,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
|
|
|
293
329
|
const Render = renderers.tool
|
|
294
330
|
return (
|
|
295
331
|
<Fragment key={part.id}>
|
|
296
|
-
<Render
|
|
332
|
+
<Render
|
|
333
|
+
part={part as Extract<TPart, { type: 'tool' }>}
|
|
334
|
+
index={index}
|
|
335
|
+
settled={settled}
|
|
336
|
+
/>
|
|
297
337
|
</Fragment>
|
|
298
338
|
)
|
|
299
339
|
}
|
|
@@ -301,7 +341,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
|
|
|
301
341
|
const Render = renderers.source
|
|
302
342
|
return (
|
|
303
343
|
<Fragment key={part.id}>
|
|
304
|
-
<Render
|
|
344
|
+
<Render
|
|
345
|
+
part={part as Extract<TPart, { type: 'source' }>}
|
|
346
|
+
index={index}
|
|
347
|
+
settled={settled}
|
|
348
|
+
/>
|
|
305
349
|
</Fragment>
|
|
306
350
|
)
|
|
307
351
|
}
|
|
@@ -309,7 +353,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
|
|
|
309
353
|
const Render = renderers.error
|
|
310
354
|
return (
|
|
311
355
|
<Fragment key={part.id}>
|
|
312
|
-
<Render
|
|
356
|
+
<Render
|
|
357
|
+
part={part as Extract<TPart, { type: 'error' }>}
|
|
358
|
+
index={index}
|
|
359
|
+
settled={settled}
|
|
360
|
+
/>
|
|
313
361
|
</Fragment>
|
|
314
362
|
)
|
|
315
363
|
}
|