basalt-ui 1.11.0 → 1.13.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 +32 -0
- package/README.md +63 -51
- package/agent/rules/basalt-agent.md +14 -0
- package/agent/rules/basalt-content.md +16 -3
- package/configs/oxlint-plugin.js +7 -3
- package/configs/oxlint-plugin.test.ts +34 -5
- package/configs/oxlint.json +4 -4
- package/dist/agent/adapter.d.ts +150 -0
- package/dist/agent/adapter.d.ts.map +1 -0
- package/dist/agent/adapter.js +482 -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.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +6 -2
- 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 +78 -0
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +30 -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 +18 -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.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +2 -2
- 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.d.ts +24 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- 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.js +55 -15
- 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 +5 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/message-affordances.d.ts +59 -0
- package/dist/agent-chat/message-affordances.d.ts.map +1 -0
- package/dist/agent-chat/message-affordances.js +7 -0
- package/dist/agent-chat/relative-time.d.ts +27 -0
- package/dist/agent-chat/relative-time.d.ts.map +1 -0
- package/dist/agent-chat/relative-time.js +27 -0
- package/dist/agent-chat/relative-time.test.d.ts +2 -0
- package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
- 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-feed-row.d.ts +65 -0
- package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed-row.js +156 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed.d.ts +34 -5
- package/dist/agent-chat/thread-feed.d.ts.map +1 -1
- package/dist/agent-chat/thread-feed.js +52 -19
- package/dist/agent-chat/thread-feed.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-message.d.ts +183 -2
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +329 -37
- 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-outcome-card.d.ts.map +1 -1
- package/dist/agent-chat/thread-outcome-card.js +1 -16
- package/dist/agent-chat/thread-workspace.d.ts +14 -2
- package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
- package/dist/agent-chat/thread-workspace.js +30 -4
- package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
- package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
- 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/agent-chat/virtualize.d.ts +123 -0
- package/dist/agent-chat/virtualize.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.js +9 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
- package/dist/agent-chat/virtualize.type-guard.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/data/index.d.ts +5 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/virtual-list.d.ts.map +1 -1
- package/dist/data/virtual.d.ts +2 -1
- package/dist/data/virtual.d.ts.map +1 -1
- 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 +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -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 +5 -5
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +14 -4
- 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 +9 -9
- package/package.json +7 -2
- package/src/agent/adapter.test.ts +1640 -0
- package/src/agent/adapter.ts +945 -0
- package/src/agent/ai-sdk-transport.test.ts +117 -26
- package/src/agent/ai-sdk-transport.ts +8 -2
- 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 +101 -3
- package/src/agent/id.ts +118 -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 +25 -1
- package/src/agent/use-agent-stream.test.tsx +31 -5
- package/src/agent/use-agent-stream.ts +6 -3
- 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 +356 -17
- package/src/agent/use-agent-thread-runs.ts +159 -23
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +93 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +13 -1
- package/src/agent-chat/message-affordances.ts +61 -0
- package/src/agent-chat/relative-time.test.ts +95 -0
- package/src/agent-chat/relative-time.ts +55 -0
- package/src/agent-chat/thread-detail-panel.tsx +3 -1
- package/src/agent-chat/thread-feed-row.test.tsx +510 -0
- package/src/agent-chat/thread-feed-row.tsx +274 -0
- package/src/agent-chat/thread-feed.test.tsx +241 -0
- package/src/agent-chat/thread-feed.tsx +119 -18
- package/src/agent-chat/thread-message.test.tsx +1078 -4
- package/src/agent-chat/thread-message.tsx +795 -42
- package/src/agent-chat/thread-outcome-card.tsx +1 -25
- package/src/agent-chat/thread-workspace.test.tsx +169 -0
- package/src/agent-chat/thread-workspace.tsx +70 -6
- package/src/agent-chat/virtualize.ts +132 -0
- package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
- 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/data/index.ts +5 -1
- package/src/data/virtual-list.tsx +2 -1
- package/src/data/virtual.ts +2 -1
- package/src/index.ts +18 -1
- package/src/surfaces.ts +13 -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 +21 -6
- package/src/theme/use-basalt-spacing.test.tsx +2 -1
package/src/agent/coalesce.ts
CHANGED
|
@@ -12,10 +12,26 @@
|
|
|
12
12
|
* wire, the SDK re-supplies `input` by reading it back off the stored invocation, and a merge that
|
|
13
13
|
* replaces wholesale rather than accumulating would lose it outright.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* A settled tool part can arrive without a `toolCallId` at all — `approval-responded` plus the
|
|
16
|
+
* three terminal states all carry it as optional (see `MaybeIdentified` in `parts.ts`).
|
|
17
|
+
* `approval-responded` is where the WIRE itself omits it: the SDK's `tool-approval-response` chunk
|
|
18
|
+
* carries no `toolCallId`, only `approval.id`, so it is resolved by reverse lookup on `approval.id`
|
|
19
|
+
* instead — this maintains an `approvalId -> toolCallId` side index while folding and routes such a
|
|
20
|
+
* part through it. A part resolvable by neither key is kept as-is rather than dropped; never
|
|
21
|
+
* silently discard a part.
|
|
22
|
+
*
|
|
23
|
+
* The fold is strictly LEFT-TO-RIGHT with no backtracking: the side index only holds ids seen so
|
|
24
|
+
* far, so an unidentified `approval-responded` arriving BEFORE its `approval-requested` stays its
|
|
25
|
+
* own block and the later request opens a second one. That is deliberate, not an oversight —
|
|
26
|
+
* reconciling backwards would let the request (the later arrival) win under the "later state wins"
|
|
27
|
+
* rule and render an already-answered call as still pending, which is worse than two blocks in an
|
|
28
|
+
* ordering the wire never produces.
|
|
29
|
+
*
|
|
30
|
+
* **This function never throws.** It is public, it runs on the render path, and it folds data that
|
|
31
|
+
* came off a wire — so every anomaly (an unrecognized tool state, an unresolvable id) degrades to
|
|
32
|
+
* "keep the part as it arrived", announced in dev, rather than aborting the render. A throw here
|
|
33
|
+
* would turn one malformed chunk into a permanently dead transcript. Exhaustiveness over the tool
|
|
34
|
+
* states is still enforced, but at COMPILE time only (see `mergeToolPart`'s default branch).
|
|
19
35
|
*
|
|
20
36
|
* @example
|
|
21
37
|
* import { coalesceParts } from 'basalt-ui/agent'
|
|
@@ -35,7 +51,7 @@ function resolveToolCallId(
|
|
|
35
51
|
part: ToolCallPart,
|
|
36
52
|
toolCallIdByApprovalId: Map<string, string>,
|
|
37
53
|
): string | undefined {
|
|
38
|
-
if (
|
|
54
|
+
if (part.toolCallId !== undefined) return part.toolCallId
|
|
39
55
|
const approval = 'approval' in part ? part.approval : undefined
|
|
40
56
|
return approval !== undefined ? toolCallIdByApprovalId.get(approval.id) : undefined
|
|
41
57
|
}
|
|
@@ -48,24 +64,119 @@ function registerApprovalIndex(
|
|
|
48
64
|
): void {
|
|
49
65
|
const approval = 'approval' in part ? part.approval : undefined
|
|
50
66
|
if (approval === undefined) return
|
|
51
|
-
if (
|
|
67
|
+
if (part.toolCallId === undefined) return
|
|
52
68
|
toolCallIdByApprovalId.set(approval.id, part.toolCallId)
|
|
53
69
|
}
|
|
54
70
|
|
|
55
|
-
/**
|
|
56
|
-
*
|
|
71
|
+
/**
|
|
72
|
+
* The later state always wins; `toolCallId`/`toolName`/`durationMs`/`providerExecuted` and `input`
|
|
73
|
+
* from the earlier state survive a later state that omits them.
|
|
74
|
+
*
|
|
75
|
+
* The result is RECONSTRUCTED per `next.state`, building only the fields that state legally has —
|
|
76
|
+
* never a blind `{ ...existing, ...next }` spread. A spread lets any field the next state does not
|
|
77
|
+
* redeclare survive onto it: an `output-available` (with `output`, possibly `preliminary`) that
|
|
78
|
+
* fails to `output-error` (which has no `output` key) would keep a stale `output` alongside
|
|
79
|
+
* `errorText`, and a consumer branching on `output` before `errorText` renders a failed call as
|
|
80
|
+
* successful. Only the fields listed above are carried; `output`, `errorText`, `rawInput`,
|
|
81
|
+
* `preliminary` and `approval` come from `next` alone, so nothing from a prior state can leak into
|
|
82
|
+
* a member that has no room for it. Reconstruction is exact enough that tsc proves each returned
|
|
83
|
+
* member valid — there is deliberately no cast here.
|
|
84
|
+
*/
|
|
57
85
|
function mergeToolPart(existing: ToolCallPart, next: ToolCallPart): ToolCallPart {
|
|
58
86
|
const existingInput = 'input' in existing ? existing.input : undefined
|
|
59
87
|
const nextInput = 'input' in next ? next.input : undefined
|
|
60
88
|
const input = nextInput !== undefined ? nextInput : existingInput
|
|
61
|
-
const
|
|
89
|
+
const durationMs = next.durationMs ?? existing.durationMs
|
|
90
|
+
const providerExecuted = next.providerExecuted ?? existing.providerExecuted
|
|
91
|
+
// `toolCallId` is optional on the four settled states (see `MaybeIdentified` in parts.ts), so a
|
|
92
|
+
// later part that arrived unidentified — resolved here through the approvalId side index — keeps
|
|
93
|
+
// the identity an earlier state supplied instead of losing it on the way through the merge.
|
|
94
|
+
const settledToolCallId = next.toolCallId ?? existing.toolCallId
|
|
95
|
+
|
|
96
|
+
const common = {
|
|
97
|
+
id: next.id,
|
|
98
|
+
type: 'tool' as const,
|
|
99
|
+
toolName: next.toolName.length > 0 ? next.toolName : existing.toolName,
|
|
100
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
101
|
+
...(providerExecuted !== undefined ? { providerExecuted } : {}),
|
|
102
|
+
}
|
|
62
103
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
104
|
+
switch (next.state) {
|
|
105
|
+
case 'input-streaming':
|
|
106
|
+
return {
|
|
107
|
+
...common,
|
|
108
|
+
toolCallId: next.toolCallId,
|
|
109
|
+
state: 'input-streaming',
|
|
110
|
+
...(input !== undefined ? { input } : {}),
|
|
111
|
+
}
|
|
112
|
+
case 'input-available':
|
|
113
|
+
return { ...common, toolCallId: next.toolCallId, state: 'input-available', input }
|
|
114
|
+
case 'approval-requested':
|
|
115
|
+
return {
|
|
116
|
+
...common,
|
|
117
|
+
toolCallId: next.toolCallId,
|
|
118
|
+
state: 'approval-requested',
|
|
119
|
+
input,
|
|
120
|
+
approval: next.approval,
|
|
121
|
+
}
|
|
122
|
+
case 'approval-responded':
|
|
123
|
+
return {
|
|
124
|
+
...common,
|
|
125
|
+
...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
|
|
126
|
+
state: 'approval-responded',
|
|
127
|
+
input,
|
|
128
|
+
approval: next.approval,
|
|
129
|
+
}
|
|
130
|
+
case 'output-available':
|
|
131
|
+
return {
|
|
132
|
+
...common,
|
|
133
|
+
...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
|
|
134
|
+
state: 'output-available',
|
|
135
|
+
input,
|
|
136
|
+
output: next.output,
|
|
137
|
+
...(next.preliminary !== undefined ? { preliminary: next.preliminary } : {}),
|
|
138
|
+
...(next.approval !== undefined ? { approval: next.approval } : {}),
|
|
139
|
+
}
|
|
140
|
+
case 'output-error':
|
|
141
|
+
return {
|
|
142
|
+
...common,
|
|
143
|
+
...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
|
|
144
|
+
state: 'output-error',
|
|
145
|
+
errorText: next.errorText,
|
|
146
|
+
...(input !== undefined ? { input } : {}),
|
|
147
|
+
...(next.rawInput !== undefined ? { rawInput: next.rawInput } : {}),
|
|
148
|
+
...(next.approval !== undefined ? { approval: next.approval } : {}),
|
|
149
|
+
}
|
|
150
|
+
case 'output-denied':
|
|
151
|
+
return {
|
|
152
|
+
...common,
|
|
153
|
+
...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
|
|
154
|
+
state: 'output-denied',
|
|
155
|
+
input,
|
|
156
|
+
approval: next.approval,
|
|
157
|
+
}
|
|
158
|
+
default: {
|
|
159
|
+
// Exhaustive at COMPILE time, defensive at RUNTIME. Binding `next` at `never` keeps tsc's
|
|
160
|
+
// guarantee — add a state to `ToolCallPart` without a case above and this line stops
|
|
161
|
+
// compiling — but this branch must NOT throw. `coalesceParts` is public, runs on the render
|
|
162
|
+
// path, and folds wire data: a throw would turn one transient wire anomaly into a
|
|
163
|
+
// permanently dead transcript (the same trade `spliceText` makes in merge.ts, where an
|
|
164
|
+
// out-of-range offset is clamped rather than thrown on). An unrecognized state therefore
|
|
165
|
+
// falls back to the incoming part with only the whitelisted carry-over fields folded in —
|
|
166
|
+
// nothing state-specific from `existing` can leak, because `common` is all that is merged.
|
|
167
|
+
const unhandled: never = next
|
|
168
|
+
// Widened back deliberately: control-flow analysis would keep an annotated const at `never`
|
|
169
|
+
// and make every field read below a compile error, so the assertion is what makes the
|
|
170
|
+
// runtime value usable. It asserts nothing tsc has not already proved on the line above.
|
|
171
|
+
const fallback = unhandled as ToolCallPart
|
|
172
|
+
if (process.env['NODE_ENV'] !== 'production') {
|
|
173
|
+
console.warn(
|
|
174
|
+
`[basalt] coalesceParts: unrecognized tool state ${JSON.stringify(fallback.state)} — kept, merged best-effort`,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
return { ...fallback, ...common }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
69
180
|
}
|
|
70
181
|
|
|
71
182
|
export function coalesceParts<TPart extends AgentPart>(parts: readonly TPart[]): TPart[] {
|
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
import { describe, expect, test } from 'bun:test'
|
|
9
9
|
import { definePartRenderers, narrowAgentPart } from './foreign'
|
|
10
10
|
import type { TranscriptPart } from './foreign'
|
|
11
|
+
import type { AgentPart } from './parts'
|
|
11
12
|
|
|
12
13
|
describe('narrowAgentPart', () => {
|
|
13
|
-
|
|
14
|
+
// Typed AgentPart[], not TranscriptPart[]: every literal here IS a built-in variant (the whole
|
|
15
|
+
// point of this fixture), and narrowAgentPart's return type is AgentPart | null — comparing it
|
|
16
|
+
// against a TranscriptPart-typed element (which also admits ForeignPart) doesn't type-check.
|
|
17
|
+
const BUILT_IN_SAMPLES: AgentPart[] = [
|
|
14
18
|
{ id: 'p1', type: 'start', runId: 'r1' },
|
|
15
19
|
{ id: 'p2', type: 'text', text: 'hi' },
|
|
16
20
|
{ id: 'p3', type: 'reasoning', text: 'thinking' },
|
package/src/agent/id.test.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* withPartIds — stamps `${runId}#${n}` onto any draft part arriving without an id; idempotent for
|
|
3
3
|
* drafts that already have one (untouched, and doesn't advance the counter).
|
|
4
|
+
*
|
|
5
|
+
* mintThreadId/mintMessageId — the two id-minting helpers behind every unguarded
|
|
6
|
+
* `crypto.randomUUID()` call site this file's siblings used to have. Both share rungs 1
|
|
7
|
+
* (`crypto.randomUUID`) and 2 (`crypto.getRandomValues`, hand-assembled into a UUIDv4); they
|
|
8
|
+
* deliberately diverge on rung 3 (no crypto at all) — `mintThreadId` degrades, `mintMessageId`
|
|
9
|
+
* throws. See each function's own doc for why.
|
|
4
10
|
*/
|
|
5
11
|
import { describe, expect, test } from 'bun:test'
|
|
6
|
-
import { withPartIds } from './id'
|
|
12
|
+
import { mintMessageId, mintThreadId, withPartIds } from './id'
|
|
13
|
+
import type { AgentPartDraft } from './parts'
|
|
7
14
|
|
|
8
15
|
async function* gen<T>(values: T[]): AsyncGenerator<T> {
|
|
9
16
|
for (const value of values) yield value
|
|
@@ -15,9 +22,39 @@ async function collect<T>(source: AsyncGenerator<T>): Promise<T[]> {
|
|
|
15
22
|
return out
|
|
16
23
|
}
|
|
17
24
|
|
|
25
|
+
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
26
|
+
|
|
27
|
+
/** Rung 2: randomUUID absent, getRandomValues present (a real, non-secure-context host). */
|
|
28
|
+
function withRung2Crypto<T>(run: () => T): T {
|
|
29
|
+
const originalCrypto = globalThis.crypto
|
|
30
|
+
Object.defineProperty(globalThis, 'crypto', {
|
|
31
|
+
value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
|
|
32
|
+
configurable: true,
|
|
33
|
+
})
|
|
34
|
+
try {
|
|
35
|
+
return run()
|
|
36
|
+
} finally {
|
|
37
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Rung 3: no usable crypto at all. */
|
|
42
|
+
function withRung3Crypto<T>(run: () => T): T {
|
|
43
|
+
const originalCrypto = globalThis.crypto
|
|
44
|
+
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
|
|
45
|
+
try {
|
|
46
|
+
return run()
|
|
47
|
+
} finally {
|
|
48
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
18
52
|
describe('withPartIds', () => {
|
|
19
53
|
test('stamps a sequential id on every draft missing one', async () => {
|
|
20
|
-
|
|
54
|
+
// Annotated: all three literals uniformly omit `id`, so without this TS infers an `id`-less
|
|
55
|
+
// element type that has no properties in common with withPartIds' `{ id?: string }`
|
|
56
|
+
// constraint — a weak-type check failure, not a real shape mismatch.
|
|
57
|
+
const drafts: AgentPartDraft[] = [
|
|
21
58
|
{ type: 'text', text: 'a' },
|
|
22
59
|
{ type: 'text', text: 'b' },
|
|
23
60
|
{ type: 'text', text: 'c' },
|
|
@@ -37,7 +74,7 @@ describe('withPartIds', () => {
|
|
|
37
74
|
})
|
|
38
75
|
|
|
39
76
|
test('is idempotent: re-running withPartIds over already-identified parts changes nothing', async () => {
|
|
40
|
-
const drafts = [{ type: 'text', text: 'a' }]
|
|
77
|
+
const drafts: AgentPartDraft[] = [{ type: 'text', text: 'a' }]
|
|
41
78
|
const once = await collect(withPartIds('run-1', gen(drafts)))
|
|
42
79
|
const twice = await collect(withPartIds('run-1', gen(once)))
|
|
43
80
|
expect(twice).toEqual(once)
|
|
@@ -69,3 +106,64 @@ describe('withPartIds', () => {
|
|
|
69
106
|
])
|
|
70
107
|
})
|
|
71
108
|
})
|
|
109
|
+
|
|
110
|
+
describe('mintThreadId', () => {
|
|
111
|
+
test('rung 1 (crypto.randomUUID): returns a valid UUIDv4 string', () => {
|
|
112
|
+
const id = mintThreadId()
|
|
113
|
+
expect(id).toMatch(UUID_V4_RE)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('rung 1: many mints are distinct', () => {
|
|
117
|
+
const ids = Array.from({ length: 1000 }, () => mintThreadId())
|
|
118
|
+
expect(new Set(ids).size).toBe(1000)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('rung 2 (getRandomValues only): assembles a valid UUIDv4 — version/variant bits set', () => {
|
|
122
|
+
const id = withRung2Crypto(() => mintThreadId())
|
|
123
|
+
expect(id).toMatch(UUID_V4_RE)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('rung 2: many mints are distinct', () => {
|
|
127
|
+
const ids = withRung2Crypto(() => Array.from({ length: 1000 }, () => mintThreadId()))
|
|
128
|
+
expect(new Set(ids).size).toBe(1000)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('rung 3 (no usable crypto at all): does NOT throw, still returns distinct non-empty strings', () => {
|
|
132
|
+
const ids = withRung3Crypto(() => Array.from({ length: 50 }, () => mintThreadId()))
|
|
133
|
+
for (const id of ids) {
|
|
134
|
+
expect(typeof id).toBe('string')
|
|
135
|
+
expect(id.length).toBeGreaterThan(0)
|
|
136
|
+
}
|
|
137
|
+
// Weak (Date.now() + Math.random()), not cryptographically random — but still distinct across
|
|
138
|
+
// this many mints in one tick, since Math.random() varies even when Date.now() doesn't.
|
|
139
|
+
expect(new Set(ids).size).toBe(50)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('mintMessageId', () => {
|
|
144
|
+
test('rung 1 (crypto.randomUUID): returns a valid UUIDv4 string', () => {
|
|
145
|
+
const id = mintMessageId()
|
|
146
|
+
expect(id).toMatch(UUID_V4_RE)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('rung 1: many mints are distinct', () => {
|
|
150
|
+
const ids = Array.from({ length: 1000 }, () => mintMessageId())
|
|
151
|
+
expect(new Set(ids).size).toBe(1000)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('rung 2 (getRandomValues only): assembles a valid UUIDv4 — version/variant bits set', () => {
|
|
155
|
+
const id = withRung2Crypto(() => mintMessageId())
|
|
156
|
+
expect(id).toMatch(UUID_V4_RE)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('rung 2: many mints are distinct', () => {
|
|
160
|
+
const ids = withRung2Crypto(() => Array.from({ length: 1000 }, () => mintMessageId()))
|
|
161
|
+
expect(new Set(ids).size).toBe(1000)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('rung 3 (no usable crypto at all): THROWS rather than minting a non-random id', () => {
|
|
165
|
+
// Diverges from mintThreadId's rung 3 on purpose — appendMessage's idempotency key must never
|
|
166
|
+
// silently degrade to a non-collision-resistant id. See mintMessageId's own doc.
|
|
167
|
+
expect(() => withRung3Crypto(() => mintMessageId())).toThrow(/idempotency key/)
|
|
168
|
+
})
|
|
169
|
+
})
|
package/src/agent/id.ts
CHANGED
|
@@ -1,3 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Id-minting helpers shared across the agent layer: `mintThreadId` (a low-stakes client-side id —
|
|
3
|
+
* threads, run/chat namespaces — used by `./thread`'s `createThreadsStore`, `./adapter`'s
|
|
4
|
+
* `createAdapterThreadsStore`/`threadsStoreAdapterContract`, `./use-agent-stream`'s per-send run
|
|
5
|
+
* id, and `aiSdkTransport`'s fixed chat id), `mintMessageId` (the higher-stakes id `appendMessage`
|
|
6
|
+
* treats as its only idempotency key), and `withPartIds` (stamps sequence ids onto streamed
|
|
7
|
+
* parts). Grouped here because they're the id-minting leaves every store/hook/transport in this
|
|
8
|
+
* layer sits above — not because they share a mechanism (`mintMessageId` deliberately diverges
|
|
9
|
+
* from `mintThreadId` on rung 3; see its own doc).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hand-assembles a UUIDv4 string from 16 random bytes — the shared rung-2 mechanism both
|
|
14
|
+
* `mintThreadId` and `mintMessageId` use once `crypto.randomUUID` is unavailable but
|
|
15
|
+
* `crypto.getRandomValues` still is. The version/variant bits are set per RFC 4122, since this
|
|
16
|
+
* rung hands back raw bytes, not a formatted UUID.
|
|
17
|
+
*/
|
|
18
|
+
function assembleUuidV4(bytes: Uint8Array): string {
|
|
19
|
+
const hex = Array.from({ length: 16 }, (_, i) => {
|
|
20
|
+
const raw = bytes.at(i) ?? 0
|
|
21
|
+
// Version 4 (bits 12-15 of time_hi_and_version) and variant 10 (bits 6-7 of
|
|
22
|
+
// clock_seq_hi_and_reserved) — the only structure a UUIDv4 promises over raw random bytes.
|
|
23
|
+
const byte = i === 6 ? (raw & 0x0f) | 0x40 : i === 8 ? (raw & 0x3f) | 0x80 : raw
|
|
24
|
+
return byte.toString(16).padStart(2, '0')
|
|
25
|
+
}).join('')
|
|
26
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Mints a client-side id for a LOW-COLLISION-COST use: a thread id, a per-send run-id namespace, a
|
|
31
|
+
* fixed chat id — anything minted client-side that is NOT the idempotency key some write depends
|
|
32
|
+
* on. `crypto.randomUUID()` exists only in SECURE CONTEXTS (HTTPS or localhost) — a consumer served
|
|
33
|
+
* over plain HTTP on a LAN hostname (a homelab dashboard on a bare IP, a staging box) would
|
|
34
|
+
* otherwise have `create()`, about the most basic user action a thread store has, throw a plain
|
|
35
|
+
* TypeError. A store used from a browser must not throw on a user gesture — the same principle
|
|
36
|
+
* already applied twice this release (`spliceText` clamps rather than throws, `coalesceParts`
|
|
37
|
+
* degrades rather than throws).
|
|
38
|
+
*
|
|
39
|
+
* Fallback chain, each rung reached only when the one above it is unavailable:
|
|
40
|
+
* 1. `crypto.randomUUID()` — cryptographically random, RFC 4122 UUID. The normal path.
|
|
41
|
+
* 2. `crypto.getRandomValues()` — still cryptographically random; hand-assembled into a UUIDv4
|
|
42
|
+
* via `assembleUuidV4`.
|
|
43
|
+
* 3. No usable `crypto` at all (missing entirely, or missing both methods above — an old WebView,
|
|
44
|
+
* an SSR shim). NOT cryptographically random and NOT collision-resistant in general. This rung
|
|
45
|
+
* exists solely so a caller never throws on such a host. It is acceptable ONLY because an id
|
|
46
|
+
* minted here is minted a handful of times per client session and is not the idempotency key
|
|
47
|
+
* any write depends on — `ThreadsStoreAdapter.appendMessage`'s idempotency key is the MESSAGE
|
|
48
|
+
* id (see `mintMessageId`), not this one, so a collision on this rung costs a locally
|
|
49
|
+
* merged/duplicated thread (or two runs sharing a part-id namespace), not silent data loss on
|
|
50
|
+
* the append path. Do not reuse this rung for anything with a higher collision cost than that
|
|
51
|
+
* — see `mintMessageId` for the id that needs one.
|
|
52
|
+
*/
|
|
53
|
+
export function mintThreadId(): string {
|
|
54
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
55
|
+
return crypto.randomUUID()
|
|
56
|
+
}
|
|
57
|
+
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
|
58
|
+
return assembleUuidV4(crypto.getRandomValues(new Uint8Array(16)))
|
|
59
|
+
}
|
|
60
|
+
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Mints a message id — the value `ThreadsStoreAdapter.appendMessage` treats as its ONLY
|
|
65
|
+
* idempotency key (see that method's contract doc in `./adapter`). Shares `mintThreadId`'s rungs
|
|
66
|
+
* 1-2 (`crypto.randomUUID()`, then `crypto.getRandomValues()` assembled into a UUIDv4) — both are
|
|
67
|
+
* cryptographically random and collision-resistant, and rung 2 is the fix that actually matters in
|
|
68
|
+
* practice (a plain-HTTP/LAN host still has `getRandomValues`; only `randomUUID` is secure-context-
|
|
69
|
+
* gated).
|
|
70
|
+
*
|
|
71
|
+
* Rung 3 deliberately DIVERGES from `mintThreadId`: it THROWS instead of degrading to a
|
|
72
|
+
* non-random fallback. A thread id's rung-3 collision costs a locally merged/duplicated thread —
|
|
73
|
+
* annoying, recoverable, visible. A message id's collision cost is different IN KIND, not degree:
|
|
74
|
+
* `appendMessage` is idempotent on this id, so two messages that collide are not two rows, they
|
|
75
|
+
* are ONE — the second write silently no-ops and its content is gone, with nothing downstream able
|
|
76
|
+
* to tell. Degrading here would trade a loud, debuggable failure for silent, permanent data loss.
|
|
77
|
+
*
|
|
78
|
+
* This deliberately diverges from this layer's other standing rule — render-path code degrades,
|
|
79
|
+
* never throws (`spliceText` clamps, `coalesceParts` degrades, a consumer fence renderer must not
|
|
80
|
+
* take the message down) — because minting a message id is NOT render-path code. It runs once, in
|
|
81
|
+
* the write path that constructs a message before handing it to a store (`start()`,
|
|
82
|
+
* `consumeAndFinalize`'s finalize step, `finalizeStop`, `aiSdkTransport`'s per-turn user message),
|
|
83
|
+
* never inside the per-chunk accumulation/render loop those functions guard. A host with no usable
|
|
84
|
+
* `crypto` at all (missing both `randomUUID` and `getRandomValues`) is vanishingly rare — no
|
|
85
|
+
* runtime with a DOM/fetch surface worth targeting ships without a Crypto object — and an uncaught
|
|
86
|
+
* throw here is a loud, debuggable failure rather than a message silently vanishing with no trace,
|
|
87
|
+
* but WHERE it surfaces differs by call site (audited, not assumed — this paragraph previously
|
|
88
|
+
* claimed a single shared catch handled all of them, which was false):
|
|
89
|
+
* - `consumeAndFinalize`'s own assistantMessage mint runs inside that function's try/catch, so a
|
|
90
|
+
* throw there is correctly turned into `onFailureStatus` ('error' from `start()`, 'interrupted'
|
|
91
|
+
* from the mount-time resume) — a visibly stuck-then-settled run, as originally claimed.
|
|
92
|
+
* - `useAgentThreadRuns.start()`'s userMessage mint runs BEFORE any state is touched
|
|
93
|
+
* (`appendMessage`/`setStatus`/the controller registration all come after it), so a throw there
|
|
94
|
+
* is a true no-op that propagates synchronously out of `start()` itself — not a stuck run at
|
|
95
|
+
* all, since nothing ever started. See `UseAgentThreadRunsReturn.start`'s `@throws` doc.
|
|
96
|
+
* - `finalizeStop`'s stoppedMessage mint is wrapped in its own try/catch precisely so a throw
|
|
97
|
+
* here cannot wedge the thread at 'streaming' forever (stop() has already torn down this run's
|
|
98
|
+
* bookkeeping by the time finalizeStop runs, so a second stop() is always a no-op) — see
|
|
99
|
+
* `finalizeStop`'s doc for the guarded behavior and the resulting 'error' status.
|
|
100
|
+
* A stuck spinner is debuggable; silently discarded chat history is not — that half of the
|
|
101
|
+
* original claim stands regardless of which of the three paths above catches it.
|
|
102
|
+
*
|
|
103
|
+
* @throws {Error} when neither `crypto.randomUUID` nor `crypto.getRandomValues` is available.
|
|
104
|
+
*/
|
|
105
|
+
export function mintMessageId(): string {
|
|
106
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
107
|
+
return crypto.randomUUID()
|
|
108
|
+
}
|
|
109
|
+
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
|
110
|
+
return assembleUuidV4(crypto.getRandomValues(new Uint8Array(16)))
|
|
111
|
+
}
|
|
112
|
+
throw new Error(
|
|
113
|
+
'mintMessageId: no usable crypto (both randomUUID and getRandomValues are unavailable) — ' +
|
|
114
|
+
'refusing to mint a non-random message id, since ThreadsStoreAdapter.appendMessage treats ' +
|
|
115
|
+
'this id as its only idempotency key and a collision would silently drop a message.',
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
1
119
|
/**
|
|
2
120
|
* withPartIds — stamps a stable id onto every draft part arriving without one.
|
|
3
121
|
*
|
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
|