basalt-ui 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +21 -21
- package/CHANGELOG.md +15 -0
- package/README.md +36 -27
- package/agent/rules/basalt-content.md +16 -3
- package/dist/agent/adapter.d.ts +146 -0
- package/dist/agent/adapter.d.ts.map +1 -0
- package/dist/agent/adapter.js +473 -0
- package/dist/agent/adapter.test.d.ts +2 -0
- package/dist/agent/adapter.test.d.ts.map +1 -0
- package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
- package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
- package/dist/agent/coalesce.d.ts +20 -4
- package/dist/agent/coalesce.d.ts.map +1 -1
- package/dist/agent/coalesce.js +76 -8
- package/dist/agent/coalesce.test.d.ts +2 -0
- package/dist/agent/coalesce.test.d.ts.map +1 -0
- package/dist/agent/foreign.test.d.ts +2 -0
- package/dist/agent/foreign.test.d.ts.map +1 -0
- package/dist/agent/id.d.ts +30 -0
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +16 -0
- package/dist/agent/id.test.d.ts +2 -0
- package/dist/agent/id.test.d.ts.map +1 -0
- package/dist/agent/index.d.ts +2 -0
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +3 -0
- package/dist/agent/merge.d.ts.map +1 -1
- package/dist/agent/merge.js +19 -7
- package/dist/agent/merge.test.d.ts +2 -0
- package/dist/agent/merge.test.d.ts.map +1 -0
- package/dist/agent/part-list.d.ts +49 -34
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +42 -6
- package/dist/agent/part-list.test.d.ts +2 -0
- package/dist/agent/part-list.test.d.ts.map +1 -0
- package/dist/agent/parts.d.ts +46 -10
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +16 -6
- package/dist/agent/parts.test.d.ts +2 -0
- package/dist/agent/parts.test.d.ts.map +1 -0
- package/dist/agent/thread.d.ts +14 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/thread.js +8 -2
- package/dist/agent/thread.test.d.ts +2 -0
- package/dist/agent/thread.test.d.ts.map +1 -0
- package/dist/agent/transport.test.d.ts +2 -0
- package/dist/agent/transport.test.d.ts.map +1 -0
- package/dist/agent/use-agent-stream.test.d.ts +2 -0
- package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
- package/dist/agent-chat/composer.d.ts +130 -8
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +189 -23
- package/dist/agent-chat/composer.test.d.ts +2 -0
- package/dist/agent-chat/composer.test.d.ts.map +1 -0
- package/dist/agent-chat/index.d.ts +1 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +14 -3
- package/dist/agent-chat/thread-message.test.d.ts +2 -0
- package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-workspace.js +1 -1
- package/dist/agent-chat/tool-chip.test.d.ts +2 -0
- package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
- package/dist/charts/utils/domain.test.d.ts +2 -0
- package/dist/charts/utils/domain.test.d.ts.map +1 -0
- package/dist/cli/check-theme.test.d.ts +2 -0
- package/dist/cli/check-theme.test.d.ts.map +1 -0
- package/dist/cli/help.test.d.ts +2 -0
- package/dist/cli/help.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/init-sync.test.d.ts +2 -0
- package/dist/cli/init-sync.test.d.ts.map +1 -0
- package/dist/cli/placement-engine.test.d.ts +2 -0
- package/dist/cli/placement-engine.test.d.ts.map +1 -0
- package/dist/cli/tarball-parity.test.d.ts +2 -0
- package/dist/cli/tarball-parity.test.d.ts.map +1 -0
- package/dist/cli/template-format.test.d.ts +2 -0
- package/dist/cli/template-format.test.d.ts.map +1 -0
- package/dist/cli/template-ledger.test.d.ts +2 -0
- package/dist/cli/template-ledger.test.d.ts.map +1 -0
- package/dist/cli/tokens-css.test.d.ts +2 -0
- package/dist/cli/tokens-css.test.d.ts.map +1 -0
- package/dist/content/article-actions.test.d.ts +2 -0
- package/dist/content/article-actions.test.d.ts.map +1 -0
- package/dist/content/article-model.test.d.ts +2 -0
- package/dist/content/article-model.test.d.ts.map +1 -0
- package/dist/content/block-split.test.d.ts +2 -0
- package/dist/content/block-split.test.d.ts.map +1 -0
- package/dist/content/fence-block.d.ts +37 -10
- package/dist/content/fence-block.d.ts.map +1 -1
- package/dist/content/fence-block.js +80 -14
- package/dist/content/heading-components.d.ts.map +1 -1
- package/dist/content/heading-components.js +7 -4
- package/dist/content/heading-components.test.d.ts +2 -0
- package/dist/content/heading-components.test.d.ts.map +1 -0
- package/dist/content/index.d.ts +6 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +5 -1
- package/dist/content/markdown.d.ts +101 -14
- package/dist/content/markdown.d.ts.map +1 -1
- package/dist/content/markdown.js +141 -28
- package/dist/content/markdown.test.d.ts +2 -0
- package/dist/content/markdown.test.d.ts.map +1 -0
- package/dist/content/prose.module.css.test.d.ts +2 -0
- package/dist/content/prose.module.css.test.d.ts.map +1 -0
- package/dist/content/sanitize.d.ts +170 -0
- package/dist/content/sanitize.d.ts.map +1 -0
- package/dist/content/sanitize.js +178 -0
- package/dist/content/sanitize.test.d.ts +2 -0
- package/dist/content/sanitize.test.d.ts.map +1 -0
- package/dist/content/slug.test.d.ts +2 -0
- package/dist/content/slug.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.test.d.ts +2 -0
- package/dist/dashboard/stat-card.test.d.ts.map +1 -0
- package/dist/guard/check-source.test.d.ts +2 -0
- package/dist/guard/check-source.test.d.ts.map +1 -0
- package/dist/guard/guard-hook.test.d.ts +2 -0
- package/dist/guard/guard-hook.test.d.ts.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/provider/index.test.d.ts +2 -0
- package/dist/provider/index.test.d.ts.map +1 -0
- package/dist/provider/lab-theme.test.d.ts +2 -0
- package/dist/provider/lab-theme.test.d.ts.map +1 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
- package/dist/styles.floor.test.d.ts +2 -0
- package/dist/styles.floor.test.d.ts.map +1 -0
- package/dist/surfaces.d.ts +4 -4
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +12 -3
- package/dist/theme/border-coverage.test.d.ts +18 -0
- package/dist/theme/border-coverage.test.d.ts.map +1 -0
- package/dist/theme/contrast.test.d.ts +2 -0
- package/dist/theme/contrast.test.d.ts.map +1 -0
- package/dist/theme/density-option.test.d.ts +2 -0
- package/dist/theme/density-option.test.d.ts.map +1 -0
- package/dist/theme/density-relations.test.d.ts +2 -0
- package/dist/theme/density-relations.test.d.ts.map +1 -0
- package/dist/theme/derive-theme.test.d.ts +2 -0
- package/dist/theme/derive-theme.test.d.ts.map +1 -0
- package/dist/theme/fonts-option.test.d.ts +2 -0
- package/dist/theme/fonts-option.test.d.ts.map +1 -0
- package/dist/theme/radius-option.test.d.ts +2 -0
- package/dist/theme/radius-option.test.d.ts.map +1 -0
- package/dist/theme/radius.test.d.ts +2 -0
- package/dist/theme/radius.test.d.ts.map +1 -0
- package/dist/theme/shadow-surfaces.test.d.ts +26 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
- package/dist/theme/spacing.test.d.ts +2 -0
- package/dist/theme/spacing.test.d.ts.map +1 -0
- package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
- package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-controls.test.d.ts +2 -0
- package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-state.test.d.ts +2 -0
- package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
- package/dist/theme-lab/theme-lab.test.d.ts +2 -0
- package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
- package/dist/tokens/build-fonts-css.test.d.ts +2 -0
- package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
- package/dist/tokens/build-radius-css.test.d.ts +2 -0
- package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
- package/dist/tokens/density.test.d.ts +2 -0
- package/dist/tokens/density.test.d.ts.map +1 -0
- package/dist/tokens/derive-radius.test.d.ts +2 -0
- package/dist/tokens/derive-radius.test.d.ts.map +1 -0
- package/dist/tokens/derive.test.d.ts +2 -0
- package/dist/tokens/derive.test.d.ts.map +1 -0
- package/dist/tokens/palette.test.d.ts +2 -0
- package/dist/tokens/palette.test.d.ts.map +1 -0
- package/dist/vite-color-mix.test.d.ts +2 -0
- package/dist/vite-color-mix.test.d.ts.map +1 -0
- package/dist/vite.test.d.ts +2 -0
- package/dist/vite.test.d.ts.map +1 -0
- package/llms.txt +6 -6
- package/package.json +6 -1
- package/src/agent/adapter.test.ts +1403 -0
- package/src/agent/adapter.ts +887 -0
- package/src/agent/ai-sdk-transport.test.ts +53 -26
- package/src/agent/coalesce.test.ts +202 -9
- package/src/agent/coalesce.ts +126 -15
- package/src/agent/foreign.test.ts +5 -1
- package/src/agent/id.test.ts +6 -2
- package/src/agent/id.ts +48 -0
- package/src/agent/index.ts +8 -0
- package/src/agent/merge.test.ts +87 -0
- package/src/agent/merge.ts +57 -10
- package/src/agent/part-list.test.tsx +31 -1
- package/src/agent/part-list.tsx +77 -29
- package/src/agent/parts.test.ts +121 -4
- package/src/agent/parts.ts +78 -17
- package/src/agent/thread.test.ts +75 -2
- package/src/agent/thread.ts +21 -1
- package/src/agent/use-agent-stream.test.tsx +9 -5
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
- package/src/agent/use-agent-thread-runs.test.tsx +30 -17
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +13 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +3 -1
- package/src/agent-chat/thread-message.test.tsx +141 -1
- package/src/agent-chat/thread-message.tsx +29 -4
- package/src/agent-chat/thread-workspace.tsx +1 -1
- package/src/cli/index.ts +0 -1
- package/src/cli/template-format.test.ts +2 -2
- package/src/content/fence-block.tsx +242 -15
- package/src/content/heading-components.test.tsx +65 -0
- package/src/content/heading-components.tsx +26 -5
- package/src/content/index.ts +19 -3
- package/src/content/markdown.test.tsx +603 -0
- package/src/content/markdown.tsx +355 -35
- package/src/content/sanitize.test.ts +659 -0
- package/src/content/sanitize.ts +510 -0
- package/src/dashboard/stat-card.test.tsx +6 -1
- package/src/index.ts +13 -1
- package/src/surfaces.ts +12 -3
- package/src/theme/contrast.test.ts +1 -4
- package/src/theme/density-relations.test.ts +4 -2
- package/src/theme/fonts-option.test.ts +7 -1
- package/src/theme/shadow-surfaces.test.ts +12 -6
- package/src/theme/use-basalt-spacing.test.tsx +2 -1
package/src/agent/parts.test.ts
CHANGED
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
* AgentPart wire format, post-1.11.0 part identity + the seven-state ToolCallPart union.
|
|
4
4
|
*
|
|
5
5
|
* Every AgentPart variant below is validated against parts.ts's `parseAgentPart`, `isStartPart`,
|
|
6
|
-
* and `isToolCallSettled` as they read right now: `id` is required on every variant,
|
|
7
|
-
*
|
|
6
|
+
* and `isToolCallSettled` as they read right now: `id` is required on every variant, `toolCallId`
|
|
7
|
+
* is required on the three in-flight tool states and optional on the four settled ones (see the
|
|
8
|
+
* per-state identity block below), and `tool` parts are validated against the seven-state
|
|
9
|
+
* discriminated union mirroring AI SDK v7's
|
|
8
10
|
* `UIToolInvocation` (see parts.ts's ToolCallPart doc for the ground-truth corrections against the
|
|
9
11
|
* design spec).
|
|
10
12
|
*/
|
|
11
13
|
import { describe, expect, test } from 'bun:test'
|
|
12
14
|
import { isStartPart, isToolCallSettled, parseAgentPart } from './parts'
|
|
13
|
-
import type { AgentPart, ToolCallPart } from './parts'
|
|
15
|
+
import type { AgentPart, ToolCallPart, ToolCallState } from './parts'
|
|
14
16
|
|
|
15
17
|
/** JSON round-trip a value through JSON.stringify → JSON.parse, mirroring wire transport. */
|
|
16
18
|
function roundTrip(value: unknown): unknown {
|
|
@@ -109,10 +111,11 @@ describe('parseAgentPart', () => {
|
|
|
109
111
|
})
|
|
110
112
|
|
|
111
113
|
describe('tool', () => {
|
|
112
|
-
test('rejects a
|
|
114
|
+
test('rejects a non-string toolCallId', () => {
|
|
113
115
|
const raw = {
|
|
114
116
|
id: 'p1',
|
|
115
117
|
type: 'tool',
|
|
118
|
+
toolCallId: 42,
|
|
116
119
|
toolName: 'search',
|
|
117
120
|
state: 'input-available',
|
|
118
121
|
input: {},
|
|
@@ -120,6 +123,120 @@ describe('parseAgentPart', () => {
|
|
|
120
123
|
expect(parseAgentPart(raw)).toBeNull()
|
|
121
124
|
})
|
|
122
125
|
|
|
126
|
+
// ── toolCallId identity, decided per state ──────────────────────────────
|
|
127
|
+
// An empty string is not a value any state accepts: '' normalizes to absent right here, so the
|
|
128
|
+
// pre-1.12.0 sentinel can never reach the type or a consumer of it. What an ABSENT id then
|
|
129
|
+
// means is a per-state decision (see Identified / MaybeIdentified in parts.ts):
|
|
130
|
+
// in-flight (input-streaming, input-available, approval-requested) → REJECTED. The part
|
|
131
|
+
// exists only to be closed by a later state, which re-supplies its toolName and input;
|
|
132
|
+
// unidentified it could never be closed and would render as a forever-pending chip.
|
|
133
|
+
// settled (approval-responded + the three terminal states) → KEPT, unidentified.
|
|
134
|
+
// It carries the OUTCOME; dropping it would erase how the call ended (silent data loss),
|
|
135
|
+
// while keeping it costs only the fold — coalesceParts renders it as its own block.
|
|
136
|
+
//
|
|
137
|
+
// The `satisfies Record<ToolCallState, …>` below is an authoring-time exhaustiveness gate: add
|
|
138
|
+
// a state to the union and this fixture stops typechecking until its identity behaviour is
|
|
139
|
+
// decided here too. It is an EDITOR/tsc guard only — the package tsconfig excludes `*.test.ts`
|
|
140
|
+
// from `bun run typecheck`, and `bun test` type-strips rather than typechecks — so the
|
|
141
|
+
// "covers every state exactly once" test below backs it with a runtime check of the same claim.
|
|
142
|
+
|
|
143
|
+
const UNIDENTIFIED_RAW = {
|
|
144
|
+
'input-streaming': { toolName: 'search', state: 'input-streaming' },
|
|
145
|
+
'input-available': { toolName: 'search', state: 'input-available', input: { q: 'x' } },
|
|
146
|
+
'approval-requested': {
|
|
147
|
+
toolName: 'search',
|
|
148
|
+
state: 'approval-requested',
|
|
149
|
+
input: { q: 'x' },
|
|
150
|
+
approval: { id: 'appr-1' },
|
|
151
|
+
},
|
|
152
|
+
'approval-responded': {
|
|
153
|
+
toolName: 'search',
|
|
154
|
+
state: 'approval-responded',
|
|
155
|
+
input: { q: 'x' },
|
|
156
|
+
approval: { id: 'appr-1', approved: true },
|
|
157
|
+
},
|
|
158
|
+
'output-available': {
|
|
159
|
+
toolName: 'search',
|
|
160
|
+
state: 'output-available',
|
|
161
|
+
input: { q: 'x' },
|
|
162
|
+
output: { hits: 3 },
|
|
163
|
+
},
|
|
164
|
+
'output-error': { toolName: 'search', state: 'output-error', errorText: 'boom' },
|
|
165
|
+
'output-denied': {
|
|
166
|
+
toolName: 'search',
|
|
167
|
+
state: 'output-denied',
|
|
168
|
+
input: { q: 'x' },
|
|
169
|
+
approval: { id: 'appr-1', approved: false },
|
|
170
|
+
},
|
|
171
|
+
} satisfies Record<ToolCallState, Record<string, unknown>>
|
|
172
|
+
|
|
173
|
+
const IN_FLIGHT_STATES = [
|
|
174
|
+
'input-streaming',
|
|
175
|
+
'input-available',
|
|
176
|
+
'approval-requested',
|
|
177
|
+
] as const satisfies readonly ToolCallState[]
|
|
178
|
+
|
|
179
|
+
const SETTLED_STATES = [
|
|
180
|
+
'approval-responded',
|
|
181
|
+
'output-available',
|
|
182
|
+
'output-error',
|
|
183
|
+
'output-denied',
|
|
184
|
+
] as const satisfies readonly ToolCallState[]
|
|
185
|
+
|
|
186
|
+
test('the in-flight/settled split covers every tool state exactly once', () => {
|
|
187
|
+
// Object.keys() is always typed string[] regardless of the object's key type — cast back to
|
|
188
|
+
// ToolCallState[], which every key of UNIDENTIFIED_RAW genuinely is (used as
|
|
189
|
+
// UNIDENTIFIED_RAW[state] elsewhere in this file).
|
|
190
|
+
expect([...IN_FLIGHT_STATES, ...SETTLED_STATES].toSorted()).toEqual(
|
|
191
|
+
(Object.keys(UNIDENTIFIED_RAW) as ToolCallState[]).toSorted(),
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
for (const state of IN_FLIGHT_STATES) {
|
|
196
|
+
test(`${state}: rejects a missing toolCallId — an in-flight part that could never be closed`, () => {
|
|
197
|
+
expect(parseAgentPart({ id: 'p1', type: 'tool', ...UNIDENTIFIED_RAW[state] })).toBeNull()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test(`${state}: rejects an empty-string toolCallId identically (it means absent)`, () => {
|
|
201
|
+
const raw = { id: 'p1', type: 'tool', toolCallId: '', ...UNIDENTIFIED_RAW[state] }
|
|
202
|
+
expect(parseAgentPart(raw)).toBeNull()
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const state of SETTLED_STATES) {
|
|
207
|
+
test(`${state}: a missing toolCallId keeps the part UNIDENTIFIED — it is never dropped`, () => {
|
|
208
|
+
const parsed = parseAgentPart({ id: 'p1', type: 'tool', ...UNIDENTIFIED_RAW[state] })
|
|
209
|
+
expect(parsed).not.toBeNull()
|
|
210
|
+
expect((parsed as ToolCallPart).state).toBe(state)
|
|
211
|
+
expect(Object.hasOwn(parsed as object, 'toolCallId')).toBe(false)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test(`${state}: an empty-string toolCallId normalizes to absent, never to ""`, () => {
|
|
215
|
+
const raw = { id: 'p1', type: 'tool', toolCallId: '', ...UNIDENTIFIED_RAW[state] }
|
|
216
|
+
const parsed = parseAgentPart(raw)
|
|
217
|
+
expect(parsed).not.toBeNull()
|
|
218
|
+
expect(Object.hasOwn(parsed as object, 'toolCallId')).toBe(false)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test(`${state}: a real toolCallId still carries through`, () => {
|
|
222
|
+
const raw = { id: 'p1', type: 'tool', toolCallId: 'call-1', ...UNIDENTIFIED_RAW[state] }
|
|
223
|
+
expect((parseAgentPart(raw) as ToolCallPart | null)?.toolCallId).toBe('call-1')
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
test('approval-responded: a real toolCallId round-trips through JSON', () => {
|
|
228
|
+
const part: ToolCallPart = {
|
|
229
|
+
id: 'p1',
|
|
230
|
+
type: 'tool',
|
|
231
|
+
toolCallId: 'call-1',
|
|
232
|
+
toolName: 'delete_file',
|
|
233
|
+
state: 'approval-responded',
|
|
234
|
+
input: { path: '/tmp/x' },
|
|
235
|
+
approval: { id: 'appr-1', approved: true },
|
|
236
|
+
}
|
|
237
|
+
expect(parseAgentPart(roundTrip(part))).toEqual(part)
|
|
238
|
+
})
|
|
239
|
+
|
|
123
240
|
test('rejects a missing toolName', () => {
|
|
124
241
|
const raw = {
|
|
125
242
|
id: 'p1',
|
package/src/agent/parts.ts
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
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
|
|
@@ -78,6 +81,10 @@ export type ReasoningPart = PartBase & {
|
|
|
78
81
|
// discriminator `tool-${NAME}`. It IS a required field here (ours, not the SDK's) because the chip
|
|
79
82
|
// needs it; a transport mapping from the SDK must DERIVE it from the discriminator, not read it off
|
|
80
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.
|
|
81
88
|
|
|
82
89
|
/** The nested approval envelope — mirrors the SDK's shape verbatim, never flattened. */
|
|
83
90
|
type ToolApproval = {
|
|
@@ -90,35 +97,66 @@ type ToolApproval = {
|
|
|
90
97
|
|
|
91
98
|
type ToolCallBase = PartBase & {
|
|
92
99
|
readonly type: 'tool'
|
|
93
|
-
/** Required on all seven states (was optional — see parts.ts history). */
|
|
94
|
-
readonly toolCallId: string
|
|
95
100
|
readonly toolName: string
|
|
96
101
|
/** Wall-clock time from first sighting of this toolCallId to its terminal state. */
|
|
97
102
|
readonly durationMs?: number
|
|
98
103
|
readonly providerExecuted?: boolean
|
|
99
104
|
}
|
|
100
105
|
|
|
106
|
+
/**
|
|
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.
|
|
130
|
+
*
|
|
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.
|
|
136
|
+
*/
|
|
137
|
+
type MaybeIdentified = ToolCallBase & { readonly toolCallId?: string }
|
|
138
|
+
|
|
101
139
|
export type ToolCallPart =
|
|
102
|
-
| (
|
|
103
|
-
| (
|
|
104
|
-
| (
|
|
140
|
+
| (Identified & { readonly state: 'input-streaming'; readonly input?: unknown })
|
|
141
|
+
| (Identified & { readonly state: 'input-available'; readonly input: unknown })
|
|
142
|
+
| (Identified & {
|
|
105
143
|
readonly state: 'approval-requested'
|
|
106
144
|
readonly input: unknown
|
|
107
145
|
readonly approval: ToolApproval & { readonly approved?: never; readonly reason?: never }
|
|
108
146
|
})
|
|
109
|
-
| (
|
|
147
|
+
| (MaybeIdentified & {
|
|
110
148
|
readonly state: 'approval-responded'
|
|
111
149
|
readonly input: unknown
|
|
112
150
|
readonly approval: ToolApproval & { readonly approved: boolean }
|
|
113
151
|
})
|
|
114
|
-
| (
|
|
152
|
+
| (MaybeIdentified & {
|
|
115
153
|
readonly state: 'output-available'
|
|
116
154
|
readonly input: unknown
|
|
117
155
|
readonly output: unknown
|
|
118
156
|
readonly preliminary?: boolean
|
|
119
157
|
readonly approval?: ToolApproval & { readonly approved?: true }
|
|
120
158
|
})
|
|
121
|
-
| (
|
|
159
|
+
| (MaybeIdentified & {
|
|
122
160
|
readonly state: 'output-error'
|
|
123
161
|
readonly input?: unknown
|
|
124
162
|
/** The SDK's field name — there is no field named `error` anywhere in the union. */
|
|
@@ -127,7 +165,7 @@ export type ToolCallPart =
|
|
|
127
165
|
readonly rawInput?: unknown
|
|
128
166
|
readonly approval?: ToolApproval & { readonly approved?: true }
|
|
129
167
|
})
|
|
130
|
-
| (
|
|
168
|
+
| (MaybeIdentified & {
|
|
131
169
|
readonly state: 'output-denied'
|
|
132
170
|
readonly input: unknown
|
|
133
171
|
readonly approval: ToolApproval & { readonly approved: false }
|
|
@@ -236,35 +274,53 @@ export function parseAgentPart(raw: unknown): AgentPart | null {
|
|
|
236
274
|
}
|
|
237
275
|
|
|
238
276
|
/** Validates the state-discriminated `tool` part shape. Rejects the pre-1.11.0 flat shape
|
|
239
|
-
* (`{type:'tool', output}` with no `state`) and any unrecognized `state` value.
|
|
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. */
|
|
240
286
|
function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPart | null {
|
|
241
|
-
if (typeof obj['toolCallId'] !== 'string') return null
|
|
242
287
|
if (typeof obj['toolName'] !== 'string') return null
|
|
243
288
|
if (typeof obj['state'] !== 'string') return null
|
|
244
289
|
|
|
245
|
-
const
|
|
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']
|
|
246
296
|
const common = {
|
|
247
297
|
id,
|
|
248
298
|
type: 'tool' as const,
|
|
249
|
-
toolCallId,
|
|
250
299
|
toolName,
|
|
251
300
|
...(typeof obj['durationMs'] === 'number' ? { durationMs: obj['durationMs'] } : {}),
|
|
252
301
|
...(typeof obj['providerExecuted'] === 'boolean'
|
|
253
302
|
? { providerExecuted: obj['providerExecuted'] }
|
|
254
303
|
: {}),
|
|
255
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 } : {}
|
|
256
309
|
|
|
257
310
|
switch (obj['state']) {
|
|
258
311
|
case 'input-streaming':
|
|
312
|
+
if (identified === null) return null
|
|
259
313
|
return {
|
|
260
|
-
...
|
|
314
|
+
...identified,
|
|
261
315
|
state: 'input-streaming',
|
|
262
316
|
...('input' in obj ? { input: obj['input'] } : {}),
|
|
263
317
|
}
|
|
264
318
|
case 'input-available':
|
|
319
|
+
if (identified === null) return null
|
|
265
320
|
if (!('input' in obj)) return null
|
|
266
|
-
return { ...
|
|
321
|
+
return { ...identified, state: 'input-available', input: obj['input'] }
|
|
267
322
|
case 'approval-requested': {
|
|
323
|
+
if (identified === null) return null
|
|
268
324
|
if (!('input' in obj)) return null
|
|
269
325
|
const approval = parseApproval(obj['approval'])
|
|
270
326
|
// The type declares this state's approval as `{ approved?: never; reason?: never }` — a
|
|
@@ -274,7 +330,7 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
|
|
|
274
330
|
return null
|
|
275
331
|
}
|
|
276
332
|
return {
|
|
277
|
-
...
|
|
333
|
+
...identified,
|
|
278
334
|
state: 'approval-requested',
|
|
279
335
|
input: obj['input'],
|
|
280
336
|
approval: approval as ToolApproval & { approved?: never; reason?: never },
|
|
@@ -284,8 +340,10 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
|
|
|
284
340
|
if (!('input' in obj)) return null
|
|
285
341
|
const approval = parseApproval(obj['approval'])
|
|
286
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.
|
|
287
344
|
return {
|
|
288
345
|
...common,
|
|
346
|
+
...identity,
|
|
289
347
|
state: 'approval-responded',
|
|
290
348
|
input: obj['input'],
|
|
291
349
|
approval: approval as ToolApproval & { approved: boolean },
|
|
@@ -297,6 +355,7 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
|
|
|
297
355
|
if (approval === null || approval.approved !== false) return null
|
|
298
356
|
return {
|
|
299
357
|
...common,
|
|
358
|
+
...identity,
|
|
300
359
|
state: 'output-denied',
|
|
301
360
|
input: obj['input'],
|
|
302
361
|
approval: approval as ToolApproval & { approved: false },
|
|
@@ -315,6 +374,7 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
|
|
|
315
374
|
}
|
|
316
375
|
return {
|
|
317
376
|
...common,
|
|
377
|
+
...identity,
|
|
318
378
|
state: 'output-available',
|
|
319
379
|
input: obj['input'],
|
|
320
380
|
output: obj['output'],
|
|
@@ -332,6 +392,7 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
|
|
|
332
392
|
}
|
|
333
393
|
return {
|
|
334
394
|
...common,
|
|
395
|
+
...identity,
|
|
335
396
|
state: 'output-error',
|
|
336
397
|
errorText: obj['errorText'],
|
|
337
398
|
...('input' in obj ? { input: obj['input'] } : {}),
|
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
|
}
|
|
@@ -11,7 +11,7 @@ import { StrictMode, useEffect } from 'react'
|
|
|
11
11
|
import { act, render, renderHook, waitFor } from '@testing-library/react'
|
|
12
12
|
import { useAgentStream } from './use-agent-stream'
|
|
13
13
|
import type { AgentTransport } from './transport'
|
|
14
|
-
import type { AgentPart } from './parts'
|
|
14
|
+
import type { AgentPart, AgentPartDraft } from './parts'
|
|
15
15
|
|
|
16
16
|
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
|
17
17
|
let resolve!: (value: T) => void
|
|
@@ -96,17 +96,21 @@ describe('useAgentStream', () => {
|
|
|
96
96
|
})
|
|
97
97
|
|
|
98
98
|
test('F1 regression: id-less drafts of different types within one send() do not destroy each other', async () => {
|
|
99
|
+
// Yields id-less drafts on purpose — the whole point of this regression is that TWO drafts
|
|
100
|
+
// (of different types, so a real bug wouldn't be "same id" but "no id at all") must not
|
|
101
|
+
// collide via `undefined === undefined`. The cast documents that the type says AgentPart
|
|
102
|
+
// (identified) while a transport is always permitted to omit id (AgentPartDraft).
|
|
99
103
|
const transport: AgentTransport<AgentPart, string> = {
|
|
100
104
|
async *stream() {
|
|
101
|
-
yield { type: 'text', text: 'Hi' }
|
|
105
|
+
yield { type: 'text', text: 'Hi' } as AgentPartDraft as AgentPart
|
|
102
106
|
yield {
|
|
103
107
|
type: 'tool',
|
|
104
108
|
toolCallId: 'c1',
|
|
105
109
|
toolName: 'search',
|
|
106
110
|
state: 'input-available',
|
|
107
111
|
input: { q: 'x' },
|
|
108
|
-
}
|
|
109
|
-
yield { type: 'text', text: 'Bye' }
|
|
112
|
+
} as AgentPartDraft as AgentPart
|
|
113
|
+
yield { type: 'text', text: 'Bye' } as AgentPartDraft as AgentPart
|
|
110
114
|
},
|
|
111
115
|
}
|
|
112
116
|
|
|
@@ -131,7 +135,7 @@ describe('useAgentStream', () => {
|
|
|
131
135
|
async *stream() {
|
|
132
136
|
throw boom
|
|
133
137
|
// eslint-disable-next-line no-unreachable
|
|
134
|
-
yield { type: 'text', text: 'unreachable' }
|
|
138
|
+
yield { id: 'unreachable', type: 'text', text: 'unreachable' }
|
|
135
139
|
},
|
|
136
140
|
}
|
|
137
141
|
|
|
@@ -86,6 +86,11 @@ function createTestThreadsStore(): ThreadsStore<TranscriptPart> {
|
|
|
86
86
|
threads = []
|
|
87
87
|
activeId = null
|
|
88
88
|
},
|
|
89
|
+
// Always-hydrated, never-erroring — mirrors the localStorage-backed store's real values
|
|
90
|
+
// (see ThreadsStore.hydrated/.error doc comments); this double is a synchronous in-memory
|
|
91
|
+
// stand-in with no async load path to fail.
|
|
92
|
+
hydrated: true,
|
|
93
|
+
error: undefined,
|
|
89
94
|
}
|
|
90
95
|
}
|
|
91
96
|
|
|
@@ -16,7 +16,7 @@ import { describe, expect, test } from 'bun:test'
|
|
|
16
16
|
import { renderHook, waitFor } from '@testing-library/react'
|
|
17
17
|
import { useAgentThreadRuns } from './use-agent-thread-runs'
|
|
18
18
|
import type { AgentThread, ThreadsStore } from './thread'
|
|
19
|
-
import type { AgentTransport } from './transport'
|
|
19
|
+
import type { AgentTransport, ResumableAgentTransport } from './transport'
|
|
20
20
|
import type { AgentPart } from './parts'
|
|
21
21
|
import type { ChatMessage } from './history'
|
|
22
22
|
import type { AgentOutcome } from './outcome'
|
|
@@ -90,6 +90,11 @@ function createTestThreadsStore(initial: AgentThread<AgentPart>[]): ThreadsStore
|
|
|
90
90
|
threads = []
|
|
91
91
|
activeId = null
|
|
92
92
|
},
|
|
93
|
+
// Always-hydrated, never-erroring — mirrors the localStorage-backed store's real values
|
|
94
|
+
// (see ThreadsStore.hydrated/.error doc comments); this double is a synchronous in-memory
|
|
95
|
+
// stand-in with no async load path to fail.
|
|
96
|
+
hydrated: true,
|
|
97
|
+
error: undefined,
|
|
93
98
|
}
|
|
94
99
|
}
|
|
95
100
|
|
|
@@ -100,7 +105,7 @@ function makeStreamingThread(
|
|
|
100
105
|
const userMessage: ChatMessage<AgentPart> = {
|
|
101
106
|
id: crypto.randomUUID(),
|
|
102
107
|
role: 'user',
|
|
103
|
-
parts: [{ type: 'text', text: 'hello' }],
|
|
108
|
+
parts: [{ id: 'seed-user-part', type: 'text', text: 'hello' }],
|
|
104
109
|
createdAt: now,
|
|
105
110
|
}
|
|
106
111
|
return {
|
|
@@ -142,7 +147,7 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
142
147
|
const userMessage: ChatMessage<AgentPart> = {
|
|
143
148
|
id: crypto.randomUUID(),
|
|
144
149
|
role: 'user',
|
|
145
|
-
parts: [{ type: 'text', text: 'hello' }],
|
|
150
|
+
parts: [{ id: 'seed-user-part', type: 'text', text: 'hello' }],
|
|
146
151
|
createdAt: now,
|
|
147
152
|
}
|
|
148
153
|
const thread: AgentThread<AgentPart> = {
|
|
@@ -157,11 +162,11 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
157
162
|
const store = createTestThreadsStore([thread])
|
|
158
163
|
|
|
159
164
|
let resumeCalls = 0
|
|
160
|
-
const transport = {
|
|
165
|
+
const transport: ResumableAgentTransport<AgentPart, string> = {
|
|
161
166
|
async *stream() {},
|
|
162
167
|
async *resume() {
|
|
163
168
|
resumeCalls++
|
|
164
|
-
yield { type: 'text', text: 'resumed' }
|
|
169
|
+
yield { id: 'p1', type: 'text', text: 'resumed' }
|
|
165
170
|
},
|
|
166
171
|
idempotentReplay: true as const,
|
|
167
172
|
}
|
|
@@ -179,11 +184,11 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
179
184
|
const store = createTestThreadsStore([thread])
|
|
180
185
|
|
|
181
186
|
let resumeCalls = 0
|
|
182
|
-
const transport = {
|
|
187
|
+
const transport: ResumableAgentTransport<AgentPart, string> = {
|
|
183
188
|
async *stream() {},
|
|
184
189
|
async *resume() {
|
|
185
190
|
resumeCalls++
|
|
186
|
-
yield { type: 'text', text: 'resumed' }
|
|
191
|
+
yield { id: 'p1', type: 'text', text: 'resumed' }
|
|
187
192
|
},
|
|
188
193
|
idempotentReplay: true as const,
|
|
189
194
|
}
|
|
@@ -205,7 +210,7 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
205
210
|
async *stream() {},
|
|
206
211
|
async *resume() {
|
|
207
212
|
resumeCalls++
|
|
208
|
-
yield { type: 'text', text: 'resumed' }
|
|
213
|
+
yield { id: 'p1', type: 'text', text: 'resumed' }
|
|
209
214
|
},
|
|
210
215
|
// deliberately NO idempotentReplay: true
|
|
211
216
|
}
|
|
@@ -223,11 +228,11 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
223
228
|
const store = createTestThreadsStore([thread])
|
|
224
229
|
|
|
225
230
|
const resumeArgs: string[] = []
|
|
226
|
-
const transport = {
|
|
231
|
+
const transport: ResumableAgentTransport<AgentPart, string> = {
|
|
227
232
|
async *stream() {},
|
|
228
|
-
async *resume(token
|
|
233
|
+
async *resume(token) {
|
|
229
234
|
resumeArgs.push(token)
|
|
230
|
-
yield { type: 'text', text: 'resumed' }
|
|
235
|
+
yield { id: 'p1', type: 'text', text: 'resumed' }
|
|
231
236
|
},
|
|
232
237
|
idempotentReplay: true as const,
|
|
233
238
|
}
|