basalt-ui 1.10.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 +25 -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.d.ts +3 -52
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +108 -24
- package/dist/agent/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 +42 -0
- package/dist/agent/coalesce.d.ts.map +1 -0
- package/dist/agent/coalesce.js +137 -0
- package/dist/agent/coalesce.test.d.ts +2 -0
- package/dist/agent/coalesce.test.d.ts.map +1 -0
- package/dist/agent/foreign.d.ts +97 -0
- package/dist/agent/foreign.d.ts.map +1 -0
- package/dist/agent/foreign.js +18 -0
- package/dist/agent/foreign.test.d.ts +2 -0
- package/dist/agent/foreign.test.d.ts.map +1 -0
- package/dist/agent/history.d.ts +2 -0
- package/dist/agent/history.d.ts.map +1 -1
- package/dist/agent/id.d.ts +65 -0
- package/dist/agent/id.d.ts.map +1 -0
- 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 +11 -4
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +18 -3
- package/dist/agent/merge.d.ts +8 -0
- package/dist/agent/merge.d.ts.map +1 -0
- package/dist/agent/merge.js +35 -0
- 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 +53 -7
- 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 +135 -20
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +138 -11
- 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.d.ts +39 -3
- package/dist/agent/transport.d.ts.map +1 -1
- package/dist/agent/transport.js +20 -17
- 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 +2 -1
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +5 -2
- package/dist/agent/use-agent-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 +9 -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 +75 -23
- 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 +3 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -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-message.d.ts +24 -7
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +115 -45
- 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.d.ts +21 -0
- package/dist/agent-chat/tool-chip.d.ts.map +1 -0
- package/dist/agent-chat/tool-chip.js +120 -0
- package/dist/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/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +1 -3
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- 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 +13 -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 +7 -7
- 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 +380 -55
- package/src/agent/ai-sdk-transport.ts +206 -53
- package/src/agent/coalesce.test.ts +387 -0
- package/src/agent/coalesce.ts +229 -0
- package/src/agent/foreign.test.ts +51 -0
- package/src/agent/foreign.ts +140 -0
- package/src/agent/history.ts +2 -0
- package/src/agent/id.test.ts +75 -0
- package/src/agent/id.ts +91 -0
- package/src/agent/index.ts +34 -3
- package/src/agent/merge.test.ts +206 -0
- package/src/agent/merge.ts +114 -0
- package/src/agent/part-list.test.tsx +112 -0
- package/src/agent/part-list.tsx +108 -35
- package/src/agent/parts.test.ts +516 -57
- package/src/agent/parts.ts +323 -32
- package/src/agent/thread.test.ts +75 -2
- package/src/agent/thread.ts +21 -1
- package/src/agent/transport.test.ts +70 -0
- package/src/agent/transport.ts +72 -20
- package/src/agent/use-agent-stream.test.tsx +53 -19
- package/src/agent/use-agent-stream.ts +13 -3
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +138 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +44 -11
- package/src/agent/use-agent-thread-runs.test.tsx +418 -22
- package/src/agent/use-agent-thread-runs.ts +170 -26
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +15 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +5 -1
- package/src/agent-chat/thread-detail-panel.tsx +3 -1
- package/src/agent-chat/thread-message.test.tsx +351 -0
- package/src/agent-chat/thread-message.tsx +262 -64
- package/src/agent-chat/thread-workspace.tsx +1 -1
- package/src/agent-chat/tool-chip.test.tsx +139 -0
- package/src/agent-chat/tool-chip.tsx +219 -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/guard/check-source.test.ts +8 -4
- package/src/guard/index.ts +1 -3
- package/src/index.ts +15 -1
- package/src/surfaces.ts +13 -4
- 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/parts.ts
CHANGED
|
@@ -3,12 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Exhaustiveness is enforced at every switch via assertNever (imported from '../register').
|
|
5
5
|
* Adding a new variant without a matching case is a tsc error — see the fixture in
|
|
6
|
-
* apps/playground/src/demo/agent-part.type-guard.ts.
|
|
6
|
+
* apps/playground/src/demo/agent-part.type-guard.ts. One caveat for code that folds WIRE data on
|
|
7
|
+
* the render path (`coalesceParts`): keep the compile-time guarantee — bind the value at `never` —
|
|
8
|
+
* but do NOT let the runtime throw, or one malformed chunk kills the whole transcript. See
|
|
9
|
+
* `mergeToolPart`'s default branch for that shape.
|
|
7
10
|
*
|
|
8
11
|
* Eden #231 doctrine: the server-side Elysia route MUST declare `: AsyncGenerator<AgentPart>`
|
|
9
12
|
* explicitly and carry NO t.Object/t.Union response schema. Validate at yield-time. See
|
|
10
13
|
* agent/rules/basalt-agent.md for the full doctrine.
|
|
11
14
|
*
|
|
15
|
+
* Part identity: every variant carries a stable `id` (see `PartBase`). Transports are permitted to
|
|
16
|
+
* yield a DRAFT — `id` omitted — via `AgentPartDraft`; the consuming hooks (or `withPartIds`)
|
|
17
|
+
* normalize a draft into a fully-identified part before it is ever accumulated. `parseAgentPart`
|
|
18
|
+
* validates the FULL (identified) shape — a draft is a separate, narrower concern.
|
|
19
|
+
*
|
|
12
20
|
* @example
|
|
13
21
|
* import type { AgentPart } from 'basalt-ui/agent'
|
|
14
22
|
* function describe(p: AgentPart): string {
|
|
@@ -24,6 +32,11 @@
|
|
|
24
32
|
* }
|
|
25
33
|
*/
|
|
26
34
|
|
|
35
|
+
// ── Part identity ─────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** Every AgentPart variant carries a stable id — the addressing key for `mergePart`/`coalesceParts`. */
|
|
38
|
+
type PartBase = { readonly id: string }
|
|
39
|
+
|
|
27
40
|
// ── AgentPart discriminated union ────────────────────────────────────────────
|
|
28
41
|
|
|
29
42
|
/**
|
|
@@ -31,53 +44,179 @@
|
|
|
31
44
|
* resume this run after a disconnect. Not renderable content — UI code should treat it as a
|
|
32
45
|
* no-op signal, not conversation content.
|
|
33
46
|
*/
|
|
34
|
-
export type StartPart = {
|
|
47
|
+
export type StartPart = PartBase & {
|
|
35
48
|
readonly type: 'start'
|
|
36
49
|
readonly runId: string
|
|
37
50
|
readonly resumeToken?: string
|
|
38
51
|
}
|
|
39
52
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
53
|
+
/**
|
|
54
|
+
* A streamed text fragment from the assistant. `offset` is the character position of this delta
|
|
55
|
+
* inside its part (undefined = append at the tail) — the mechanism `mergePart` uses to splice a
|
|
56
|
+
* replayed delta in place rather than duplicating it.
|
|
57
|
+
*/
|
|
58
|
+
export type TextPart = PartBase & {
|
|
59
|
+
readonly type: 'text'
|
|
60
|
+
readonly text: string
|
|
61
|
+
readonly offset?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** An internal reasoning/thinking fragment (may be hidden in the UI). Same `offset` mechanism as TextPart. */
|
|
65
|
+
export type ReasoningPart = PartBase & {
|
|
66
|
+
readonly type: 'reasoning'
|
|
67
|
+
readonly text: string
|
|
68
|
+
readonly offset?: number
|
|
69
|
+
}
|
|
42
70
|
|
|
43
|
-
|
|
44
|
-
|
|
71
|
+
// ── ToolCallPart — mirrors AI SDK v7's UIToolInvocation ─────────────────────
|
|
72
|
+
//
|
|
73
|
+
// Verified against installed ai@7.0.16 / 7.0.18 (byte-identical .d.ts). Seven states, no
|
|
74
|
+
// 'running' ('input-available' IS the running state — input complete, no output yet).
|
|
75
|
+
//
|
|
76
|
+
// The nested `approval` shape mirrors the SDK exactly (`{ id, approved?, reason?, isAutomatic?,
|
|
77
|
+
// signature? }`) rather than flattening to `approvalId`/`approved`/`reason` — flattening would
|
|
78
|
+
// silently drop `isAutomatic`/`signature`.
|
|
79
|
+
//
|
|
80
|
+
// `toolName` is NOT a field on the SDK's UIToolInvocation — for static tools it lives in the part
|
|
81
|
+
// discriminator `tool-${NAME}`. It IS a required field here (ours, not the SDK's) because the chip
|
|
82
|
+
// needs it; a transport mapping from the SDK must DERIVE it from the discriminator, not read it off
|
|
83
|
+
// the invocation.
|
|
84
|
+
//
|
|
85
|
+
// `toolCallId` is required on the three IN-FLIGHT states and OPTIONAL on the four SETTLED ones —
|
|
86
|
+
// see `Identified` / `MaybeIdentified` below. The absence is modelled in the type, never as a
|
|
87
|
+
// sentinel value.
|
|
88
|
+
|
|
89
|
+
/** The nested approval envelope — mirrors the SDK's shape verbatim, never flattened. */
|
|
90
|
+
type ToolApproval = {
|
|
91
|
+
readonly id: string
|
|
92
|
+
readonly approved?: boolean
|
|
93
|
+
readonly reason?: string
|
|
94
|
+
readonly isAutomatic?: boolean
|
|
95
|
+
readonly signature?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type ToolCallBase = PartBase & {
|
|
99
|
+
readonly type: 'tool'
|
|
100
|
+
readonly toolName: string
|
|
101
|
+
/** Wall-clock time from first sighting of this toolCallId to its terminal state. */
|
|
102
|
+
readonly durationMs?: number
|
|
103
|
+
readonly providerExecuted?: boolean
|
|
104
|
+
}
|
|
45
105
|
|
|
46
106
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
107
|
+
* The three IN-FLIGHT states (`input-streaming`, `input-available`, `approval-requested`) carry an
|
|
108
|
+
* authoritative `toolCallId` — the merge key, and for them it is structural. An in-flight part
|
|
109
|
+
* exists only to be superseded by the settled part for the same call; with no merge key it can
|
|
110
|
+
* never be closed, so it would render as a forever-pending chip standing beside the real result.
|
|
111
|
+
* Nothing is lost by rejecting an unidentified one either: the `toolName` and `input` it carries
|
|
112
|
+
* are re-supplied on the settled part. `parseToolCallPart` therefore rejects these when the id is
|
|
113
|
+
* missing or empty.
|
|
114
|
+
*/
|
|
115
|
+
type Identified = ToolCallBase & { readonly toolCallId: string }
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The four SETTLED states (`approval-responded` plus the three terminal ones) may legitimately
|
|
119
|
+
* arrive without a `toolCallId`, for two different reasons.
|
|
120
|
+
*
|
|
121
|
+
* `approval-responded` is where the WIRE omits it: the SDK's `tool-approval-response` chunk
|
|
122
|
+
* carries none, and it is resolved by reverse lookup on `approval.id` (coalesce.ts keeps the
|
|
123
|
+
* approvalId -> toolCallId side index that does it).
|
|
124
|
+
*
|
|
125
|
+
* The three terminal states admit the absence for a different reason — tolerance, not wire shape.
|
|
126
|
+
* They are self-contained: each carries the OUTCOME of the call, so a missing id costs only the
|
|
127
|
+
* fold (`coalesceParts` keeps an unresolvable part as its own block and never discards it).
|
|
128
|
+
* Rejecting one instead would delete the only record of how the call ended — silent data loss in a
|
|
129
|
+
* transcript, strictly worse than an unfolded block.
|
|
49
130
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
131
|
+
* The absence is modelled here rather than smuggled through an empty-string sentinel: the union
|
|
132
|
+
* previously declared an always-meaningful `string` that the `approval-responded` wire shape
|
|
133
|
+
* cannot supply, which forced a `''` sentinel into the type and an `as unknown as` cast into every
|
|
134
|
+
* test that built one. A `''` toolCallId is not a value any state accepts; `parseToolCallPart`
|
|
135
|
+
* normalizes one to absent at the wire boundary, so it never reaches the type or a consumer.
|
|
53
136
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
readonly
|
|
58
|
-
readonly
|
|
59
|
-
|
|
137
|
+
type MaybeIdentified = ToolCallBase & { readonly toolCallId?: string }
|
|
138
|
+
|
|
139
|
+
export type ToolCallPart =
|
|
140
|
+
| (Identified & { readonly state: 'input-streaming'; readonly input?: unknown })
|
|
141
|
+
| (Identified & { readonly state: 'input-available'; readonly input: unknown })
|
|
142
|
+
| (Identified & {
|
|
143
|
+
readonly state: 'approval-requested'
|
|
144
|
+
readonly input: unknown
|
|
145
|
+
readonly approval: ToolApproval & { readonly approved?: never; readonly reason?: never }
|
|
146
|
+
})
|
|
147
|
+
| (MaybeIdentified & {
|
|
148
|
+
readonly state: 'approval-responded'
|
|
149
|
+
readonly input: unknown
|
|
150
|
+
readonly approval: ToolApproval & { readonly approved: boolean }
|
|
151
|
+
})
|
|
152
|
+
| (MaybeIdentified & {
|
|
153
|
+
readonly state: 'output-available'
|
|
154
|
+
readonly input: unknown
|
|
155
|
+
readonly output: unknown
|
|
156
|
+
readonly preliminary?: boolean
|
|
157
|
+
readonly approval?: ToolApproval & { readonly approved?: true }
|
|
158
|
+
})
|
|
159
|
+
| (MaybeIdentified & {
|
|
160
|
+
readonly state: 'output-error'
|
|
161
|
+
readonly input?: unknown
|
|
162
|
+
/** The SDK's field name — there is no field named `error` anywhere in the union. */
|
|
163
|
+
readonly errorText: string
|
|
164
|
+
/** The only place the raw, un-parsed input survives — what lets a UI render a call whose input never validated. */
|
|
165
|
+
readonly rawInput?: unknown
|
|
166
|
+
readonly approval?: ToolApproval & { readonly approved?: true }
|
|
167
|
+
})
|
|
168
|
+
| (MaybeIdentified & {
|
|
169
|
+
readonly state: 'output-denied'
|
|
170
|
+
readonly input: unknown
|
|
171
|
+
readonly approval: ToolApproval & { readonly approved: false }
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
export type ToolCallState = ToolCallPart['state']
|
|
175
|
+
|
|
176
|
+
/** The three states a tool call can never leave — see `isToolCallSettled`. */
|
|
177
|
+
export const TERMINAL_TOOL_STATES = ['output-available', 'output-error', 'output-denied'] as const
|
|
178
|
+
|
|
179
|
+
/** True once a tool call has reached one of `TERMINAL_TOOL_STATES` — no further state transition. */
|
|
180
|
+
export function isToolCallSettled(part: ToolCallPart): boolean {
|
|
181
|
+
return (TERMINAL_TOOL_STATES as readonly ToolCallState[]).includes(part.state)
|
|
60
182
|
}
|
|
61
183
|
|
|
62
184
|
/** A cited source URL referenced by the assistant. */
|
|
63
|
-
export type SourcePart =
|
|
185
|
+
export type SourcePart = PartBase & {
|
|
186
|
+
readonly type: 'source'
|
|
187
|
+
readonly url: string
|
|
188
|
+
readonly title?: string
|
|
189
|
+
}
|
|
64
190
|
|
|
65
191
|
/** A fatal or recoverable error emitted in-stream by the agent. */
|
|
66
|
-
export type ErrorPart = { readonly type: 'error'; readonly message: string }
|
|
192
|
+
export type ErrorPart = PartBase & { readonly type: 'error'; readonly message: string }
|
|
67
193
|
|
|
68
194
|
/**
|
|
69
195
|
* The complete discriminated union of all structured agent stream parts.
|
|
70
196
|
*
|
|
71
197
|
* @example
|
|
72
|
-
* const part: AgentPart = { type: 'text', text: 'Hello' }
|
|
198
|
+
* const part: AgentPart = { id: 'p1', type: 'text', text: 'Hello' }
|
|
73
199
|
*/
|
|
74
200
|
export type AgentPart = StartPart | TextPart | ReasoningPart | ToolCallPart | SourcePart | ErrorPart
|
|
75
201
|
|
|
202
|
+
// ── AgentPartDraft — what a transport is allowed to yield ────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* An AgentPart with `id` optional — the shape a transport is allowed to yield. The naked type
|
|
206
|
+
* parameter (not `AgentPart extends unknown ? ... : never` written some other way) is deliberate:
|
|
207
|
+
* it makes the conditional type distribute over the AgentPart union member-by-member, so
|
|
208
|
+
* `Drafted<AgentPart>` is `Drafted<StartPart> | Drafted<TextPart> | ...` rather than one flattened
|
|
209
|
+
* (and wrong) shape.
|
|
210
|
+
*/
|
|
211
|
+
export type Drafted<T> = T extends unknown ? Omit<T, 'id'> & { readonly id?: string } : never
|
|
212
|
+
export type AgentPartDraft = Drafted<AgentPart>
|
|
213
|
+
|
|
76
214
|
// ── parseAgentPart ───────────────────────────────────────────────────────────
|
|
77
215
|
|
|
78
216
|
/**
|
|
79
217
|
* Runtime type-narrowing guard: validates an unknown value against the AgentPart discriminated
|
|
80
|
-
* union and returns the narrowed type or null when validation fails.
|
|
218
|
+
* union and returns the narrowed type or null when validation fails. Validates the FULL
|
|
219
|
+
* (identified) shape — an `id`-less draft is a separate concern handled by `withPartIds`.
|
|
81
220
|
*
|
|
82
221
|
* @example
|
|
83
222
|
* const raw = JSON.parse(line)
|
|
@@ -88,46 +227,198 @@ export function parseAgentPart(raw: unknown): AgentPart | null {
|
|
|
88
227
|
if (raw === null || typeof raw !== 'object') return null
|
|
89
228
|
const obj = raw as Record<string, unknown>
|
|
90
229
|
if (typeof obj['type'] !== 'string') return null
|
|
230
|
+
if (typeof obj['id'] !== 'string') return null
|
|
231
|
+
const id = obj['id']
|
|
91
232
|
|
|
92
233
|
switch (obj['type']) {
|
|
93
234
|
case 'start':
|
|
94
235
|
if (typeof obj['runId'] !== 'string') return null
|
|
95
236
|
return {
|
|
237
|
+
id,
|
|
96
238
|
type: 'start',
|
|
97
239
|
runId: obj['runId'],
|
|
98
240
|
...(typeof obj['resumeToken'] === 'string' ? { resumeToken: obj['resumeToken'] } : {}),
|
|
99
241
|
}
|
|
100
242
|
case 'text':
|
|
101
243
|
if (typeof obj['text'] !== 'string') return null
|
|
102
|
-
return {
|
|
244
|
+
return {
|
|
245
|
+
id,
|
|
246
|
+
type: 'text',
|
|
247
|
+
text: obj['text'],
|
|
248
|
+
...(typeof obj['offset'] === 'number' ? { offset: obj['offset'] } : {}),
|
|
249
|
+
}
|
|
103
250
|
case 'reasoning':
|
|
104
251
|
if (typeof obj['text'] !== 'string') return null
|
|
105
|
-
return { type: 'reasoning', text: obj['text'] }
|
|
106
|
-
case 'tool':
|
|
107
|
-
if (typeof obj['toolName'] !== 'string') return null
|
|
108
|
-
if (!('input' in obj)) return null
|
|
109
252
|
return {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
...(obj['
|
|
114
|
-
...(typeof obj['toolCallId'] === 'string' ? { toolCallId: obj['toolCallId'] } : {}),
|
|
253
|
+
id,
|
|
254
|
+
type: 'reasoning',
|
|
255
|
+
text: obj['text'],
|
|
256
|
+
...(typeof obj['offset'] === 'number' ? { offset: obj['offset'] } : {}),
|
|
115
257
|
}
|
|
258
|
+
case 'tool':
|
|
259
|
+
return parseToolCallPart(id, obj)
|
|
116
260
|
case 'source':
|
|
117
261
|
if (typeof obj['url'] !== 'string') return null
|
|
118
262
|
return {
|
|
263
|
+
id,
|
|
119
264
|
type: 'source',
|
|
120
265
|
url: obj['url'],
|
|
121
266
|
...(typeof obj['title'] === 'string' ? { title: obj['title'] } : {}),
|
|
122
267
|
}
|
|
123
268
|
case 'error':
|
|
124
269
|
if (typeof obj['message'] !== 'string') return null
|
|
125
|
-
return { type: 'error', message: obj['message'] }
|
|
270
|
+
return { id, type: 'error', message: obj['message'] }
|
|
126
271
|
default:
|
|
127
272
|
return null
|
|
128
273
|
}
|
|
129
274
|
}
|
|
130
275
|
|
|
276
|
+
/** Validates the state-discriminated `tool` part shape. Rejects the pre-1.11.0 flat shape
|
|
277
|
+
* (`{type:'tool', output}` with no `state`) and any unrecognized `state` value.
|
|
278
|
+
*
|
|
279
|
+
* An empty-string `toolCallId` is normalized to absent here — the one place the wire's degenerate
|
|
280
|
+
* shape is decoded — so `''` never reaches the type or any consumer of it. What an ABSENT id then
|
|
281
|
+
* means is decided per state, exactly as the union declares it (`Identified` /
|
|
282
|
+
* `MaybeIdentified`): fatal on the three in-flight states, which exist only to be closed by a
|
|
283
|
+
* later one and are re-supplied by it; survivable on the four settled states, which carry the
|
|
284
|
+
* outcome and are kept UNIDENTIFIED rather than dropped. Dropping a settled part would be silent
|
|
285
|
+
* data loss — the transcript would simply forget how the call ended. */
|
|
286
|
+
function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPart | null {
|
|
287
|
+
if (typeof obj['toolName'] !== 'string') return null
|
|
288
|
+
if (typeof obj['state'] !== 'string') return null
|
|
289
|
+
|
|
290
|
+
const rawToolCallId = obj['toolCallId']
|
|
291
|
+
if (rawToolCallId !== undefined && typeof rawToolCallId !== 'string') return null
|
|
292
|
+
const toolCallId =
|
|
293
|
+
typeof rawToolCallId === 'string' && rawToolCallId.length > 0 ? rawToolCallId : undefined
|
|
294
|
+
|
|
295
|
+
const toolName = obj['toolName']
|
|
296
|
+
const common = {
|
|
297
|
+
id,
|
|
298
|
+
type: 'tool' as const,
|
|
299
|
+
toolName,
|
|
300
|
+
...(typeof obj['durationMs'] === 'number' ? { durationMs: obj['durationMs'] } : {}),
|
|
301
|
+
...(typeof obj['providerExecuted'] === 'boolean'
|
|
302
|
+
? { providerExecuted: obj['providerExecuted'] }
|
|
303
|
+
: {}),
|
|
304
|
+
}
|
|
305
|
+
// The three in-flight states below require an identified call; the four settled ones spread
|
|
306
|
+
// `identity` instead, which is simply empty when the wire supplied no usable id.
|
|
307
|
+
const identified = toolCallId !== undefined ? { ...common, toolCallId } : null
|
|
308
|
+
const identity = toolCallId !== undefined ? { toolCallId } : {}
|
|
309
|
+
|
|
310
|
+
switch (obj['state']) {
|
|
311
|
+
case 'input-streaming':
|
|
312
|
+
if (identified === null) return null
|
|
313
|
+
return {
|
|
314
|
+
...identified,
|
|
315
|
+
state: 'input-streaming',
|
|
316
|
+
...('input' in obj ? { input: obj['input'] } : {}),
|
|
317
|
+
}
|
|
318
|
+
case 'input-available':
|
|
319
|
+
if (identified === null) return null
|
|
320
|
+
if (!('input' in obj)) return null
|
|
321
|
+
return { ...identified, state: 'input-available', input: obj['input'] }
|
|
322
|
+
case 'approval-requested': {
|
|
323
|
+
if (identified === null) return null
|
|
324
|
+
if (!('input' in obj)) return null
|
|
325
|
+
const approval = parseApproval(obj['approval'])
|
|
326
|
+
// The type declares this state's approval as `{ approved?: never; reason?: never }` — a
|
|
327
|
+
// request that already carries a verdict or a reason is a contradiction, not a pending
|
|
328
|
+
// request, so reject it rather than silently casting past the invariant.
|
|
329
|
+
if (approval === null || approval.approved !== undefined || approval.reason !== undefined) {
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
...identified,
|
|
334
|
+
state: 'approval-requested',
|
|
335
|
+
input: obj['input'],
|
|
336
|
+
approval: approval as ToolApproval & { approved?: never; reason?: never },
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
case 'approval-responded': {
|
|
340
|
+
if (!('input' in obj)) return null
|
|
341
|
+
const approval = parseApproval(obj['approval'])
|
|
342
|
+
if (approval === null || typeof approval.approved !== 'boolean') return null
|
|
343
|
+
// The state the WIRE ships unidentified — `approval.id` is then its only addressing key.
|
|
344
|
+
return {
|
|
345
|
+
...common,
|
|
346
|
+
...identity,
|
|
347
|
+
state: 'approval-responded',
|
|
348
|
+
input: obj['input'],
|
|
349
|
+
approval: approval as ToolApproval & { approved: boolean },
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
case 'output-denied': {
|
|
353
|
+
if (!('input' in obj)) return null
|
|
354
|
+
const approval = parseApproval(obj['approval'])
|
|
355
|
+
if (approval === null || approval.approved !== false) return null
|
|
356
|
+
return {
|
|
357
|
+
...common,
|
|
358
|
+
...identity,
|
|
359
|
+
state: 'output-denied',
|
|
360
|
+
input: obj['input'],
|
|
361
|
+
approval: approval as ToolApproval & { approved: false },
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
case 'output-available': {
|
|
365
|
+
if (!('input' in obj)) return null
|
|
366
|
+
if (!('output' in obj)) return null
|
|
367
|
+
const approval = obj['approval'] === undefined ? null : parseApproval(obj['approval'])
|
|
368
|
+
// The type declares this state's approval as `{ approved?: true }` — output present
|
|
369
|
+
// alongside `approved: false` is a denial masquerading as a result, not a valid "approved"
|
|
370
|
+
// carry-through. A present-but-malformed approval is rejected the same way (not silently
|
|
371
|
+
// dropped), matching output-denied/approval-responded's precedent above.
|
|
372
|
+
if (obj['approval'] !== undefined && (approval === null || approval.approved === false)) {
|
|
373
|
+
return null
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
...common,
|
|
377
|
+
...identity,
|
|
378
|
+
state: 'output-available',
|
|
379
|
+
input: obj['input'],
|
|
380
|
+
output: obj['output'],
|
|
381
|
+
...(typeof obj['preliminary'] === 'boolean' ? { preliminary: obj['preliminary'] } : {}),
|
|
382
|
+
...(approval !== null ? { approval: approval as ToolApproval & { approved?: true } } : {}),
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
case 'output-error': {
|
|
386
|
+
if (typeof obj['errorText'] !== 'string') return null
|
|
387
|
+
const approval = obj['approval'] === undefined ? null : parseApproval(obj['approval'])
|
|
388
|
+
// Same invariant as output-available above: an error carrying `approved: false` is a
|
|
389
|
+
// denial, not an error whose approval "happens to carry through".
|
|
390
|
+
if (obj['approval'] !== undefined && (approval === null || approval.approved === false)) {
|
|
391
|
+
return null
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
...common,
|
|
395
|
+
...identity,
|
|
396
|
+
state: 'output-error',
|
|
397
|
+
errorText: obj['errorText'],
|
|
398
|
+
...('input' in obj ? { input: obj['input'] } : {}),
|
|
399
|
+
...('rawInput' in obj ? { rawInput: obj['rawInput'] } : {}),
|
|
400
|
+
...(approval !== null ? { approval: approval as ToolApproval & { approved?: true } } : {}),
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
default:
|
|
404
|
+
return null
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Validates the nested `approval` envelope. Returns null when absent/malformed. */
|
|
409
|
+
function parseApproval(raw: unknown): ToolApproval | null {
|
|
410
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
411
|
+
const obj = raw as Record<string, unknown>
|
|
412
|
+
if (typeof obj['id'] !== 'string') return null
|
|
413
|
+
return {
|
|
414
|
+
id: obj['id'],
|
|
415
|
+
...(typeof obj['approved'] === 'boolean' ? { approved: obj['approved'] } : {}),
|
|
416
|
+
...(typeof obj['reason'] === 'string' ? { reason: obj['reason'] } : {}),
|
|
417
|
+
...(typeof obj['isAutomatic'] === 'boolean' ? { isAutomatic: obj['isAutomatic'] } : {}),
|
|
418
|
+
...(typeof obj['signature'] === 'string' ? { signature: obj['signature'] } : {}),
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
131
422
|
// ── isStartPart ──────────────────────────────────────────────────────────────
|
|
132
423
|
|
|
133
424
|
/** Type guard: narrows an unknown value to StartPart. */
|
package/src/agent/thread.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* createThreadsStore — ring-buffer caps (maxThreads/maxMessagesPerThread), newest-first ordering,
|
|
3
|
-
* setResumeToken's delete-the-key-on-undefined semantics,
|
|
3
|
+
* setResumeToken's delete-the-key-on-undefined semantics, same-tick action accumulation, and the
|
|
4
|
+
* constant hydrated/error pair the async sibling store (./adapter) actually varies.
|
|
4
5
|
*
|
|
5
6
|
* Uses the REAL localStorage-backed store (happy-dom provides `window.localStorage`), so every
|
|
6
7
|
* test uses its own unique `key` — the same convention as
|
|
@@ -13,7 +14,12 @@ import { createThreadsStore } from './thread'
|
|
|
13
14
|
import type { ChatMessage } from './history'
|
|
14
15
|
|
|
15
16
|
function makeMessage(id: string): ChatMessage {
|
|
16
|
-
return {
|
|
17
|
+
return {
|
|
18
|
+
id,
|
|
19
|
+
role: 'user',
|
|
20
|
+
parts: [{ id: `${id}-p0`, type: 'text', text: id }],
|
|
21
|
+
createdAt: Date.now(),
|
|
22
|
+
}
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
describe('createThreadsStore', () => {
|
|
@@ -144,4 +150,71 @@ describe('createThreadsStore', () => {
|
|
|
144
150
|
expect(thread?.status).toBe('streaming')
|
|
145
151
|
expect(thread?.resumeToken).toBe('tok-accum')
|
|
146
152
|
})
|
|
153
|
+
|
|
154
|
+
test('hydrated is always true and error always undefined — localStorage resolves synchronously', () => {
|
|
155
|
+
const useThreads = createThreadsStore({ key: 'thread-hydrated-a', version: 1 })
|
|
156
|
+
const { result } = renderHook(() => useThreads())
|
|
157
|
+
|
|
158
|
+
// Pinned because these two exist for the async adapter store (./adapter); the localStorage
|
|
159
|
+
// store must fill them with the "nothing to wait for, nothing to report" constants on the
|
|
160
|
+
// very first render, before any action has run.
|
|
161
|
+
expect(result.current.hydrated).toBe(true)
|
|
162
|
+
expect(result.current.error).toBeUndefined()
|
|
163
|
+
|
|
164
|
+
act(() => {
|
|
165
|
+
result.current.create()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(result.current.hydrated).toBe(true)
|
|
169
|
+
expect(result.current.error).toBeUndefined()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('create() mints distinct ids via crypto.getRandomValues when randomUUID is unavailable', () => {
|
|
173
|
+
const originalCrypto = globalThis.crypto
|
|
174
|
+
Object.defineProperty(globalThis, 'crypto', {
|
|
175
|
+
value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
|
|
176
|
+
configurable: true,
|
|
177
|
+
})
|
|
178
|
+
try {
|
|
179
|
+
const useThreads = createThreadsStore({ key: 'thread-crypto-a', version: 1 })
|
|
180
|
+
const { result } = renderHook(() => useThreads())
|
|
181
|
+
|
|
182
|
+
let first = ''
|
|
183
|
+
let second = ''
|
|
184
|
+
act(() => {
|
|
185
|
+
first = result.current.create()
|
|
186
|
+
second = result.current.create()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
expect(first).not.toBe('')
|
|
190
|
+
expect(second).not.toBe('')
|
|
191
|
+
expect(first).not.toBe(second)
|
|
192
|
+
} finally {
|
|
193
|
+
// Restore the real crypto so no later test in the process is affected.
|
|
194
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('create() still mints distinct ids with no usable crypto at all (randomUUID and getRandomValues both absent)', () => {
|
|
199
|
+
const originalCrypto = globalThis.crypto
|
|
200
|
+
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
|
|
201
|
+
try {
|
|
202
|
+
const useThreads = createThreadsStore({ key: 'thread-crypto-b', version: 1 })
|
|
203
|
+
const { result } = renderHook(() => useThreads())
|
|
204
|
+
|
|
205
|
+
let first = ''
|
|
206
|
+
let second = ''
|
|
207
|
+
act(() => {
|
|
208
|
+
first = result.current.create()
|
|
209
|
+
second = result.current.create()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
expect(first).not.toBe('')
|
|
213
|
+
expect(second).not.toBe('')
|
|
214
|
+
expect(first).not.toBe(second)
|
|
215
|
+
} finally {
|
|
216
|
+
// Restore the real crypto so no later test in the process is affected.
|
|
217
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
218
|
+
}
|
|
219
|
+
})
|
|
147
220
|
})
|
package/src/agent/thread.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { useCallback, useRef } from 'react'
|
|
25
25
|
import { createPersistedState } from '../state'
|
|
26
26
|
import type { ChatMessage } from './history'
|
|
27
|
+
import { mintThreadId } from './id'
|
|
27
28
|
import type { AgentOutcome } from './outcome'
|
|
28
29
|
import type { AgentPart } from './parts'
|
|
29
30
|
|
|
@@ -138,6 +139,20 @@ export type ThreadsStore<TPart = AgentPart> = {
|
|
|
138
139
|
readonly remove: (id: string) => void
|
|
139
140
|
/** Remove all threads and clear the active selection. */
|
|
140
141
|
readonly clear: () => void
|
|
142
|
+
/**
|
|
143
|
+
* Whether `threads` reflects the backing store yet. Always `true` for the localStorage store
|
|
144
|
+
* (createPersistedState resolves synchronously); meaningful only for an async, server-backed
|
|
145
|
+
* store built with `createAdapterThreadsStore` (./adapter), where it stays `false` until the
|
|
146
|
+
* first `listThreads` SUCCEEDS. Pair it with `error` — `!hydrated && error !== undefined` is a
|
|
147
|
+
* failed load, `!hydrated && error === undefined` is still loading.
|
|
148
|
+
*/
|
|
149
|
+
readonly hydrated: boolean
|
|
150
|
+
/**
|
|
151
|
+
* The most recent persistence failure, or `undefined`. Always `undefined` for the localStorage
|
|
152
|
+
* store, which has no failure the caller can act on. An async store surfaces the rejected
|
|
153
|
+
* load/write here after rolling its optimistic edit back.
|
|
154
|
+
*/
|
|
155
|
+
readonly error: unknown
|
|
141
156
|
}
|
|
142
157
|
|
|
143
158
|
// ── createThreadsStore ────────────────────────────────────────────────────────
|
|
@@ -198,7 +213,7 @@ export function createThreadsStore<TPart = AgentPart>(
|
|
|
198
213
|
|
|
199
214
|
const create = useCallback(
|
|
200
215
|
(createOpts?: { readonly meta?: Record<string, unknown> }): string => {
|
|
201
|
-
const id =
|
|
216
|
+
const id = mintThreadId()
|
|
202
217
|
const now = Date.now()
|
|
203
218
|
const thread: AgentThread<TPart> = {
|
|
204
219
|
id,
|
|
@@ -307,6 +322,11 @@ export function createThreadsStore<TPart = AgentPart>(
|
|
|
307
322
|
markRead,
|
|
308
323
|
remove,
|
|
309
324
|
clear,
|
|
325
|
+
// localStorage resolves synchronously and swallows its own quota errors, so there is never
|
|
326
|
+
// a pending load to await nor a failure a caller could act on. See ./adapter for the async
|
|
327
|
+
// store where these two carry real information.
|
|
328
|
+
hydrated: true,
|
|
329
|
+
error: undefined,
|
|
310
330
|
}
|
|
311
331
|
}
|
|
312
332
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transport — `isResumable` and `edenTransport`'s opt-in `idempotentReplay` flag.
|
|
3
|
+
*
|
|
4
|
+
* `resume` alone is NOT enough for a transport to be treated as resumable: `isResumable` also
|
|
5
|
+
* requires the literal `idempotentReplay: true` assertion, and `edenTransport` only sets it when
|
|
6
|
+
* the caller opts in explicitly via a third argument — never inferred from `resumeCall` alone.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test'
|
|
9
|
+
import { edenTransport, isResumable } from './transport'
|
|
10
|
+
import type { AgentTransport } from './transport'
|
|
11
|
+
import type { AgentPart } from './parts'
|
|
12
|
+
|
|
13
|
+
describe('isResumable', () => {
|
|
14
|
+
test('false for a transport with no resume() at all', () => {
|
|
15
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
16
|
+
async *stream() {},
|
|
17
|
+
}
|
|
18
|
+
expect(isResumable(transport)).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('false for a transport with resume() but WITHOUT idempotentReplay — resume alone is not enough', () => {
|
|
22
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
23
|
+
async *stream() {},
|
|
24
|
+
async *resume() {
|
|
25
|
+
yield { id: 'p1', type: 'text', text: 'resumed' }
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
expect(isResumable(transport)).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('true only once BOTH resume() and idempotentReplay: true are present', () => {
|
|
32
|
+
const transport = {
|
|
33
|
+
async *stream() {},
|
|
34
|
+
async *resume() {
|
|
35
|
+
yield { id: 'p1', type: 'text', text: 'resumed' }
|
|
36
|
+
},
|
|
37
|
+
idempotentReplay: true as const,
|
|
38
|
+
}
|
|
39
|
+
expect(isResumable(transport)).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('edenTransport — idempotentReplay is opt-in, never inferred', () => {
|
|
44
|
+
test('no resumeCall at all → the returned object has NO resume key', () => {
|
|
45
|
+
const transport = edenTransport<AgentPart>(async () => ({
|
|
46
|
+
data: (async function* () {})(),
|
|
47
|
+
error: null,
|
|
48
|
+
}))
|
|
49
|
+
expect('resume' in transport).toBe(false)
|
|
50
|
+
expect(isResumable(transport)).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('resumeCall supplied but NO options → resume() exists but isResumable is still false', () => {
|
|
54
|
+
const transport = edenTransport<AgentPart>(
|
|
55
|
+
async () => ({ data: (async function* () {})(), error: null }),
|
|
56
|
+
async () => ({ data: (async function* () {})(), error: null }),
|
|
57
|
+
)
|
|
58
|
+
expect(typeof transport.resume).toBe('function')
|
|
59
|
+
expect(isResumable(transport)).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('resumeCall + { idempotentReplay: true } → isResumable is true', () => {
|
|
63
|
+
const transport = edenTransport<AgentPart>(
|
|
64
|
+
async () => ({ data: (async function* () {})(), error: null }),
|
|
65
|
+
async () => ({ data: (async function* () {})(), error: null }),
|
|
66
|
+
{ idempotentReplay: true },
|
|
67
|
+
)
|
|
68
|
+
expect(isResumable(transport)).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
})
|