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/transport.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* api.chat.post({ body: { message: input }, fetch: { signal } }),
|
|
30
30
|
* )
|
|
31
31
|
*/
|
|
32
|
-
import type { AgentPart } from './parts'
|
|
32
|
+
import type { AgentPart, AgentPartDraft } from './parts'
|
|
33
33
|
|
|
34
34
|
// ── AgentTransport ────────────────────────────────────────────────────────────
|
|
35
35
|
|
|
@@ -37,6 +37,11 @@ import type { AgentPart } from './parts'
|
|
|
37
37
|
* The injected transport seam. `stream(input, signal)` returns an async generator of parts.
|
|
38
38
|
* Implement this interface for any backend — Eden, fetch-based SSE, AI SDK, or a mock.
|
|
39
39
|
*
|
|
40
|
+
* The default `TPart` is `AgentPartDraft` (not `AgentPart`): transports yield DRAFTS — `id`
|
|
41
|
+
* optional — and the consuming hooks (or `withPartIds`) normalize a draft into a fully-identified
|
|
42
|
+
* part before it is ever accumulated. Pass an explicit `AgentPart` generic when a transport mints
|
|
43
|
+
* its own ids directly (as `aiSdkTransport` does).
|
|
44
|
+
*
|
|
40
45
|
* @example
|
|
41
46
|
* // Minimal mock transport for tests / playground:
|
|
42
47
|
* const mockTransport: AgentTransport = {
|
|
@@ -45,12 +50,48 @@ import type { AgentPart } from './parts'
|
|
|
45
50
|
* },
|
|
46
51
|
* }
|
|
47
52
|
*/
|
|
48
|
-
export type AgentTransport<TPart =
|
|
53
|
+
export type AgentTransport<TPart = AgentPartDraft, TInput = string> = {
|
|
49
54
|
stream: (input: TInput, signal?: AbortSignal) => AsyncGenerator<TPart>
|
|
50
55
|
/** Optional: resume a previously-started run from its resumeToken (see StartPart). Transports that don't support resumption simply omit this. */
|
|
51
56
|
resume?: (resumeToken: string, signal?: AbortSignal) => AsyncGenerator<TPart>
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
// ── ResumableAgentTransport ───────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A transport whose replay is safe to run over an already-rendered turn. Implementing `resume`
|
|
63
|
+
* alone is no longer enough: `idempotentReplay: true` is a literal-typed assertion that every
|
|
64
|
+
* text/reasoning part this transport emits carries an authoritative `offset`, so mergePart
|
|
65
|
+
* rewrites instead of appending.
|
|
66
|
+
*/
|
|
67
|
+
export type ResumableAgentTransport<TPart = AgentPartDraft, TInput = string> = AgentTransport<
|
|
68
|
+
TPart,
|
|
69
|
+
TInput
|
|
70
|
+
> & {
|
|
71
|
+
readonly resume: (resumeToken: string, signal?: AbortSignal) => AsyncGenerator<TPart>
|
|
72
|
+
readonly idempotentReplay: true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Type guard: true when `t` both implements `resume` AND has explicitly asserted
|
|
77
|
+
* `idempotentReplay: true`. A transport with `resume` but no `idempotentReplay` is NOT resumable
|
|
78
|
+
* — the mount-time reconcile in `useAgentThreadRuns` falls back to `'interrupted'` for it rather
|
|
79
|
+
* than risk duplicating already-rendered content.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* if (isResumable(transport)) {
|
|
83
|
+
* for await (const part of transport.resume(resumeToken)) { ... }
|
|
84
|
+
* }
|
|
85
|
+
*/
|
|
86
|
+
export function isResumable<TPart, TInput>(
|
|
87
|
+
t: AgentTransport<TPart, TInput>,
|
|
88
|
+
): t is ResumableAgentTransport<TPart, TInput> {
|
|
89
|
+
return (
|
|
90
|
+
typeof t.resume === 'function' &&
|
|
91
|
+
(t as Partial<ResumableAgentTransport<TPart, TInput>>).idempotentReplay === true
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
54
95
|
// ── edenTransport ─────────────────────────────────────────────────────────────
|
|
55
96
|
|
|
56
97
|
/**
|
|
@@ -70,6 +111,12 @@ export type AgentTransport<TPart = AgentPart, TInput = string> = {
|
|
|
70
111
|
* object has NO `resume` key at all (not `resume: undefined`) — consumers that don't wire a
|
|
71
112
|
* resumable backend get a transport that behaves exactly as before.
|
|
72
113
|
*
|
|
114
|
+
* `resumeCall` alone does NOT make the transport resumable per `isResumable` — pass
|
|
115
|
+
* `{ idempotentReplay: true }` as the third argument to opt in EXPLICITLY once the server-side
|
|
116
|
+
* `resumeCall` route is verified to re-emit every text/reasoning delta with an authoritative
|
|
117
|
+
* `offset` (never a bare append). This is an opt-in, not an inference: a `resumeCall` whose deltas
|
|
118
|
+
* aren't offset-accurate would double-render on replay if treated as resumable.
|
|
119
|
+
*
|
|
73
120
|
* @example
|
|
74
121
|
* import { edenTransport, type AgentPart } from 'basalt-ui/agent'
|
|
75
122
|
*
|
|
@@ -78,6 +125,7 @@ export type AgentTransport<TPart = AgentPart, TInput = string> = {
|
|
|
78
125
|
* const transport = edenTransport<AgentPart>(
|
|
79
126
|
* (input, signal) => api.chat.post({ body: { message: input }, fetch: { signal } }),
|
|
80
127
|
* (resumeToken, signal) => api.chat.resume.post({ body: { resumeToken }, fetch: { signal } }),
|
|
128
|
+
* { idempotentReplay: true },
|
|
81
129
|
* )
|
|
82
130
|
*
|
|
83
131
|
* // Wire into useAgentStream:
|
|
@@ -92,23 +140,27 @@ export function edenTransport<TPart = AgentPart, TInput = string>(
|
|
|
92
140
|
resumeToken: string,
|
|
93
141
|
signal?: AbortSignal,
|
|
94
142
|
) => Promise<{ data: AsyncGenerator<TPart> | null; error: unknown }>,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
143
|
+
options?: { readonly idempotentReplay?: true },
|
|
144
|
+
): AgentTransport<TPart, TInput> | ResumableAgentTransport<TPart, TInput> {
|
|
145
|
+
const stream = async function* (input: TInput, signal?: AbortSignal): AsyncGenerator<TPart> {
|
|
146
|
+
const { data, error } = await call(input, signal)
|
|
147
|
+
if (error) throw error
|
|
148
|
+
if (data === null) return
|
|
149
|
+
yield* data
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (resumeCall === undefined) return { stream }
|
|
153
|
+
|
|
154
|
+
const resume = async function* (
|
|
155
|
+
resumeToken: string,
|
|
156
|
+
signal?: AbortSignal,
|
|
157
|
+
): AsyncGenerator<TPart> {
|
|
158
|
+
const { data, error } = await resumeCall(resumeToken, signal)
|
|
159
|
+
if (error) throw error
|
|
160
|
+
if (data === null) return
|
|
161
|
+
yield* data
|
|
113
162
|
}
|
|
163
|
+
|
|
164
|
+
if (options?.idempotentReplay !== true) return { stream, resume }
|
|
165
|
+
return { stream, resume, idempotentReplay: true }
|
|
114
166
|
}
|
|
@@ -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
|
|
@@ -26,10 +26,10 @@ describe('useAgentStream', () => {
|
|
|
26
26
|
const gate = deferred<void>()
|
|
27
27
|
const transport: AgentTransport<AgentPart, string> = {
|
|
28
28
|
async *stream() {
|
|
29
|
-
yield { type: 'text', text: 'a' }
|
|
29
|
+
yield { id: 'p1', type: 'text', text: 'a' }
|
|
30
30
|
await gate.promise
|
|
31
31
|
// Must never be observed — the consumer's guard should discard this once aborted.
|
|
32
|
-
yield { type: 'text', text: 'b' }
|
|
32
|
+
yield { id: 'p2', type: 'text', text: 'b' }
|
|
33
33
|
},
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -39,7 +39,7 @@ describe('useAgentStream', () => {
|
|
|
39
39
|
void result.current.send('hi')
|
|
40
40
|
})
|
|
41
41
|
await waitFor(() => {
|
|
42
|
-
expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
|
|
42
|
+
expect(result.current.parts).toEqual([{ id: 'p1', type: 'text', text: 'a' }])
|
|
43
43
|
})
|
|
44
44
|
expect(result.current.status).toBe('streaming')
|
|
45
45
|
|
|
@@ -53,7 +53,7 @@ describe('useAgentStream', () => {
|
|
|
53
53
|
await waitFor(() => {
|
|
54
54
|
expect(result.current.status).toBe('done')
|
|
55
55
|
})
|
|
56
|
-
expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
|
|
56
|
+
expect(result.current.parts).toEqual([{ id: 'p1', type: 'text', text: 'a' }])
|
|
57
57
|
})
|
|
58
58
|
|
|
59
59
|
test("a second send() supersedes an in-flight one — the first stream's later parts are discarded", async () => {
|
|
@@ -61,12 +61,12 @@ describe('useAgentStream', () => {
|
|
|
61
61
|
const transport: AgentTransport<AgentPart, string> = {
|
|
62
62
|
async *stream(input) {
|
|
63
63
|
if (input === 'first') {
|
|
64
|
-
yield { type: 'text', text: 'first-1' }
|
|
64
|
+
yield { id: 'f1', type: 'text', text: 'first-1' }
|
|
65
65
|
await gateFirst.promise
|
|
66
66
|
// Must never be observed — 'second' has already superseded this controller.
|
|
67
|
-
yield { type: 'text', text: 'first-2' }
|
|
67
|
+
yield { id: 'f2', type: 'text', text: 'first-2' }
|
|
68
68
|
} else {
|
|
69
|
-
yield { type: 'text', text: 'second-1' }
|
|
69
|
+
yield { id: 's1', type: 'text', text: 'second-1' }
|
|
70
70
|
}
|
|
71
71
|
},
|
|
72
72
|
}
|
|
@@ -77,14 +77,14 @@ describe('useAgentStream', () => {
|
|
|
77
77
|
void result.current.send('first')
|
|
78
78
|
})
|
|
79
79
|
await waitFor(() => {
|
|
80
|
-
expect(result.current.parts).toEqual([{ type: 'text', text: 'first-1' }])
|
|
80
|
+
expect(result.current.parts).toEqual([{ id: 'f1', type: 'text', text: 'first-1' }])
|
|
81
81
|
})
|
|
82
82
|
|
|
83
83
|
act(() => {
|
|
84
84
|
void result.current.send('second')
|
|
85
85
|
})
|
|
86
86
|
await waitFor(() => {
|
|
87
|
-
expect(result.current.parts).toEqual([{ type: 'text', text: 'second-1' }])
|
|
87
|
+
expect(result.current.parts).toEqual([{ id: 's1', type: 'text', text: 'second-1' }])
|
|
88
88
|
})
|
|
89
89
|
expect(result.current.status).toBe('done')
|
|
90
90
|
|
|
@@ -92,7 +92,41 @@ describe('useAgentStream', () => {
|
|
|
92
92
|
await waitFor(() => {
|
|
93
93
|
expect(result.current.status).toBe('done')
|
|
94
94
|
})
|
|
95
|
-
expect(result.current.parts).toEqual([{ type: 'text', text: 'second-1' }])
|
|
95
|
+
expect(result.current.parts).toEqual([{ id: 's1', type: 'text', text: 'second-1' }])
|
|
96
|
+
})
|
|
97
|
+
|
|
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).
|
|
103
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
104
|
+
async *stream() {
|
|
105
|
+
yield { type: 'text', text: 'Hi' } as AgentPartDraft as AgentPart
|
|
106
|
+
yield {
|
|
107
|
+
type: 'tool',
|
|
108
|
+
toolCallId: 'c1',
|
|
109
|
+
toolName: 'search',
|
|
110
|
+
state: 'input-available',
|
|
111
|
+
input: { q: 'x' },
|
|
112
|
+
} as AgentPartDraft as AgentPart
|
|
113
|
+
yield { type: 'text', text: 'Bye' } as AgentPartDraft as AgentPart
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { result } = renderHook(() => useAgentStream({ transport }))
|
|
118
|
+
|
|
119
|
+
await act(async () => {
|
|
120
|
+
await result.current.send('hi')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// Pre-fix: mergePart matched all three drafts on `undefined === undefined`, so text→tool→text
|
|
124
|
+
// collapsed to a single (wrong-typed) entry instead of three. withPartIds stamping a distinct
|
|
125
|
+
// id per draft is what keeps them apart.
|
|
126
|
+
expect(result.current.parts).toHaveLength(3)
|
|
127
|
+
expect(result.current.parts.map((part) => part.type)).toEqual(['text', 'tool', 'text'])
|
|
128
|
+
const ids = result.current.parts.map((part) => (part as { id: string }).id)
|
|
129
|
+
expect(new Set(ids).size).toBe(3)
|
|
96
130
|
})
|
|
97
131
|
|
|
98
132
|
test('a thrown (non-abort) error sets status "error" and captures the error', async () => {
|
|
@@ -101,7 +135,7 @@ describe('useAgentStream', () => {
|
|
|
101
135
|
async *stream() {
|
|
102
136
|
throw boom
|
|
103
137
|
// eslint-disable-next-line no-unreachable
|
|
104
|
-
yield { type: 'text', text: 'unreachable' }
|
|
138
|
+
yield { id: 'unreachable', type: 'text', text: 'unreachable' }
|
|
105
139
|
},
|
|
106
140
|
}
|
|
107
141
|
|
|
@@ -119,10 +153,10 @@ describe('useAgentStream', () => {
|
|
|
119
153
|
const gate = deferred<void>()
|
|
120
154
|
const transport: AgentTransport<AgentPart, string> = {
|
|
121
155
|
async *stream(_input, signal) {
|
|
122
|
-
yield { type: 'text', text: 'a' }
|
|
156
|
+
yield { id: 'p1', type: 'text', text: 'a' }
|
|
123
157
|
await gate.promise
|
|
124
158
|
if (signal?.aborted) throw new DOMException('The operation was aborted.', 'AbortError')
|
|
125
|
-
yield { type: 'text', text: 'b' }
|
|
159
|
+
yield { id: 'p2', type: 'text', text: 'b' }
|
|
126
160
|
},
|
|
127
161
|
}
|
|
128
162
|
|
|
@@ -132,7 +166,7 @@ describe('useAgentStream', () => {
|
|
|
132
166
|
void result.current.send('hi')
|
|
133
167
|
})
|
|
134
168
|
await waitFor(() => {
|
|
135
|
-
expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
|
|
169
|
+
expect(result.current.parts).toEqual([{ id: 'p1', type: 'text', text: 'a' }])
|
|
136
170
|
})
|
|
137
171
|
|
|
138
172
|
// stop() aborts the signal AND sets 'done' synchronously.
|
|
@@ -155,7 +189,7 @@ describe('useAgentStream', () => {
|
|
|
155
189
|
const gate = deferred<void>()
|
|
156
190
|
const transport: AgentTransport<AgentPart, string> = {
|
|
157
191
|
async *stream(_input, signal) {
|
|
158
|
-
yield { type: 'text', text: 'a' }
|
|
192
|
+
yield { id: 'p1', type: 'text', text: 'a' }
|
|
159
193
|
await gate.promise
|
|
160
194
|
observedAbortedOnResume = signal?.aborted ?? false
|
|
161
195
|
},
|
|
@@ -167,7 +201,7 @@ describe('useAgentStream', () => {
|
|
|
167
201
|
void result.current.send('hi')
|
|
168
202
|
})
|
|
169
203
|
await waitFor(() => {
|
|
170
|
-
expect(result.current.parts).toEqual([{ type: 'text', text: 'a' }])
|
|
204
|
+
expect(result.current.parts).toEqual([{ id: 'p1', type: 'text', text: 'a' }])
|
|
171
205
|
})
|
|
172
206
|
|
|
173
207
|
unmount()
|
|
@@ -197,7 +231,7 @@ describe('useAgentStream', () => {
|
|
|
197
231
|
const transport: AgentTransport<AgentPart, string> = {
|
|
198
232
|
async *stream() {
|
|
199
233
|
streamCalls++
|
|
200
|
-
yield { type: 'text', text: `call-${streamCalls}` }
|
|
234
|
+
yield { id: `call-${streamCalls}`, type: 'text', text: `call-${streamCalls}` }
|
|
201
235
|
},
|
|
202
236
|
}
|
|
203
237
|
|
|
@@ -226,6 +260,6 @@ describe('useAgentStream', () => {
|
|
|
226
260
|
|
|
227
261
|
expect(streamCalls).toBe(2)
|
|
228
262
|
// Only the SECOND (surviving) call's part made it into final state — the first is discarded.
|
|
229
|
-
expect(capturedParts).toEqual([{ type: 'text', text: 'call-2' }])
|
|
263
|
+
expect(capturedParts).toEqual([{ id: 'call-2', type: 'text', text: 'call-2' }])
|
|
230
264
|
})
|
|
231
265
|
})
|
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
* }
|
|
28
28
|
*/
|
|
29
29
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
30
|
+
import { withPartIds } from './id'
|
|
30
31
|
import { isStartPart } from './parts'
|
|
32
|
+
import { mergePart } from './merge'
|
|
33
|
+
import type { PartLike } from './merge'
|
|
31
34
|
import type { AgentPart } from './parts'
|
|
32
35
|
import type { AgentTransport } from './transport'
|
|
33
36
|
|
|
@@ -67,7 +70,7 @@ export type UseAgentStreamReturn<TPart, TInput> = {
|
|
|
67
70
|
* // parts accumulates as the stream arrives
|
|
68
71
|
* // status: 'idle' → 'streaming' → 'done' (or 'error')
|
|
69
72
|
*/
|
|
70
|
-
export function useAgentStream<TPart = AgentPart, TInput = string>({
|
|
73
|
+
export function useAgentStream<TPart extends PartLike = AgentPart, TInput = string>({
|
|
71
74
|
transport,
|
|
72
75
|
}: {
|
|
73
76
|
transport: AgentTransport<TPart, TInput>
|
|
@@ -100,15 +103,22 @@ export function useAgentStream<TPart = AgentPart, TInput = string>({
|
|
|
100
103
|
setError(undefined)
|
|
101
104
|
setStatus('streaming')
|
|
102
105
|
|
|
106
|
+
// Per-send namespace: this hook has no thread/session id to key off, and each send() call
|
|
107
|
+
// fully resets `parts` anyway, so a fresh id per call is enough to keep two id-less drafts
|
|
108
|
+
// ARRIVING WITHIN THE SAME SEND from colliding on `undefined === undefined` in mergePart (the
|
|
109
|
+
// F1 bug — text→tool→text destroying each other). Not a replay mechanism — this hook has no
|
|
110
|
+
// resume() path — see withPartIds' own doc for why it can't be one anyway.
|
|
111
|
+
const runId = crypto.randomUUID()
|
|
112
|
+
|
|
103
113
|
try {
|
|
104
|
-
for await (const part of transport.stream(input, controller.signal)) {
|
|
114
|
+
for await (const part of withPartIds(runId, transport.stream(input, controller.signal))) {
|
|
105
115
|
// Guard: if a newer send() has superseded this stream, stop updating state.
|
|
106
116
|
if (controllerRef.current !== controller) return
|
|
107
117
|
if (controller.signal.aborted) break
|
|
108
118
|
// StartPart is a no-op signal (run id + resume token), not renderable content — this
|
|
109
119
|
// hook has no persistence layer to resume across, so just skip it.
|
|
110
120
|
if (isStartPart(part)) continue
|
|
111
|
-
setParts((prev) =>
|
|
121
|
+
setParts((prev) => mergePart(prev, part))
|
|
112
122
|
}
|
|
113
123
|
// Only mark done if this stream is still the current one and wasn't aborted.
|
|
114
124
|
if (controllerRef.current === controller && !controller.signal.aborted) setStatus('done')
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAgentThreadRuns — foreign-part round trip (B2 convergence fix).
|
|
3
|
+
*
|
|
4
|
+
* The open part registry (./foreign) only serves its headline purpose — a transport streaming an
|
|
5
|
+
* app-specific part type (`data-toolProgress`, `data-chart`, …) reaching `ThreadTranscript` — if a
|
|
6
|
+
* ForeignPart can flow all the way through this hook's mergePart accumulator: transport →
|
|
7
|
+
* consumeAndFinalize → runs.get(id).parts → the persisted assistant ChatMessage. This file proves
|
|
8
|
+
* that path end to end with `useAgentThreadRuns<TranscriptPart>`, which was a tsc error before
|
|
9
|
+
* mergePart's constraint was widened from `AgentPart` to the structural `PartLike`.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from 'bun:test'
|
|
12
|
+
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
13
|
+
import { useAgentThreadRuns } from './use-agent-thread-runs'
|
|
14
|
+
import type { AgentThread, ThreadsStore } from './thread'
|
|
15
|
+
import type { AgentTransport } from './transport'
|
|
16
|
+
import type { TranscriptPart } from './foreign'
|
|
17
|
+
import type { AgentOutcome, OutcomeResolver } from './outcome'
|
|
18
|
+
|
|
19
|
+
// ── test-only ThreadsStore double, typed over TranscriptPart ─────────────────────────────────────
|
|
20
|
+
function createTestThreadsStore(): ThreadsStore<TranscriptPart> {
|
|
21
|
+
let threads: AgentThread<TranscriptPart>[] = []
|
|
22
|
+
let activeId: string | null = null
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
get threads() {
|
|
26
|
+
return threads
|
|
27
|
+
},
|
|
28
|
+
get activeId() {
|
|
29
|
+
return activeId
|
|
30
|
+
},
|
|
31
|
+
select(id) {
|
|
32
|
+
activeId = id
|
|
33
|
+
},
|
|
34
|
+
create(createOpts) {
|
|
35
|
+
const id = crypto.randomUUID()
|
|
36
|
+
const now = Date.now()
|
|
37
|
+
const thread: AgentThread<TranscriptPart> = {
|
|
38
|
+
id,
|
|
39
|
+
messages: [],
|
|
40
|
+
outcome: null,
|
|
41
|
+
status: 'pending',
|
|
42
|
+
read: false,
|
|
43
|
+
createdAt: now,
|
|
44
|
+
updatedAt: now,
|
|
45
|
+
...(createOpts?.meta !== undefined ? { meta: createOpts.meta } : {}),
|
|
46
|
+
}
|
|
47
|
+
threads = [thread, ...threads]
|
|
48
|
+
return id
|
|
49
|
+
},
|
|
50
|
+
appendMessage(id, message) {
|
|
51
|
+
threads = threads.map((thread) =>
|
|
52
|
+
thread.id === id
|
|
53
|
+
? { ...thread, messages: [...thread.messages, message], updatedAt: Date.now() }
|
|
54
|
+
: thread,
|
|
55
|
+
)
|
|
56
|
+
},
|
|
57
|
+
setOutcome(id, outcome) {
|
|
58
|
+
threads = threads.map((thread) =>
|
|
59
|
+
thread.id === id ? { ...thread, outcome, updatedAt: Date.now() } : thread,
|
|
60
|
+
)
|
|
61
|
+
},
|
|
62
|
+
setStatus(id, status) {
|
|
63
|
+
threads = threads.map((thread) =>
|
|
64
|
+
thread.id === id ? { ...thread, status, updatedAt: Date.now() } : thread,
|
|
65
|
+
)
|
|
66
|
+
},
|
|
67
|
+
setResumeToken(id, token) {
|
|
68
|
+
threads = threads.map((thread) => {
|
|
69
|
+
if (thread.id !== id) return thread
|
|
70
|
+
const { resumeToken: _resumeToken, ...rest } = thread
|
|
71
|
+
return {
|
|
72
|
+
...rest,
|
|
73
|
+
...(token !== undefined ? { resumeToken: token } : {}),
|
|
74
|
+
updatedAt: Date.now(),
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
},
|
|
78
|
+
markRead(id) {
|
|
79
|
+
threads = threads.map((thread) => (thread.id === id ? { ...thread, read: true } : thread))
|
|
80
|
+
},
|
|
81
|
+
remove(id) {
|
|
82
|
+
threads = threads.filter((thread) => thread.id !== id)
|
|
83
|
+
if (activeId === id) activeId = null
|
|
84
|
+
},
|
|
85
|
+
clear() {
|
|
86
|
+
threads = []
|
|
87
|
+
activeId = null
|
|
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,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const resolveOutcome: OutcomeResolver<TranscriptPart> = (): AgentOutcome => ({
|
|
98
|
+
title: 'title',
|
|
99
|
+
summary: 'summary',
|
|
100
|
+
status: 'done',
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('useAgentThreadRuns<TranscriptPart> — foreign part round trip', () => {
|
|
104
|
+
test('a foreign part survives accumulation and a replay replaces rather than duplicates', async () => {
|
|
105
|
+
const store = createTestThreadsStore()
|
|
106
|
+
const threadId = store.create()
|
|
107
|
+
|
|
108
|
+
const transport: AgentTransport<TranscriptPart, string> = {
|
|
109
|
+
async *stream() {
|
|
110
|
+
yield { id: 'progress-1', type: 'data-toolProgress', message: 'searching' }
|
|
111
|
+
// Same id, second delta — must REPLACE the first entry, not duplicate it.
|
|
112
|
+
yield { id: 'progress-1', type: 'data-toolProgress', message: 'done' }
|
|
113
|
+
yield { id: 'text-1', type: 'text', text: 'Found it.' }
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { result } = renderHook(() =>
|
|
118
|
+
useAgentThreadRuns<TranscriptPart>({ transport, store, resolveOutcome }),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
act(() => {
|
|
122
|
+
result.current.start(threadId, 'go find it')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
await waitFor(() => {
|
|
126
|
+
expect(store.threads.find((t) => t.id === threadId)?.status).toBe('done')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const assistantMessage = store.threads
|
|
130
|
+
.find((t) => t.id === threadId)
|
|
131
|
+
?.messages.find((m) => m.role === 'assistant')
|
|
132
|
+
|
|
133
|
+
expect(assistantMessage?.parts).toEqual([
|
|
134
|
+
{ id: 'progress-1', type: 'data-toolProgress', message: 'done' },
|
|
135
|
+
{ id: 'text-1', type: 'text', text: 'Found it.' },
|
|
136
|
+
])
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* The mount-reconcile effect (empty deps — a one-time sweep of whatever the store held when this
|
|
5
5
|
* manager first attaches) treats any persisted 'pending' or 'streaming' thread with no live
|
|
6
6
|
* controller as orphaned, and either attempts `transport.resume(resumeToken, signal)` (when the
|
|
7
|
-
* transport
|
|
8
|
-
*
|
|
7
|
+
* transport is RESUMABLE per `isResumable` — `resume` AND the literal `idempotentReplay: true`
|
|
8
|
+
* assertion, a resumeToken is present, and there's a last user message to attribute the resumed
|
|
9
|
+
* turn to) or falls back straight to 'interrupted'. `resume` alone is NOT enough post-B2 — see the
|
|
10
|
+
* dedicated "resume() present but no idempotentReplay" test below.
|
|
9
11
|
*
|
|
10
12
|
* The F3 wedge (StrictMode/`<Activity>` re-running this same effect on the SAME fiber) has its
|
|
11
13
|
* own file: `use-agent-thread-runs.wedge.test.tsx`.
|
|
@@ -14,7 +16,7 @@ import { describe, expect, test } from 'bun:test'
|
|
|
14
16
|
import { renderHook, waitFor } from '@testing-library/react'
|
|
15
17
|
import { useAgentThreadRuns } from './use-agent-thread-runs'
|
|
16
18
|
import type { AgentThread, ThreadsStore } from './thread'
|
|
17
|
-
import type { AgentTransport } from './transport'
|
|
19
|
+
import type { AgentTransport, ResumableAgentTransport } from './transport'
|
|
18
20
|
import type { AgentPart } from './parts'
|
|
19
21
|
import type { ChatMessage } from './history'
|
|
20
22
|
import type { AgentOutcome } from './outcome'
|
|
@@ -88,6 +90,11 @@ function createTestThreadsStore(initial: AgentThread<AgentPart>[]): ThreadsStore
|
|
|
88
90
|
threads = []
|
|
89
91
|
activeId = null
|
|
90
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,
|
|
91
98
|
}
|
|
92
99
|
}
|
|
93
100
|
|
|
@@ -98,7 +105,7 @@ function makeStreamingThread(
|
|
|
98
105
|
const userMessage: ChatMessage<AgentPart> = {
|
|
99
106
|
id: crypto.randomUUID(),
|
|
100
107
|
role: 'user',
|
|
101
|
-
parts: [{ type: 'text', text: 'hello' }],
|
|
108
|
+
parts: [{ id: 'seed-user-part', type: 'text', text: 'hello' }],
|
|
102
109
|
createdAt: now,
|
|
103
110
|
}
|
|
104
111
|
return {
|
|
@@ -140,7 +147,7 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
140
147
|
const userMessage: ChatMessage<AgentPart> = {
|
|
141
148
|
id: crypto.randomUUID(),
|
|
142
149
|
role: 'user',
|
|
143
|
-
parts: [{ type: 'text', text: 'hello' }],
|
|
150
|
+
parts: [{ id: 'seed-user-part', type: 'text', text: 'hello' }],
|
|
144
151
|
createdAt: now,
|
|
145
152
|
}
|
|
146
153
|
const thread: AgentThread<AgentPart> = {
|
|
@@ -155,12 +162,13 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
155
162
|
const store = createTestThreadsStore([thread])
|
|
156
163
|
|
|
157
164
|
let resumeCalls = 0
|
|
158
|
-
const transport:
|
|
165
|
+
const transport: ResumableAgentTransport<AgentPart, string> = {
|
|
159
166
|
async *stream() {},
|
|
160
167
|
async *resume() {
|
|
161
168
|
resumeCalls++
|
|
162
|
-
yield { type: 'text', text: 'resumed' }
|
|
169
|
+
yield { id: 'p1', type: 'text', text: 'resumed' }
|
|
163
170
|
},
|
|
171
|
+
idempotentReplay: true as const,
|
|
164
172
|
}
|
|
165
173
|
|
|
166
174
|
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
@@ -175,13 +183,36 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
175
183
|
const thread = makeStreamingThread({ messages: [] })
|
|
176
184
|
const store = createTestThreadsStore([thread])
|
|
177
185
|
|
|
186
|
+
let resumeCalls = 0
|
|
187
|
+
const transport: ResumableAgentTransport<AgentPart, string> = {
|
|
188
|
+
async *stream() {},
|
|
189
|
+
async *resume() {
|
|
190
|
+
resumeCalls++
|
|
191
|
+
yield { id: 'p1', type: 'text', text: 'resumed' }
|
|
192
|
+
},
|
|
193
|
+
idempotentReplay: true as const,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
197
|
+
|
|
198
|
+
await waitFor(() => {
|
|
199
|
+
expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
|
|
200
|
+
})
|
|
201
|
+
expect(resumeCalls).toBe(0)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('resume() present but WITHOUT idempotentReplay → still falls back to interrupted (resume alone is not enough post-B2)', async () => {
|
|
205
|
+
const thread = makeStreamingThread()
|
|
206
|
+
const store = createTestThreadsStore([thread])
|
|
207
|
+
|
|
178
208
|
let resumeCalls = 0
|
|
179
209
|
const transport: AgentTransport<AgentPart, string> = {
|
|
180
210
|
async *stream() {},
|
|
181
211
|
async *resume() {
|
|
182
212
|
resumeCalls++
|
|
183
|
-
yield { type: 'text', text: 'resumed' }
|
|
213
|
+
yield { id: 'p1', type: 'text', text: 'resumed' }
|
|
184
214
|
},
|
|
215
|
+
// deliberately NO idempotentReplay: true
|
|
185
216
|
}
|
|
186
217
|
|
|
187
218
|
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
@@ -197,12 +228,13 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
197
228
|
const store = createTestThreadsStore([thread])
|
|
198
229
|
|
|
199
230
|
const resumeArgs: string[] = []
|
|
200
|
-
const transport:
|
|
231
|
+
const transport: ResumableAgentTransport<AgentPart, string> = {
|
|
201
232
|
async *stream() {},
|
|
202
233
|
async *resume(token) {
|
|
203
234
|
resumeArgs.push(token)
|
|
204
|
-
yield { type: 'text', text: 'resumed' }
|
|
235
|
+
yield { id: 'p1', type: 'text', text: 'resumed' }
|
|
205
236
|
},
|
|
237
|
+
idempotentReplay: true as const,
|
|
206
238
|
}
|
|
207
239
|
|
|
208
240
|
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
@@ -217,12 +249,13 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
|
|
|
217
249
|
const thread = makeStreamingThread()
|
|
218
250
|
const store = createTestThreadsStore([thread])
|
|
219
251
|
|
|
220
|
-
const transport
|
|
252
|
+
const transport = {
|
|
221
253
|
async *stream() {},
|
|
222
254
|
// oxlint-disable-next-line require-yield
|
|
223
255
|
async *resume() {
|
|
224
256
|
throw new Error('resume failed')
|
|
225
257
|
},
|
|
258
|
+
idempotentReplay: true as const,
|
|
226
259
|
}
|
|
227
260
|
|
|
228
261
|
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|