basalt-ui 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +21 -21
- package/CHANGELOG.md +32 -0
- package/README.md +63 -51
- package/agent/rules/basalt-agent.md +14 -0
- package/agent/rules/basalt-content.md +16 -3
- package/configs/oxlint-plugin.js +7 -3
- package/configs/oxlint-plugin.test.ts +34 -5
- package/configs/oxlint.json +4 -4
- package/dist/agent/adapter.d.ts +150 -0
- package/dist/agent/adapter.d.ts.map +1 -0
- package/dist/agent/adapter.js +482 -0
- package/dist/agent/adapter.test.d.ts +2 -0
- package/dist/agent/adapter.test.d.ts.map +1 -0
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +6 -2
- package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
- package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
- package/dist/agent/coalesce.d.ts +20 -4
- package/dist/agent/coalesce.d.ts.map +1 -1
- package/dist/agent/coalesce.js +76 -8
- package/dist/agent/coalesce.test.d.ts +2 -0
- package/dist/agent/coalesce.test.d.ts.map +1 -0
- package/dist/agent/foreign.test.d.ts +2 -0
- package/dist/agent/foreign.test.d.ts.map +1 -0
- package/dist/agent/id.d.ts +78 -0
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +30 -0
- package/dist/agent/id.test.d.ts +2 -0
- package/dist/agent/id.test.d.ts.map +1 -0
- package/dist/agent/index.d.ts +2 -0
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +3 -0
- package/dist/agent/merge.d.ts.map +1 -1
- package/dist/agent/merge.js +19 -7
- package/dist/agent/merge.test.d.ts +2 -0
- package/dist/agent/merge.test.d.ts.map +1 -0
- package/dist/agent/part-list.d.ts +49 -34
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +42 -6
- package/dist/agent/part-list.test.d.ts +2 -0
- package/dist/agent/part-list.test.d.ts.map +1 -0
- package/dist/agent/parts.d.ts +46 -10
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +16 -6
- package/dist/agent/parts.test.d.ts +2 -0
- package/dist/agent/parts.test.d.ts.map +1 -0
- package/dist/agent/thread.d.ts +18 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/thread.js +8 -2
- package/dist/agent/thread.test.d.ts +2 -0
- package/dist/agent/thread.test.d.ts.map +1 -0
- package/dist/agent/transport.test.d.ts +2 -0
- package/dist/agent/transport.test.d.ts.map +1 -0
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +2 -2
- package/dist/agent/use-agent-stream.test.d.ts +2 -0
- package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.d.ts +24 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.js +55 -15
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
- package/dist/agent-chat/composer.d.ts +130 -8
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +189 -23
- package/dist/agent-chat/composer.test.d.ts +2 -0
- package/dist/agent-chat/composer.test.d.ts.map +1 -0
- package/dist/agent-chat/index.d.ts +5 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/message-affordances.d.ts +59 -0
- package/dist/agent-chat/message-affordances.d.ts.map +1 -0
- package/dist/agent-chat/message-affordances.js +7 -0
- package/dist/agent-chat/relative-time.d.ts +27 -0
- package/dist/agent-chat/relative-time.d.ts.map +1 -0
- package/dist/agent-chat/relative-time.js +27 -0
- package/dist/agent-chat/relative-time.test.d.ts +2 -0
- package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-feed-row.d.ts +65 -0
- package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed-row.js +156 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed.d.ts +34 -5
- package/dist/agent-chat/thread-feed.d.ts.map +1 -1
- package/dist/agent-chat/thread-feed.js +52 -19
- package/dist/agent-chat/thread-feed.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-message.d.ts +183 -2
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +329 -37
- package/dist/agent-chat/thread-message.test.d.ts +2 -0
- package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
- package/dist/agent-chat/thread-outcome-card.js +1 -16
- package/dist/agent-chat/thread-workspace.d.ts +14 -2
- package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
- package/dist/agent-chat/thread-workspace.js +30 -4
- package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
- package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
- package/dist/agent-chat/tool-chip.test.d.ts +2 -0
- package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.d.ts +123 -0
- package/dist/agent-chat/virtualize.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.js +9 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
- package/dist/charts/utils/domain.test.d.ts +2 -0
- package/dist/charts/utils/domain.test.d.ts.map +1 -0
- package/dist/cli/check-theme.test.d.ts +2 -0
- package/dist/cli/check-theme.test.d.ts.map +1 -0
- package/dist/cli/help.test.d.ts +2 -0
- package/dist/cli/help.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/init-sync.test.d.ts +2 -0
- package/dist/cli/init-sync.test.d.ts.map +1 -0
- package/dist/cli/placement-engine.test.d.ts +2 -0
- package/dist/cli/placement-engine.test.d.ts.map +1 -0
- package/dist/cli/tarball-parity.test.d.ts +2 -0
- package/dist/cli/tarball-parity.test.d.ts.map +1 -0
- package/dist/cli/template-format.test.d.ts +2 -0
- package/dist/cli/template-format.test.d.ts.map +1 -0
- package/dist/cli/template-ledger.test.d.ts +2 -0
- package/dist/cli/template-ledger.test.d.ts.map +1 -0
- package/dist/cli/tokens-css.test.d.ts +2 -0
- package/dist/cli/tokens-css.test.d.ts.map +1 -0
- package/dist/content/article-actions.test.d.ts +2 -0
- package/dist/content/article-actions.test.d.ts.map +1 -0
- package/dist/content/article-model.test.d.ts +2 -0
- package/dist/content/article-model.test.d.ts.map +1 -0
- package/dist/content/block-split.test.d.ts +2 -0
- package/dist/content/block-split.test.d.ts.map +1 -0
- package/dist/content/fence-block.d.ts +37 -10
- package/dist/content/fence-block.d.ts.map +1 -1
- package/dist/content/fence-block.js +80 -14
- package/dist/content/heading-components.d.ts.map +1 -1
- package/dist/content/heading-components.js +7 -4
- package/dist/content/heading-components.test.d.ts +2 -0
- package/dist/content/heading-components.test.d.ts.map +1 -0
- package/dist/content/index.d.ts +6 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +5 -1
- package/dist/content/markdown.d.ts +101 -14
- package/dist/content/markdown.d.ts.map +1 -1
- package/dist/content/markdown.js +141 -28
- package/dist/content/markdown.test.d.ts +2 -0
- package/dist/content/markdown.test.d.ts.map +1 -0
- package/dist/content/prose.module.css.test.d.ts +2 -0
- package/dist/content/prose.module.css.test.d.ts.map +1 -0
- package/dist/content/sanitize.d.ts +170 -0
- package/dist/content/sanitize.d.ts.map +1 -0
- package/dist/content/sanitize.js +178 -0
- package/dist/content/sanitize.test.d.ts +2 -0
- package/dist/content/sanitize.test.d.ts.map +1 -0
- package/dist/content/slug.test.d.ts +2 -0
- package/dist/content/slug.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.test.d.ts +2 -0
- package/dist/dashboard/stat-card.test.d.ts.map +1 -0
- package/dist/data/index.d.ts +5 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/virtual-list.d.ts.map +1 -1
- package/dist/data/virtual.d.ts +2 -1
- package/dist/data/virtual.d.ts.map +1 -1
- package/dist/guard/check-source.test.d.ts +2 -0
- package/dist/guard/check-source.test.d.ts.map +1 -0
- package/dist/guard/guard-hook.test.d.ts +2 -0
- package/dist/guard/guard-hook.test.d.ts.map +1 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/provider/index.test.d.ts +2 -0
- package/dist/provider/index.test.d.ts.map +1 -0
- package/dist/provider/lab-theme.test.d.ts +2 -0
- package/dist/provider/lab-theme.test.d.ts.map +1 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
- package/dist/styles.floor.test.d.ts +2 -0
- package/dist/styles.floor.test.d.ts.map +1 -0
- package/dist/surfaces.d.ts +5 -5
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +14 -4
- package/dist/theme/border-coverage.test.d.ts +18 -0
- package/dist/theme/border-coverage.test.d.ts.map +1 -0
- package/dist/theme/contrast.test.d.ts +2 -0
- package/dist/theme/contrast.test.d.ts.map +1 -0
- package/dist/theme/density-option.test.d.ts +2 -0
- package/dist/theme/density-option.test.d.ts.map +1 -0
- package/dist/theme/density-relations.test.d.ts +2 -0
- package/dist/theme/density-relations.test.d.ts.map +1 -0
- package/dist/theme/derive-theme.test.d.ts +2 -0
- package/dist/theme/derive-theme.test.d.ts.map +1 -0
- package/dist/theme/fonts-option.test.d.ts +2 -0
- package/dist/theme/fonts-option.test.d.ts.map +1 -0
- package/dist/theme/radius-option.test.d.ts +2 -0
- package/dist/theme/radius-option.test.d.ts.map +1 -0
- package/dist/theme/radius.test.d.ts +2 -0
- package/dist/theme/radius.test.d.ts.map +1 -0
- package/dist/theme/shadow-surfaces.test.d.ts +26 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
- package/dist/theme/spacing.test.d.ts +2 -0
- package/dist/theme/spacing.test.d.ts.map +1 -0
- package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
- package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-controls.test.d.ts +2 -0
- package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-state.test.d.ts +2 -0
- package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
- package/dist/theme-lab/theme-lab.test.d.ts +2 -0
- package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
- package/dist/tokens/build-fonts-css.test.d.ts +2 -0
- package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
- package/dist/tokens/build-radius-css.test.d.ts +2 -0
- package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
- package/dist/tokens/density.test.d.ts +2 -0
- package/dist/tokens/density.test.d.ts.map +1 -0
- package/dist/tokens/derive-radius.test.d.ts +2 -0
- package/dist/tokens/derive-radius.test.d.ts.map +1 -0
- package/dist/tokens/derive.test.d.ts +2 -0
- package/dist/tokens/derive.test.d.ts.map +1 -0
- package/dist/tokens/palette.test.d.ts +2 -0
- package/dist/tokens/palette.test.d.ts.map +1 -0
- package/dist/vite-color-mix.test.d.ts +2 -0
- package/dist/vite-color-mix.test.d.ts.map +1 -0
- package/dist/vite.test.d.ts +2 -0
- package/dist/vite.test.d.ts.map +1 -0
- package/llms.txt +9 -9
- package/package.json +7 -2
- package/src/agent/adapter.test.ts +1640 -0
- package/src/agent/adapter.ts +945 -0
- package/src/agent/ai-sdk-transport.test.ts +117 -26
- package/src/agent/ai-sdk-transport.ts +8 -2
- package/src/agent/coalesce.test.ts +202 -9
- package/src/agent/coalesce.ts +126 -15
- package/src/agent/foreign.test.ts +5 -1
- package/src/agent/id.test.ts +101 -3
- package/src/agent/id.ts +118 -0
- package/src/agent/index.ts +8 -0
- package/src/agent/merge.test.ts +87 -0
- package/src/agent/merge.ts +57 -10
- package/src/agent/part-list.test.tsx +31 -1
- package/src/agent/part-list.tsx +77 -29
- package/src/agent/parts.test.ts +121 -4
- package/src/agent/parts.ts +78 -17
- package/src/agent/thread.test.ts +75 -2
- package/src/agent/thread.ts +25 -1
- package/src/agent/use-agent-stream.test.tsx +31 -5
- package/src/agent/use-agent-stream.ts +6 -3
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
- package/src/agent/use-agent-thread-runs.test.tsx +356 -17
- package/src/agent/use-agent-thread-runs.ts +159 -23
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +93 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +13 -1
- package/src/agent-chat/message-affordances.ts +61 -0
- package/src/agent-chat/relative-time.test.ts +95 -0
- package/src/agent-chat/relative-time.ts +55 -0
- package/src/agent-chat/thread-detail-panel.tsx +3 -1
- package/src/agent-chat/thread-feed-row.test.tsx +510 -0
- package/src/agent-chat/thread-feed-row.tsx +274 -0
- package/src/agent-chat/thread-feed.test.tsx +241 -0
- package/src/agent-chat/thread-feed.tsx +119 -18
- package/src/agent-chat/thread-message.test.tsx +1078 -4
- package/src/agent-chat/thread-message.tsx +795 -42
- package/src/agent-chat/thread-outcome-card.tsx +1 -25
- package/src/agent-chat/thread-workspace.test.tsx +169 -0
- package/src/agent-chat/thread-workspace.tsx +70 -6
- package/src/agent-chat/virtualize.ts +132 -0
- package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
- package/src/cli/index.ts +0 -1
- package/src/cli/template-format.test.ts +2 -2
- package/src/content/fence-block.tsx +242 -15
- package/src/content/heading-components.test.tsx +65 -0
- package/src/content/heading-components.tsx +26 -5
- package/src/content/index.ts +19 -3
- package/src/content/markdown.test.tsx +603 -0
- package/src/content/markdown.tsx +355 -35
- package/src/content/sanitize.test.ts +659 -0
- package/src/content/sanitize.ts +510 -0
- package/src/dashboard/stat-card.test.tsx +6 -1
- package/src/data/index.ts +5 -1
- package/src/data/virtual-list.tsx +2 -1
- package/src/data/virtual.ts +2 -1
- package/src/index.ts +18 -1
- package/src/surfaces.ts +13 -3
- package/src/theme/contrast.test.ts +1 -4
- package/src/theme/density-relations.test.ts +4 -2
- package/src/theme/fonts-option.test.ts +7 -1
- package/src/theme/shadow-surfaces.test.ts +21 -6
- package/src/theme/use-basalt-spacing.test.tsx +2 -1
|
@@ -23,7 +23,7 @@ import { aiSdkTransport } from './ai-sdk-transport'
|
|
|
23
23
|
import { withPartIds } from './id'
|
|
24
24
|
import { mergePart } from './merge'
|
|
25
25
|
import type { UIMessageChunk } from 'ai'
|
|
26
|
-
import type { AgentPart } from './parts'
|
|
26
|
+
import type { AgentPart, ToolCallPart } from './parts'
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Builds a fetch-compatible Response whose body is the given low-level UIMessageChunks, SSE-
|
|
@@ -43,6 +43,16 @@ function scriptedResponse(chunks: UIMessageChunk[]): Response {
|
|
|
43
43
|
return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Casts a scripted fetch stub into `aiSdkTransport`'s `fetch: typeof globalThis.fetch` option.
|
|
48
|
+
* The DOM lib's `typeof fetch` type also demands a static `preconnect` method (a real
|
|
49
|
+
* browser/undici API these scripted mocks never call and don't need to implement) — the cast
|
|
50
|
+
* documents that the only structural gap is that one, unused static member.
|
|
51
|
+
*/
|
|
52
|
+
function mockFetch(impl: () => Promise<Response>): typeof fetch {
|
|
53
|
+
return impl as unknown as typeof fetch
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
/** Drains an aiSdkTransport `.stream()`/`.resume()` async generator into a plain array. */
|
|
47
57
|
async function collect(gen: AsyncGenerator<AgentPart>): Promise<AgentPart[]> {
|
|
48
58
|
const parts: AgentPart[] = []
|
|
@@ -50,11 +60,24 @@ async function collect(gen: AsyncGenerator<AgentPart>): Promise<AgentPart[]> {
|
|
|
50
60
|
return parts
|
|
51
61
|
}
|
|
52
62
|
|
|
63
|
+
/**
|
|
64
|
+
* `Omit<T, K>` does not distribute over a union — `keyof T` on a union collapses to only the
|
|
65
|
+
* KEYS COMMON to every member, so `Omit<ToolCallPart, 'durationMs'>` would silently drop each
|
|
66
|
+
* state's own fields (`input`, `output`, …) rather than just `durationMs`. This variant forces
|
|
67
|
+
* distribution via a naked conditional type parameter.
|
|
68
|
+
*/
|
|
69
|
+
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
|
|
70
|
+
|
|
53
71
|
/** Strips a non-deterministic `durationMs` (wall-clock, so never equality-comparable) before an
|
|
54
|
-
* exact `toEqual`, and separately asserts its type.
|
|
55
|
-
|
|
72
|
+
* exact `toEqual`, and separately asserts its type. Constrained to `Record<string, unknown>`
|
|
73
|
+
* rather than `{ durationMs?: number }` because ErrorPart (uniquely among AgentPart variants)
|
|
74
|
+
* carries no `durationMs` field at all — that stricter constraint trips TS's weak-type check
|
|
75
|
+
* ("no properties in common") the moment this is called with the full AgentPart union. */
|
|
76
|
+
function withoutDurationMs<T extends Record<string, unknown>>(
|
|
77
|
+
part: T,
|
|
78
|
+
): DistributiveOmit<T, 'durationMs'> {
|
|
56
79
|
const { durationMs: _durationMs, ...rest } = part
|
|
57
|
-
return rest
|
|
80
|
+
return rest as DistributiveOmit<T, 'durationMs'>
|
|
58
81
|
}
|
|
59
82
|
|
|
60
83
|
describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
@@ -67,7 +90,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
67
90
|
]
|
|
68
91
|
const transport = aiSdkTransport({
|
|
69
92
|
api: '/api/chat',
|
|
70
|
-
fetch: async () => scriptedResponse(chunks),
|
|
93
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
71
94
|
})
|
|
72
95
|
const parts = await collect(transport.stream('hi'))
|
|
73
96
|
|
|
@@ -107,7 +130,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
107
130
|
// trivially fail to match on that alone, masking whether withPartIds itself changed anything.
|
|
108
131
|
const transport = aiSdkTransport({
|
|
109
132
|
api: '/api/chat',
|
|
110
|
-
fetch: async () => scriptedResponse(chunks),
|
|
133
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
111
134
|
})
|
|
112
135
|
const direct = await collect(transport.stream('hi'))
|
|
113
136
|
const wrapped = await collect(withPartIds('unrelated-run-id', transport.stream('hi')))
|
|
@@ -126,7 +149,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
126
149
|
]
|
|
127
150
|
const transport = aiSdkTransport({
|
|
128
151
|
api: '/api/chat',
|
|
129
|
-
fetch: async () => scriptedResponse(chunks),
|
|
152
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
130
153
|
})
|
|
131
154
|
const parts = await collect(transport.stream('hi'))
|
|
132
155
|
|
|
@@ -146,7 +169,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
146
169
|
]
|
|
147
170
|
const transport = aiSdkTransport({
|
|
148
171
|
api: '/api/chat',
|
|
149
|
-
fetch: async () => scriptedResponse(chunks),
|
|
172
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
150
173
|
})
|
|
151
174
|
const parts = await collect(transport.stream('hi'))
|
|
152
175
|
|
|
@@ -168,13 +191,16 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
168
191
|
]
|
|
169
192
|
const transport = aiSdkTransport({
|
|
170
193
|
api: '/api/chat',
|
|
171
|
-
fetch: async () => scriptedResponse(chunks),
|
|
194
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
172
195
|
})
|
|
173
196
|
const parts = await collect(transport.stream('hi'))
|
|
174
197
|
|
|
175
198
|
const toolParts = parts.filter((p) => p.type === 'tool')
|
|
176
199
|
expect(toolParts.every((p) => (p as { id: string }).id === 'tool#call-1')).toBe(true)
|
|
177
|
-
|
|
200
|
+
// Annotated so each element is checked individually against the ToolCallPart union — passed
|
|
201
|
+
// inline, TS collapses the array's contextual element type and rejects the settled states'
|
|
202
|
+
// extra `input`/`output` fields against the first (narrowest) member it picks.
|
|
203
|
+
const expectedToolParts: DistributiveOmit<ToolCallPart, 'durationMs'>[] = [
|
|
178
204
|
{
|
|
179
205
|
id: 'tool#call-1',
|
|
180
206
|
type: 'tool',
|
|
@@ -199,7 +225,8 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
199
225
|
input: { q: 'x' },
|
|
200
226
|
output: { hits: 3 },
|
|
201
227
|
},
|
|
202
|
-
]
|
|
228
|
+
]
|
|
229
|
+
expect(toolParts.map(withoutDurationMs)).toEqual(expectedToolParts)
|
|
203
230
|
// durationMs is only set once the call reaches a terminal state (output-available here).
|
|
204
231
|
expect((toolParts[0] as { durationMs?: number }).durationMs).toBeUndefined()
|
|
205
232
|
expect(typeof (toolParts.at(-1) as { durationMs?: number }).durationMs).toBe('number')
|
|
@@ -215,7 +242,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
215
242
|
]
|
|
216
243
|
const transport = aiSdkTransport({
|
|
217
244
|
api: '/api/chat',
|
|
218
|
-
fetch: async () => scriptedResponse(chunks),
|
|
245
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
219
246
|
})
|
|
220
247
|
const parts = await collect(transport.stream('hi'))
|
|
221
248
|
|
|
@@ -245,7 +272,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
245
272
|
]
|
|
246
273
|
const transport = aiSdkTransport({
|
|
247
274
|
api: '/api/chat',
|
|
248
|
-
fetch: async () => scriptedResponse(chunks),
|
|
275
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
249
276
|
})
|
|
250
277
|
const parts = await collect(transport.stream('hi'))
|
|
251
278
|
|
|
@@ -282,7 +309,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
282
309
|
]
|
|
283
310
|
const transport = aiSdkTransport({
|
|
284
311
|
api: '/api/chat',
|
|
285
|
-
fetch: async () => scriptedResponse(chunks),
|
|
312
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
286
313
|
})
|
|
287
314
|
const parts = await collect(transport.stream('hi'))
|
|
288
315
|
|
|
@@ -330,7 +357,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
330
357
|
]
|
|
331
358
|
const transport = aiSdkTransport({
|
|
332
359
|
api: '/api/chat',
|
|
333
|
-
fetch: async () => scriptedResponse(chunks),
|
|
360
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
334
361
|
})
|
|
335
362
|
const parts = await collect(transport.stream('hi'))
|
|
336
363
|
|
|
@@ -362,7 +389,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
362
389
|
]
|
|
363
390
|
const transport = aiSdkTransport({
|
|
364
391
|
api: '/api/chat',
|
|
365
|
-
fetch: async () => scriptedResponse(chunks),
|
|
392
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
366
393
|
})
|
|
367
394
|
const parts = await collect(transport.stream('hi'))
|
|
368
395
|
|
|
@@ -394,7 +421,7 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
394
421
|
]
|
|
395
422
|
const transport = aiSdkTransport({
|
|
396
423
|
api: '/api/chat',
|
|
397
|
-
fetch: async () => scriptedResponse(chunks),
|
|
424
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
398
425
|
})
|
|
399
426
|
const parts = await collect(transport.stream('hi'))
|
|
400
427
|
|
|
@@ -415,7 +442,7 @@ describe('aiSdkTransport — signal.aborted stops the yield', () => {
|
|
|
415
442
|
]
|
|
416
443
|
const transport = aiSdkTransport({
|
|
417
444
|
api: '/api/chat',
|
|
418
|
-
fetch: async () => scriptedResponse(chunks),
|
|
445
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
419
446
|
})
|
|
420
447
|
const controller = new AbortController()
|
|
421
448
|
const gen = transport.stream('hi', controller.signal)
|
|
@@ -446,9 +473,9 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
|
|
|
446
473
|
// generator that never advances past its first `.next()` never touches the network.
|
|
447
474
|
const transport = aiSdkTransport({
|
|
448
475
|
api: '/api/chat',
|
|
449
|
-
fetch: () => {
|
|
476
|
+
fetch: mockFetch(() => {
|
|
450
477
|
throw new Error('must not be called — only the first, pre-network yield is read')
|
|
451
|
-
},
|
|
478
|
+
}),
|
|
452
479
|
})
|
|
453
480
|
|
|
454
481
|
const first = await transport.stream('a').next()
|
|
@@ -460,9 +487,9 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
|
|
|
460
487
|
test('.forThread(id) binds the StartPart runId/resumeToken/id deterministically to the given id', async () => {
|
|
461
488
|
const transport = aiSdkTransport({
|
|
462
489
|
api: '/api/chat',
|
|
463
|
-
fetch: () => {
|
|
490
|
+
fetch: mockFetch(() => {
|
|
464
491
|
throw new Error('must not be called — only the first, pre-network yield is read')
|
|
465
|
-
},
|
|
492
|
+
}),
|
|
466
493
|
})
|
|
467
494
|
const bound = transport.forThread('caller-supplied-thread-id')
|
|
468
495
|
|
|
@@ -482,9 +509,9 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
|
|
|
482
509
|
test('two different .forThread() ids produce two different StartPart runIds/ids', async () => {
|
|
483
510
|
const transport = aiSdkTransport({
|
|
484
511
|
api: '/api/chat',
|
|
485
|
-
fetch: () => {
|
|
512
|
+
fetch: mockFetch(() => {
|
|
486
513
|
throw new Error('must not be called — only the first, pre-network yield is read')
|
|
487
|
-
},
|
|
514
|
+
}),
|
|
488
515
|
})
|
|
489
516
|
|
|
490
517
|
const a = await transport.forThread('thread-a').stream('hi').next()
|
|
@@ -516,7 +543,7 @@ describe('aiSdkTransport — deterministic ids and replay idempotency', () => {
|
|
|
516
543
|
]
|
|
517
544
|
const transport = aiSdkTransport({
|
|
518
545
|
api: '/api/chat',
|
|
519
|
-
fetch: async () => scriptedResponse(chunks),
|
|
546
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
520
547
|
}).forThread('thread-x')
|
|
521
548
|
|
|
522
549
|
const firstIds = (await collect(transport.stream('hi'))).map((p) => (p as { id: string }).id)
|
|
@@ -535,7 +562,7 @@ describe('aiSdkTransport — deterministic ids and replay idempotency', () => {
|
|
|
535
562
|
]
|
|
536
563
|
const transport = aiSdkTransport({
|
|
537
564
|
api: '/api/chat',
|
|
538
|
-
fetch: async () => scriptedResponse(chunks),
|
|
565
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
539
566
|
}).forThread('thread-x')
|
|
540
567
|
|
|
541
568
|
const firstPass = await collect(transport.stream('hi'))
|
|
@@ -565,3 +592,67 @@ describe('aiSdkTransport — deterministic ids and replay idempotency', () => {
|
|
|
565
592
|
)
|
|
566
593
|
})
|
|
567
594
|
})
|
|
595
|
+
|
|
596
|
+
describe('aiSdkTransport — id minting under degraded crypto', () => {
|
|
597
|
+
test('the fixed chatId (mintThreadId, low collision cost) mints fine with no usable crypto at all — construction never throws', () => {
|
|
598
|
+
const originalCrypto = globalThis.crypto
|
|
599
|
+
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
|
|
600
|
+
try {
|
|
601
|
+
expect(() =>
|
|
602
|
+
aiSdkTransport({
|
|
603
|
+
api: '/api/chat',
|
|
604
|
+
fetch: mockFetch(() => {
|
|
605
|
+
throw new Error('must not be called')
|
|
606
|
+
}),
|
|
607
|
+
}),
|
|
608
|
+
).not.toThrow()
|
|
609
|
+
} finally {
|
|
610
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
611
|
+
}
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
test('the outbound user message id (mintMessageId, an idempotency key on the AI SDK backend) works via getRandomValues when randomUUID is unavailable', async () => {
|
|
615
|
+
const chunks: UIMessageChunk[] = [
|
|
616
|
+
{ type: 'text-start', id: 't1' },
|
|
617
|
+
{ type: 'text-end', id: 't1' },
|
|
618
|
+
]
|
|
619
|
+
const transport = aiSdkTransport({
|
|
620
|
+
api: '/api/chat',
|
|
621
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
const originalCrypto = globalThis.crypto
|
|
625
|
+
Object.defineProperty(globalThis, 'crypto', {
|
|
626
|
+
value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
|
|
627
|
+
configurable: true,
|
|
628
|
+
})
|
|
629
|
+
try {
|
|
630
|
+
const parts = await collect(transport.stream('hi'))
|
|
631
|
+
expect(parts[0]?.type).toBe('start')
|
|
632
|
+
} finally {
|
|
633
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
634
|
+
}
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
test('the outbound user message id THROWS on a host with no usable crypto at all, rather than silently colliding', async () => {
|
|
638
|
+
const transport = aiSdkTransport({
|
|
639
|
+
api: '/api/chat',
|
|
640
|
+
fetch: mockFetch(() => {
|
|
641
|
+
throw new Error('must not be called — must throw before any network call')
|
|
642
|
+
}),
|
|
643
|
+
})
|
|
644
|
+
const gen = transport.stream('hi')
|
|
645
|
+
|
|
646
|
+
// The synthesized StartPart is yielded first, before mintMessageId is ever reached.
|
|
647
|
+
const start = await gen.next()
|
|
648
|
+
expect(start.value).toMatchObject({ type: 'start' })
|
|
649
|
+
|
|
650
|
+
const originalCrypto = globalThis.crypto
|
|
651
|
+
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
|
|
652
|
+
try {
|
|
653
|
+
await expect(gen.next()).rejects.toThrow(/idempotency key/)
|
|
654
|
+
} finally {
|
|
655
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
656
|
+
}
|
|
657
|
+
})
|
|
658
|
+
})
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
* })
|
|
49
49
|
*/
|
|
50
50
|
import { assertNever } from '../register'
|
|
51
|
+
import { mintMessageId, mintThreadId } from './id'
|
|
51
52
|
import { TERMINAL_TOOL_STATES } from './parts'
|
|
52
53
|
import type { AgentPart, AgentPartDraft } from './parts'
|
|
53
54
|
import type { ResumableAgentTransport } from './transport'
|
|
@@ -401,7 +402,10 @@ export function aiSdkTransport<TPart = AgentPart>(
|
|
|
401
402
|
|
|
402
403
|
const { httpTransport, readUIMessageStream: readStream } = await resolveAiSdk()
|
|
403
404
|
const userMessage: UIMessage = {
|
|
404
|
-
id
|
|
405
|
+
// This id is what AI SDK's own backend persistence/dedup keys turns hang off — the same
|
|
406
|
+
// idempotency-key cost as basalt's own ChatMessage.id (see mintMessageId's doc), so
|
|
407
|
+
// mintMessageId, not mintThreadId, is the right helper here.
|
|
408
|
+
id: mintMessageId(),
|
|
405
409
|
role: 'user',
|
|
406
410
|
parts: [{ type: 'text', text: input }],
|
|
407
411
|
}
|
|
@@ -425,7 +429,9 @@ export function aiSdkTransport<TPart = AgentPart>(
|
|
|
425
429
|
}
|
|
426
430
|
}
|
|
427
431
|
|
|
428
|
-
|
|
432
|
+
// Client-side conversation namespace, not an idempotency key — a collision here would merge two
|
|
433
|
+
// conversations' index-addressed part ids, the same low collision cost `mintThreadId` covers.
|
|
434
|
+
const fixedChatId = mintThreadId()
|
|
429
435
|
return {
|
|
430
436
|
...makeTransport(fixedChatId),
|
|
431
437
|
forThread: (chatId: string) => makeTransport(chatId),
|
|
@@ -136,6 +136,8 @@ describe('coalesceParts', () => {
|
|
|
136
136
|
state: 'input-available',
|
|
137
137
|
input: { q: 'x' },
|
|
138
138
|
}
|
|
139
|
+
// `toolName` keeps its own empty-string convention for "omitted" (unlike toolCallId, whose
|
|
140
|
+
// absence is now modelled in the type) — the strict type still requires the field, hence the cast.
|
|
139
141
|
const outputAvailableNoToolName = {
|
|
140
142
|
id: 'p2',
|
|
141
143
|
type: 'tool',
|
|
@@ -150,6 +152,10 @@ describe('coalesceParts', () => {
|
|
|
150
152
|
expect((out[0] as ToolCallPart).toolName).toBe('search')
|
|
151
153
|
})
|
|
152
154
|
|
|
155
|
+
// The SDK's raw approval-responded chunk arrives without its own toolCallId — one of the four
|
|
156
|
+
// SETTLED states whose id is optional in the type (parts.ts, MaybeIdentified), so every fixture
|
|
157
|
+
// below builds a legal wire shape directly, with no cast.
|
|
158
|
+
|
|
153
159
|
test('an approval-responded part with no toolCallId is resolved via the approvalId side index', () => {
|
|
154
160
|
const approvalRequested: ToolCallPart = {
|
|
155
161
|
id: 'p1',
|
|
@@ -160,35 +166,222 @@ describe('coalesceParts', () => {
|
|
|
160
166
|
input: { path: '/etc/passwd' },
|
|
161
167
|
approval: { id: 'appr-1' },
|
|
162
168
|
}
|
|
163
|
-
|
|
164
|
-
// reverse lookup on approval.id instead.
|
|
165
|
-
const approvalResponded = {
|
|
169
|
+
const approvalResponded: ToolCallPart = {
|
|
166
170
|
id: 'p2',
|
|
167
171
|
type: 'tool',
|
|
168
|
-
toolCallId: '',
|
|
169
172
|
toolName: 'delete_file',
|
|
170
173
|
state: 'approval-responded',
|
|
171
174
|
input: { path: '/etc/passwd' },
|
|
172
175
|
approval: { id: 'appr-1', approved: true },
|
|
173
|
-
}
|
|
176
|
+
}
|
|
174
177
|
|
|
175
178
|
const out = coalesceParts([approvalRequested, approvalResponded])
|
|
176
179
|
expect(out).toHaveLength(1)
|
|
177
|
-
|
|
180
|
+
const merged = out[0] as ToolCallPart
|
|
181
|
+
expect(merged.state).toBe('approval-responded')
|
|
182
|
+
// The resolved call keeps the identity the request supplied — the response had none.
|
|
183
|
+
expect(merged.toolCallId).toBe('call-1')
|
|
178
184
|
})
|
|
179
185
|
|
|
180
186
|
test('a tool part unresolvable by either toolCallId or approvalId is kept as-is, never dropped', () => {
|
|
181
|
-
const orphan = {
|
|
187
|
+
const orphan: ToolCallPart = {
|
|
182
188
|
id: 'p1',
|
|
183
189
|
type: 'tool',
|
|
184
|
-
toolCallId: '',
|
|
185
190
|
toolName: 'mystery',
|
|
186
191
|
state: 'approval-responded',
|
|
187
192
|
input: {},
|
|
188
193
|
approval: { id: 'unknown-approval', approved: true },
|
|
189
|
-
}
|
|
194
|
+
}
|
|
190
195
|
|
|
191
196
|
const out = coalesceParts([orphan])
|
|
192
197
|
expect(out).toEqual([orphan])
|
|
193
198
|
})
|
|
199
|
+
|
|
200
|
+
test('an approval-responded seen BEFORE its request stays its own block — the fold never backtracks', () => {
|
|
201
|
+
// The approvalId side index only holds ids seen so far, so a response arriving first cannot
|
|
202
|
+
// resolve. Deliberate: reconciling backwards would let the request (the LATER arrival) win
|
|
203
|
+
// under "later state wins" and re-render an already-answered call as still pending.
|
|
204
|
+
const respondedFirst: ToolCallPart = {
|
|
205
|
+
id: 'p1',
|
|
206
|
+
type: 'tool',
|
|
207
|
+
toolName: 'delete_file',
|
|
208
|
+
state: 'approval-responded',
|
|
209
|
+
input: { path: '/etc/passwd' },
|
|
210
|
+
approval: { id: 'appr-1', approved: true },
|
|
211
|
+
}
|
|
212
|
+
const requestedLater: ToolCallPart = {
|
|
213
|
+
id: 'p2',
|
|
214
|
+
type: 'tool',
|
|
215
|
+
toolCallId: 'call-1',
|
|
216
|
+
toolName: 'delete_file',
|
|
217
|
+
state: 'approval-requested',
|
|
218
|
+
input: { path: '/etc/passwd' },
|
|
219
|
+
approval: { id: 'appr-1' },
|
|
220
|
+
}
|
|
221
|
+
const outputAvailable: ToolCallPart = {
|
|
222
|
+
id: 'p3',
|
|
223
|
+
type: 'tool',
|
|
224
|
+
toolCallId: 'call-1',
|
|
225
|
+
toolName: 'delete_file',
|
|
226
|
+
state: 'output-available',
|
|
227
|
+
input: { path: '/etc/passwd' },
|
|
228
|
+
output: { deleted: true },
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const out = coalesceParts([respondedFirst, requestedLater, outputAvailable])
|
|
232
|
+
expect(out).toHaveLength(2)
|
|
233
|
+
// The orphan survives untouched at its original position — never dropped, never rewritten.
|
|
234
|
+
expect(out[0]).toEqual(respondedFirst)
|
|
235
|
+
// Everything from the request onward folds into ONE second block, not a third.
|
|
236
|
+
expect((out[1] as ToolCallPart).state).toBe('output-available')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('a failed call keeps NO stale output from the preliminary result it replaces', () => {
|
|
240
|
+
// The defect a blind `{ ...existing, ...next }` spread produced: output-error has no `output`
|
|
241
|
+
// key, so a spread let the preliminary result's `output`/`preliminary` ride along and any
|
|
242
|
+
// consumer branching on `output` before `errorText` rendered a failed call as successful.
|
|
243
|
+
const preliminaryResult: ToolCallPart = {
|
|
244
|
+
id: 'p1',
|
|
245
|
+
type: 'tool',
|
|
246
|
+
toolCallId: 'call-1',
|
|
247
|
+
toolName: 'search',
|
|
248
|
+
state: 'output-available',
|
|
249
|
+
input: { q: 'x' },
|
|
250
|
+
output: { hits: 1, partial: true },
|
|
251
|
+
preliminary: true,
|
|
252
|
+
}
|
|
253
|
+
const failed: ToolCallPart = {
|
|
254
|
+
id: 'p2',
|
|
255
|
+
type: 'tool',
|
|
256
|
+
toolCallId: 'call-1',
|
|
257
|
+
toolName: 'search',
|
|
258
|
+
state: 'output-error',
|
|
259
|
+
errorText: 'upstream timed out',
|
|
260
|
+
durationMs: 900,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const out = coalesceParts([preliminaryResult, failed])
|
|
264
|
+
expect(out).toHaveLength(1)
|
|
265
|
+
const merged = out[0] as ToolCallPart
|
|
266
|
+
expect(merged.state).toBe('output-error')
|
|
267
|
+
expect(Object.hasOwn(merged, 'output')).toBe(false)
|
|
268
|
+
expect(Object.hasOwn(merged, 'preliminary')).toBe(false)
|
|
269
|
+
expect(merged).toEqual({
|
|
270
|
+
id: 'p2',
|
|
271
|
+
type: 'tool',
|
|
272
|
+
toolCallId: 'call-1',
|
|
273
|
+
toolName: 'search',
|
|
274
|
+
durationMs: 900,
|
|
275
|
+
state: 'output-error',
|
|
276
|
+
errorText: 'upstream timed out',
|
|
277
|
+
// input is the one thing legitimately carried across the transition.
|
|
278
|
+
input: { q: 'x' },
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
test('a SETTLED part that arrived unidentified keeps the identity an earlier state supplied', () => {
|
|
283
|
+
// `toolCallId` is optional on the four settled states (parts.ts, MaybeIdentified). A terminal
|
|
284
|
+
// part that resolved only through the approvalId side index must come out of the merge WITH the
|
|
285
|
+
// id the request carried — losing it here would unfold the block on the next pass.
|
|
286
|
+
const approvalRequested: ToolCallPart = {
|
|
287
|
+
id: 'p1',
|
|
288
|
+
type: 'tool',
|
|
289
|
+
toolCallId: 'call-1',
|
|
290
|
+
toolName: 'transfer_funds',
|
|
291
|
+
state: 'approval-requested',
|
|
292
|
+
input: { amount: 250 },
|
|
293
|
+
approval: { id: 'appr-1' },
|
|
294
|
+
}
|
|
295
|
+
const unidentifiedResult: ToolCallPart = {
|
|
296
|
+
id: 'p2',
|
|
297
|
+
type: 'tool',
|
|
298
|
+
toolName: 'transfer_funds',
|
|
299
|
+
state: 'output-available',
|
|
300
|
+
input: { amount: 250 },
|
|
301
|
+
output: { ok: true },
|
|
302
|
+
approval: { id: 'appr-1', approved: true },
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const out = coalesceParts([approvalRequested, unidentifiedResult])
|
|
306
|
+
expect(out).toHaveLength(1)
|
|
307
|
+
const merged = out[0] as ToolCallPart
|
|
308
|
+
expect(merged.state).toBe('output-available')
|
|
309
|
+
expect(merged.toolCallId).toBe('call-1')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// ── never throws ───────────────────────────────────────────────────────────
|
|
313
|
+
// The tool-state switch is exhaustive at COMPILE time only. coalesceParts is public, runs on the
|
|
314
|
+
// render path, and folds wire data — an unrecognized state must degrade, not abort the render.
|
|
315
|
+
|
|
316
|
+
test('an unrecognized tool state does not throw and yields a sensible merged part', () => {
|
|
317
|
+
const inputAvailable: ToolCallPart = {
|
|
318
|
+
id: 'p1',
|
|
319
|
+
type: 'tool',
|
|
320
|
+
toolCallId: 'call-1',
|
|
321
|
+
toolName: 'search',
|
|
322
|
+
state: 'input-available',
|
|
323
|
+
input: { q: 'x' },
|
|
324
|
+
}
|
|
325
|
+
// A state no version of the union declares — what a newer server would send to an older client.
|
|
326
|
+
const futureState = {
|
|
327
|
+
id: 'p2',
|
|
328
|
+
type: 'tool',
|
|
329
|
+
toolCallId: 'call-1',
|
|
330
|
+
toolName: '',
|
|
331
|
+
state: 'output-streaming',
|
|
332
|
+
output: { partial: true },
|
|
333
|
+
} as unknown as ToolCallPart
|
|
334
|
+
|
|
335
|
+
const warnings: string[] = []
|
|
336
|
+
const original = console.warn
|
|
337
|
+
console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
|
|
338
|
+
let out: ToolCallPart[]
|
|
339
|
+
try {
|
|
340
|
+
out = coalesceParts([inputAvailable, futureState]) as ToolCallPart[]
|
|
341
|
+
} finally {
|
|
342
|
+
console.warn = original
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
expect(out).toHaveLength(1)
|
|
346
|
+
// `state` widened to `string` — the whole point of this test is a state value the
|
|
347
|
+
// `ToolCallState` union does NOT declare (a future server sending an unknown state to an
|
|
348
|
+
// older client), so the real, narrower type would reject the very literal being asserted.
|
|
349
|
+
const merged = out[0] as Omit<ToolCallPart, 'state'> & { output?: unknown; state: string }
|
|
350
|
+
// The incoming part survives intact…
|
|
351
|
+
expect(merged.state).toBe('output-streaming')
|
|
352
|
+
expect(merged.output).toEqual({ partial: true })
|
|
353
|
+
// …with the whitelisted carry-over fields folded in, exactly as a known state would get.
|
|
354
|
+
expect(merged.toolName).toBe('search')
|
|
355
|
+
expect(merged.toolCallId).toBe('call-1')
|
|
356
|
+
// …and the anomaly is announced in dev rather than thrown.
|
|
357
|
+
expect(warnings).toHaveLength(1)
|
|
358
|
+
expect(warnings[0]).toContain('unrecognized tool state')
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
test('an approval-requested that later succeeds does not carry its pending approval onto the result', () => {
|
|
362
|
+
const approvalRequested: ToolCallPart = {
|
|
363
|
+
id: 'p1',
|
|
364
|
+
type: 'tool',
|
|
365
|
+
toolCallId: 'call-1',
|
|
366
|
+
toolName: 'transfer_funds',
|
|
367
|
+
state: 'approval-requested',
|
|
368
|
+
input: { amount: 250 },
|
|
369
|
+
approval: { id: 'appr-1' },
|
|
370
|
+
}
|
|
371
|
+
const outputAvailable: ToolCallPart = {
|
|
372
|
+
id: 'p2',
|
|
373
|
+
type: 'tool',
|
|
374
|
+
toolCallId: 'call-1',
|
|
375
|
+
toolName: 'transfer_funds',
|
|
376
|
+
state: 'output-available',
|
|
377
|
+
input: { amount: 250 },
|
|
378
|
+
output: { ok: true },
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const out = coalesceParts([approvalRequested, outputAvailable])
|
|
382
|
+
expect(out).toHaveLength(1)
|
|
383
|
+
// `approval` comes from `next` alone: a pending request envelope (no verdict) must not surface
|
|
384
|
+
// on a settled result, where the type only admits `{ approved?: true }`.
|
|
385
|
+
expect(Object.hasOwn(out[0] as object, 'approval')).toBe(false)
|
|
386
|
+
})
|
|
194
387
|
})
|