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
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* aiSdkTransport — snapshot→delta diffing (text/reasoning/source/tool),
|
|
3
|
-
* and deterministic chat-id binding via `.forThread()`.
|
|
2
|
+
* aiSdkTransport — snapshot→delta diffing (text/reasoning/source/tool), deterministic id minting,
|
|
3
|
+
* durationMs, signal-abort mid-stream, and deterministic chat-id binding via `.forThread()`.
|
|
4
4
|
*
|
|
5
|
-
* Scope: the diffing behaviour
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Scope: the diffing behaviour post-1.11.0's B2 rewrite — `diffToolPart` now models all seven
|
|
6
|
+
* `UIToolInvocation` states (including `input-streaming`, the nested `approval` envelope, and
|
|
7
|
+
* `output-denied`), mints deterministic ids (`${chatId}#${index}` for index-addressed parts,
|
|
8
|
+
* `tool#${toolCallId}` for tool parts), carries an authoritative `offset` on every text/reasoning
|
|
9
|
+
* delta, derives a dynamic tool's real name instead of the literal `'dynamic-tool'`, and does not
|
|
10
|
+
* suppress a `preliminary: true` output-available refinement.
|
|
9
11
|
*
|
|
10
12
|
* Driving `aiSdkTransport` through a scripted HTTP response — the only way to exercise its public
|
|
11
13
|
* stream()/resume() diffing at all, since the diffing internals are not exported — requires a real
|
|
@@ -18,8 +20,10 @@
|
|
|
18
20
|
import { describe, expect, test } from 'bun:test'
|
|
19
21
|
import { JsonToSseTransformStream } from 'ai'
|
|
20
22
|
import { aiSdkTransport } from './ai-sdk-transport'
|
|
23
|
+
import { withPartIds } from './id'
|
|
24
|
+
import { mergePart } from './merge'
|
|
21
25
|
import type { UIMessageChunk } from 'ai'
|
|
22
|
-
import type { AgentPart } from './parts'
|
|
26
|
+
import type { AgentPart, ToolCallPart } from './parts'
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* Builds a fetch-compatible Response whose body is the given low-level UIMessageChunks, SSE-
|
|
@@ -39,6 +43,16 @@ function scriptedResponse(chunks: UIMessageChunk[]): Response {
|
|
|
39
43
|
return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
40
44
|
}
|
|
41
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
|
+
|
|
42
56
|
/** Drains an aiSdkTransport `.stream()`/`.resume()` async generator into a plain array. */
|
|
43
57
|
async function collect(gen: AsyncGenerator<AgentPart>): Promise<AgentPart[]> {
|
|
44
58
|
const parts: AgentPart[] = []
|
|
@@ -46,8 +60,28 @@ async function collect(gen: AsyncGenerator<AgentPart>): Promise<AgentPart[]> {
|
|
|
46
60
|
return parts
|
|
47
61
|
}
|
|
48
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
|
+
|
|
71
|
+
/** Strips a non-deterministic `durationMs` (wall-clock, so never equality-comparable) before an
|
|
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'> {
|
|
79
|
+
const { durationMs: _durationMs, ...rest } = part
|
|
80
|
+
return rest as DistributiveOmit<T, 'durationMs'>
|
|
81
|
+
}
|
|
82
|
+
|
|
49
83
|
describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
50
|
-
test('yields a synthesized start part first, then diffs text deltas across snapshots', async () => {
|
|
84
|
+
test('yields a synthesized start part first, with a deterministic id, then diffs text deltas across snapshots with an authoritative offset', async () => {
|
|
51
85
|
const chunks: UIMessageChunk[] = [
|
|
52
86
|
{ type: 'text-start', id: 't1' },
|
|
53
87
|
{ type: 'text-delta', id: 't1', delta: 'Hel' },
|
|
@@ -56,23 +90,57 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
56
90
|
]
|
|
57
91
|
const transport = aiSdkTransport({
|
|
58
92
|
api: '/api/chat',
|
|
59
|
-
fetch: async () => scriptedResponse(chunks),
|
|
93
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
60
94
|
})
|
|
61
95
|
const parts = await collect(transport.stream('hi'))
|
|
62
96
|
|
|
63
97
|
expect(parts[0]?.type).toBe('start')
|
|
64
|
-
|
|
98
|
+
expect(typeof (parts[0] as { id: string }).id).toBe('string')
|
|
99
|
+
const textParts = parts.filter(
|
|
100
|
+
(p): p is { id: string; type: 'text'; text: string; offset: number } => p.type === 'text',
|
|
101
|
+
)
|
|
65
102
|
// AI SDK's snapshot carries the FULL accumulated text at every write — the module's whole job
|
|
66
103
|
// is re-deriving the two incremental deltas ('Hel' then 'lo') rather than replaying 'Hello'
|
|
67
|
-
// twice or once whole.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
104
|
+
// twice or once whole. Both deltas share ONE id (the same message-part index) so mergePart
|
|
105
|
+
// splices them into a single TextPart; `offset` is the length of the previously-seen text.
|
|
106
|
+
expect(textParts.map((p) => p.text)).toEqual(['Hel', 'lo'])
|
|
107
|
+
expect(textParts.map((p) => p.offset)).toEqual([0, 3])
|
|
108
|
+
expect(new Set(textParts.map((p) => p.id)).size).toBe(1)
|
|
72
109
|
expect(textParts.reduce((acc, p) => acc + p.text, '')).toBe('Hello')
|
|
73
110
|
})
|
|
74
111
|
|
|
75
|
-
|
|
112
|
+
// F1: withPartIds is only ever a normalizer for DRAFTS (id-less parts). aiSdkTransport mints its
|
|
113
|
+
// own deterministic, content-stable ids for every part it yields (`${chatId}#${index}`,
|
|
114
|
+
// `tool#${toolCallId}`, `${chatId}#start`), so wrapping its output in withPartIds must be a pure
|
|
115
|
+
// no-op passthrough — proves the two hooks wiring withPartIds in unconditionally does not disturb
|
|
116
|
+
// a transport that already identifies its own parts.
|
|
117
|
+
test('withPartIds is a no-op over aiSdkTransport output — every part already carries a stable id', async () => {
|
|
118
|
+
const chunks: UIMessageChunk[] = [
|
|
119
|
+
{ type: 'text-start', id: 't1' },
|
|
120
|
+
{ type: 'text-delta', id: 't1', delta: 'Hel' },
|
|
121
|
+
{ type: 'text-delta', id: 't1', delta: 'lo' },
|
|
122
|
+
{ type: 'text-end', id: 't1' },
|
|
123
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
|
|
124
|
+
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
|
|
125
|
+
{ type: 'tool-output-available', toolCallId: 'call-1', output: { hits: 3 } },
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
// Same transport instance for both sides — aiSdkTransport mints its chat id at CONSTRUCTION
|
|
129
|
+
// time, so two separately-constructed transports would mint two different chat ids and
|
|
130
|
+
// trivially fail to match on that alone, masking whether withPartIds itself changed anything.
|
|
131
|
+
const transport = aiSdkTransport({
|
|
132
|
+
api: '/api/chat',
|
|
133
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
134
|
+
})
|
|
135
|
+
const direct = await collect(transport.stream('hi'))
|
|
136
|
+
const wrapped = await collect(withPartIds('unrelated-run-id', transport.stream('hi')))
|
|
137
|
+
|
|
138
|
+
expect(wrapped.map(withoutDurationMs)).toEqual(direct.map(withoutDurationMs))
|
|
139
|
+
// Every part already had an id — withPartIds' counter never had anything id-less to stamp.
|
|
140
|
+
expect(direct.every((part) => typeof (part as { id: string }).id === 'string')).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('diffs reasoning deltas the same way as text, with the same id/offset mechanism', async () => {
|
|
76
144
|
const chunks: UIMessageChunk[] = [
|
|
77
145
|
{ type: 'reasoning-start', id: 'r1' },
|
|
78
146
|
{ type: 'reasoning-delta', id: 'r1', delta: 'thinking ' },
|
|
@@ -81,32 +149,41 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
81
149
|
]
|
|
82
150
|
const transport = aiSdkTransport({
|
|
83
151
|
api: '/api/chat',
|
|
84
|
-
fetch: async () => scriptedResponse(chunks),
|
|
152
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
85
153
|
})
|
|
86
154
|
const parts = await collect(transport.stream('hi'))
|
|
87
155
|
|
|
88
|
-
const reasoningParts = parts.filter((p) => p.type === 'reasoning')
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
]
|
|
156
|
+
const reasoningParts = parts.filter((p) => p.type === 'reasoning') as {
|
|
157
|
+
id: string
|
|
158
|
+
text: string
|
|
159
|
+
offset: number
|
|
160
|
+
}[]
|
|
161
|
+
expect(reasoningParts.map((p) => p.text)).toEqual(['thinking ', 'more'])
|
|
162
|
+
expect(reasoningParts.map((p) => p.offset)).toEqual([0, 9])
|
|
163
|
+
expect(new Set(reasoningParts.map((p) => p.id)).size).toBe(1)
|
|
93
164
|
})
|
|
94
165
|
|
|
95
|
-
test('emits a source part exactly once for a source-url chunk', async () => {
|
|
166
|
+
test('emits a source part exactly once for a source-url chunk, with a deterministic id', async () => {
|
|
96
167
|
const chunks: UIMessageChunk[] = [
|
|
97
168
|
{ type: 'source-url', sourceId: 's1', url: 'https://example.com', title: 'Example' },
|
|
98
169
|
]
|
|
99
170
|
const transport = aiSdkTransport({
|
|
100
171
|
api: '/api/chat',
|
|
101
|
-
fetch: async () => scriptedResponse(chunks),
|
|
172
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
102
173
|
})
|
|
103
174
|
const parts = await collect(transport.stream('hi'))
|
|
104
175
|
|
|
105
176
|
const sourceParts = parts.filter((p) => p.type === 'source')
|
|
106
|
-
expect(sourceParts).
|
|
177
|
+
expect(sourceParts).toHaveLength(1)
|
|
178
|
+
expect(sourceParts[0]).toMatchObject({
|
|
179
|
+
type: 'source',
|
|
180
|
+
url: 'https://example.com',
|
|
181
|
+
title: 'Example',
|
|
182
|
+
})
|
|
183
|
+
expect(typeof (sourceParts[0] as { id: string }).id).toBe('string')
|
|
107
184
|
})
|
|
108
185
|
|
|
109
|
-
test('a tool call progresses input-available → output-available
|
|
186
|
+
test('a tool call progresses input-streaming → input-available → output-available, one draft per state, addressed by tool#<toolCallId>', async () => {
|
|
110
187
|
const chunks: UIMessageChunk[] = [
|
|
111
188
|
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
|
|
112
189
|
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
|
|
@@ -114,23 +191,45 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
114
191
|
]
|
|
115
192
|
const transport = aiSdkTransport({
|
|
116
193
|
api: '/api/chat',
|
|
117
|
-
fetch: async () => scriptedResponse(chunks),
|
|
194
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
118
195
|
})
|
|
119
196
|
const parts = await collect(transport.stream('hi'))
|
|
120
197
|
|
|
121
198
|
const toolParts = parts.filter((p) => p.type === 'tool')
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
199
|
+
expect(toolParts.every((p) => (p as { id: string }).id === 'tool#call-1')).toBe(true)
|
|
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'>[] = [
|
|
204
|
+
{
|
|
205
|
+
id: 'tool#call-1',
|
|
206
|
+
type: 'tool',
|
|
207
|
+
state: 'input-streaming',
|
|
208
|
+
toolName: 'search',
|
|
209
|
+
toolCallId: 'call-1',
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: 'tool#call-1',
|
|
213
|
+
type: 'tool',
|
|
214
|
+
state: 'input-available',
|
|
215
|
+
toolName: 'search',
|
|
216
|
+
toolCallId: 'call-1',
|
|
217
|
+
input: { q: 'x' },
|
|
218
|
+
},
|
|
126
219
|
{
|
|
220
|
+
id: 'tool#call-1',
|
|
127
221
|
type: 'tool',
|
|
222
|
+
state: 'output-available',
|
|
128
223
|
toolName: 'search',
|
|
129
224
|
toolCallId: 'call-1',
|
|
130
225
|
input: { q: 'x' },
|
|
131
226
|
output: { hits: 3 },
|
|
132
227
|
},
|
|
133
|
-
]
|
|
228
|
+
]
|
|
229
|
+
expect(toolParts.map(withoutDurationMs)).toEqual(expectedToolParts)
|
|
230
|
+
// durationMs is only set once the call reaches a terminal state (output-available here).
|
|
231
|
+
expect((toolParts[0] as { durationMs?: number }).durationMs).toBeUndefined()
|
|
232
|
+
expect(typeof (toolParts.at(-1) as { durationMs?: number }).durationMs).toBe('number')
|
|
134
233
|
})
|
|
135
234
|
|
|
136
235
|
test('a repeated chunk carrying the SAME tool state is deduped — nothing new to report', async () => {
|
|
@@ -143,19 +242,146 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
143
242
|
]
|
|
144
243
|
const transport = aiSdkTransport({
|
|
145
244
|
api: '/api/chat',
|
|
146
|
-
fetch: async () => scriptedResponse(chunks),
|
|
245
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
246
|
+
})
|
|
247
|
+
const parts = await collect(transport.stream('hi'))
|
|
248
|
+
|
|
249
|
+
const toolParts = parts.filter((p) => p.type === 'tool')
|
|
250
|
+
// input-streaming once, input-available once (redundant resend deduped), output-available
|
|
251
|
+
// once — three states, not four.
|
|
252
|
+
expect(toolParts).toHaveLength(3)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('a preliminary output-available result emits EVERY refinement, not just the first (bug: state-equality alone would freeze on the first snapshot)', async () => {
|
|
256
|
+
const chunks: UIMessageChunk[] = [
|
|
257
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
|
|
258
|
+
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
|
|
259
|
+
{
|
|
260
|
+
type: 'tool-output-available',
|
|
261
|
+
toolCallId: 'call-1',
|
|
262
|
+
output: { hits: 1 },
|
|
263
|
+
preliminary: true,
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
type: 'tool-output-available',
|
|
267
|
+
toolCallId: 'call-1',
|
|
268
|
+
output: { hits: 5 },
|
|
269
|
+
preliminary: true,
|
|
270
|
+
},
|
|
271
|
+
{ type: 'tool-output-available', toolCallId: 'call-1', output: { hits: 5, done: true } },
|
|
272
|
+
]
|
|
273
|
+
const transport = aiSdkTransport({
|
|
274
|
+
api: '/api/chat',
|
|
275
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
276
|
+
})
|
|
277
|
+
const parts = await collect(transport.stream('hi'))
|
|
278
|
+
|
|
279
|
+
const outputs = parts
|
|
280
|
+
.filter((p) => p.type === 'tool' && p.state === 'output-available')
|
|
281
|
+
.map((p) => (p as { output: unknown; preliminary?: boolean }).output)
|
|
282
|
+
// All THREE refinements surfaced — a naive `prev.state === curr.state` dedup would have
|
|
283
|
+
// dropped the second and third (state stays 'output-available' throughout).
|
|
284
|
+
expect(outputs).toEqual([{ hits: 1 }, { hits: 5 }, { hits: 5, done: true }])
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('approval-requested → approval-responded carries the nested approval envelope through verbatim (isAutomatic and signature survive)', async () => {
|
|
288
|
+
const chunks: UIMessageChunk[] = [
|
|
289
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'delete-file' },
|
|
290
|
+
{
|
|
291
|
+
type: 'tool-input-available',
|
|
292
|
+
toolCallId: 'call-1',
|
|
293
|
+
toolName: 'delete-file',
|
|
294
|
+
input: { path: '/x' },
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
type: 'tool-approval-request',
|
|
298
|
+
approvalId: 'appr-1',
|
|
299
|
+
toolCallId: 'call-1',
|
|
300
|
+
isAutomatic: true,
|
|
301
|
+
signature: 'sig-xyz',
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
type: 'tool-approval-response',
|
|
305
|
+
approvalId: 'appr-1',
|
|
306
|
+
approved: true,
|
|
307
|
+
reason: 'looks safe',
|
|
308
|
+
},
|
|
309
|
+
]
|
|
310
|
+
const transport = aiSdkTransport({
|
|
311
|
+
api: '/api/chat',
|
|
312
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
313
|
+
})
|
|
314
|
+
const parts = await collect(transport.stream('hi'))
|
|
315
|
+
|
|
316
|
+
const toolParts = parts.filter((p) => p.type === 'tool') as {
|
|
317
|
+
state: string
|
|
318
|
+
approval?: {
|
|
319
|
+
id: string
|
|
320
|
+
approved?: boolean
|
|
321
|
+
reason?: string
|
|
322
|
+
isAutomatic?: boolean
|
|
323
|
+
signature?: string
|
|
324
|
+
}
|
|
325
|
+
}[]
|
|
326
|
+
|
|
327
|
+
const requested = toolParts.find((p) => p.state === 'approval-requested')
|
|
328
|
+
// signature only exists on the SDK's approval-requested envelope — this pins that OUR diffing
|
|
329
|
+
// does not drop it (a flattened `approvalId`/`approved` shape, the spec's own error, would).
|
|
330
|
+
expect(requested?.approval).toEqual({ id: 'appr-1', isAutomatic: true, signature: 'sig-xyz' })
|
|
331
|
+
|
|
332
|
+
const responded = toolParts.find((p) => p.state === 'approval-responded')
|
|
333
|
+
// AI SDK's OWN runtime carries `isAutomatic` forward into approval-responded but drops
|
|
334
|
+
// `signature` there (verified against dist/index.js) — this is the SDK's behavior, not a bug
|
|
335
|
+
// in this diffing layer; what this pins is that basalt passes whatever the SDK gives it
|
|
336
|
+
// through unflattened, not that basalt itself re-adds a dropped field.
|
|
337
|
+
expect(responded?.approval).toEqual({
|
|
338
|
+
id: 'appr-1',
|
|
339
|
+
approved: true,
|
|
340
|
+
reason: 'looks safe',
|
|
341
|
+
isAutomatic: true,
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test('output-denied is unambiguously terminal — no output, no errorText, just the explicit state + a settled durationMs', async () => {
|
|
346
|
+
const chunks: UIMessageChunk[] = [
|
|
347
|
+
{ type: 'tool-input-start', toolCallId: 'call-2', toolName: 'delete-file' },
|
|
348
|
+
{
|
|
349
|
+
type: 'tool-input-available',
|
|
350
|
+
toolCallId: 'call-2',
|
|
351
|
+
toolName: 'delete-file',
|
|
352
|
+
input: { path: '/etc' },
|
|
353
|
+
},
|
|
354
|
+
{ type: 'tool-approval-request', approvalId: 'appr-2', toolCallId: 'call-2' },
|
|
355
|
+
{ type: 'tool-approval-response', approvalId: 'appr-2', approved: false, reason: 'blocked' },
|
|
356
|
+
{ type: 'tool-output-denied', toolCallId: 'call-2' },
|
|
357
|
+
]
|
|
358
|
+
const transport = aiSdkTransport({
|
|
359
|
+
api: '/api/chat',
|
|
360
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
147
361
|
})
|
|
148
362
|
const parts = await collect(transport.stream('hi'))
|
|
149
363
|
|
|
150
364
|
const toolParts = parts.filter((p) => p.type === 'tool')
|
|
151
|
-
|
|
365
|
+
const denied = toolParts.at(-1) as {
|
|
366
|
+
state: string
|
|
367
|
+
output?: unknown
|
|
368
|
+
errorText?: unknown
|
|
369
|
+
input: unknown
|
|
370
|
+
approval?: unknown
|
|
371
|
+
durationMs?: number
|
|
372
|
+
}
|
|
373
|
+
expect(denied.state).toBe('output-denied')
|
|
374
|
+
expect('output' in denied).toBe(false)
|
|
375
|
+
expect('errorText' in denied).toBe(false)
|
|
376
|
+
expect(denied.input).toEqual({ path: '/etc' })
|
|
377
|
+
expect(denied.approval).toEqual({ id: 'appr-2', approved: false, reason: 'blocked' })
|
|
378
|
+
expect(typeof denied.durationMs).toBe('number')
|
|
152
379
|
})
|
|
153
380
|
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
test('output-error is currently smuggled through `output`, not a dedicated error field', async () => {
|
|
381
|
+
// ToolCallPart's `output-error` state carries a dedicated `errorText` field (the SDK's own field
|
|
382
|
+
// name — there is no field named `error` anywhere in the union). Stuffing the failure into
|
|
383
|
+
// `output` is gone: `output-error` doesn't even have an `output` field to smuggle it through.
|
|
384
|
+
test('output-error surfaces a dedicated `errorText` field, not `output`, with input carried forward', async () => {
|
|
159
385
|
const chunks: UIMessageChunk[] = [
|
|
160
386
|
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
|
|
161
387
|
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
|
|
@@ -163,19 +389,46 @@ describe('aiSdkTransport — snapshot→delta diffing', () => {
|
|
|
163
389
|
]
|
|
164
390
|
const transport = aiSdkTransport({
|
|
165
391
|
api: '/api/chat',
|
|
166
|
-
fetch: async () => scriptedResponse(chunks),
|
|
392
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
167
393
|
})
|
|
168
394
|
const parts = await collect(transport.stream('hi'))
|
|
169
395
|
|
|
170
396
|
const toolParts = parts.filter((p) => p.type === 'tool')
|
|
171
397
|
const failed = toolParts.at(-1)
|
|
172
|
-
expect(failed).toEqual({
|
|
398
|
+
expect(withoutDurationMs(failed as { durationMs?: number })).toEqual({
|
|
399
|
+
id: 'tool#call-1',
|
|
173
400
|
type: 'tool',
|
|
401
|
+
state: 'output-error',
|
|
174
402
|
toolName: 'search',
|
|
175
403
|
toolCallId: 'call-1',
|
|
176
404
|
input: { q: 'x' },
|
|
177
|
-
|
|
405
|
+
errorText: 'boom',
|
|
406
|
+
})
|
|
407
|
+
expect('output' in (failed as object)).toBe(false)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
test('a dynamic tool call yields its REAL toolName, not the literal "dynamic-tool"', async () => {
|
|
411
|
+
const chunks: UIMessageChunk[] = [
|
|
412
|
+
{ type: 'tool-input-start', toolCallId: 'call-4', toolName: 'custom-lookup', dynamic: true },
|
|
413
|
+
{
|
|
414
|
+
type: 'tool-input-available',
|
|
415
|
+
toolCallId: 'call-4',
|
|
416
|
+
toolName: 'custom-lookup',
|
|
417
|
+
input: { id: 7 },
|
|
418
|
+
dynamic: true,
|
|
419
|
+
},
|
|
420
|
+
{ type: 'tool-output-available', toolCallId: 'call-4', output: { found: true } },
|
|
421
|
+
]
|
|
422
|
+
const transport = aiSdkTransport({
|
|
423
|
+
api: '/api/chat',
|
|
424
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
178
425
|
})
|
|
426
|
+
const parts = await collect(transport.stream('hi'))
|
|
427
|
+
|
|
428
|
+
const toolParts = parts.filter((p) => p.type === 'tool') as { toolName: string; id: string }[]
|
|
429
|
+
expect(toolParts.length).toBeGreaterThan(0)
|
|
430
|
+
expect(toolParts.every((p) => p.toolName === 'custom-lookup')).toBe(true)
|
|
431
|
+
expect(toolParts.every((p) => p.id === 'tool#call-4')).toBe(true)
|
|
179
432
|
})
|
|
180
433
|
})
|
|
181
434
|
|
|
@@ -189,7 +442,7 @@ describe('aiSdkTransport — signal.aborted stops the yield', () => {
|
|
|
189
442
|
]
|
|
190
443
|
const transport = aiSdkTransport({
|
|
191
444
|
api: '/api/chat',
|
|
192
|
-
fetch: async () => scriptedResponse(chunks),
|
|
445
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
193
446
|
})
|
|
194
447
|
const controller = new AbortController()
|
|
195
448
|
const gen = transport.stream('hi', controller.signal)
|
|
@@ -197,14 +450,14 @@ describe('aiSdkTransport — signal.aborted stops the yield', () => {
|
|
|
197
450
|
const start = await gen.next()
|
|
198
451
|
expect(start.value).toMatchObject({ type: 'start' })
|
|
199
452
|
const firstDelta = await gen.next()
|
|
200
|
-
expect(firstDelta.value).
|
|
453
|
+
expect(firstDelta.value).toMatchObject({ type: 'text', text: 'a', offset: 0 })
|
|
201
454
|
|
|
202
455
|
// Let AI SDK's background readUIMessageStream pump (kicked off inside diffChunkStream, and
|
|
203
456
|
// NOT gated on our own reads) fully settle before aborting — this is inherent to the library,
|
|
204
457
|
// not to the AgentTransport code under test: aborting while chunks are still in flight through
|
|
205
458
|
// that pump races diffChunkStream's early return against the pump's own stream teardown. A
|
|
206
459
|
// short real-time wait avoids exercising that unrelated race so this test isolates exactly
|
|
207
|
-
// ai-sdk-transport.ts
|
|
460
|
+
// ai-sdk-transport.ts's `if (signal?.aborted) return`.
|
|
208
461
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
209
462
|
|
|
210
463
|
controller.abort()
|
|
@@ -220,9 +473,9 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
|
|
|
220
473
|
// generator that never advances past its first `.next()` never touches the network.
|
|
221
474
|
const transport = aiSdkTransport({
|
|
222
475
|
api: '/api/chat',
|
|
223
|
-
fetch: () => {
|
|
476
|
+
fetch: mockFetch(() => {
|
|
224
477
|
throw new Error('must not be called — only the first, pre-network yield is read')
|
|
225
|
-
},
|
|
478
|
+
}),
|
|
226
479
|
})
|
|
227
480
|
|
|
228
481
|
const first = await transport.stream('a').next()
|
|
@@ -231,12 +484,12 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
|
|
|
231
484
|
expect((first.value as { runId: string }).runId).toBe((second.value as { runId: string }).runId)
|
|
232
485
|
})
|
|
233
486
|
|
|
234
|
-
test('.forThread(id) binds the StartPart runId/resumeToken deterministically to the given id', async () => {
|
|
487
|
+
test('.forThread(id) binds the StartPart runId/resumeToken/id deterministically to the given id', async () => {
|
|
235
488
|
const transport = aiSdkTransport({
|
|
236
489
|
api: '/api/chat',
|
|
237
|
-
fetch: () => {
|
|
490
|
+
fetch: mockFetch(() => {
|
|
238
491
|
throw new Error('must not be called — only the first, pre-network yield is read')
|
|
239
|
-
},
|
|
492
|
+
}),
|
|
240
493
|
})
|
|
241
494
|
const bound = transport.forThread('caller-supplied-thread-id')
|
|
242
495
|
|
|
@@ -244,6 +497,7 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
|
|
|
244
497
|
const second = await bound.stream('second turn').next()
|
|
245
498
|
|
|
246
499
|
expect(first.value).toEqual({
|
|
500
|
+
id: 'caller-supplied-thread-id#start',
|
|
247
501
|
type: 'start',
|
|
248
502
|
runId: 'caller-supplied-thread-id',
|
|
249
503
|
resumeToken: 'caller-supplied-thread-id',
|
|
@@ -252,18 +506,89 @@ describe('aiSdkTransport — deterministic chat-id binding', () => {
|
|
|
252
506
|
expect(second.value).toEqual(first.value)
|
|
253
507
|
})
|
|
254
508
|
|
|
255
|
-
test('two different .forThread() ids produce two different StartPart runIds', async () => {
|
|
509
|
+
test('two different .forThread() ids produce two different StartPart runIds/ids', async () => {
|
|
256
510
|
const transport = aiSdkTransport({
|
|
257
511
|
api: '/api/chat',
|
|
258
|
-
fetch: () => {
|
|
512
|
+
fetch: mockFetch(() => {
|
|
259
513
|
throw new Error('must not be called — only the first, pre-network yield is read')
|
|
260
|
-
},
|
|
514
|
+
}),
|
|
261
515
|
})
|
|
262
516
|
|
|
263
517
|
const a = await transport.forThread('thread-a').stream('hi').next()
|
|
264
518
|
const b = await transport.forThread('thread-b').stream('hi').next()
|
|
265
519
|
|
|
266
|
-
expect(a.value).toEqual({
|
|
267
|
-
|
|
520
|
+
expect(a.value).toEqual({
|
|
521
|
+
id: 'thread-a#start',
|
|
522
|
+
type: 'start',
|
|
523
|
+
runId: 'thread-a',
|
|
524
|
+
resumeToken: 'thread-a',
|
|
525
|
+
})
|
|
526
|
+
expect(b.value).toEqual({
|
|
527
|
+
id: 'thread-b#start',
|
|
528
|
+
type: 'start',
|
|
529
|
+
runId: 'thread-b',
|
|
530
|
+
resumeToken: 'thread-b',
|
|
531
|
+
})
|
|
532
|
+
})
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
describe('aiSdkTransport — deterministic ids and replay idempotency', () => {
|
|
536
|
+
test('the same snapshot sequence produces the same ids twice', async () => {
|
|
537
|
+
const chunks: UIMessageChunk[] = [
|
|
538
|
+
{ type: 'text-start', id: 't1' },
|
|
539
|
+
{ type: 'text-delta', id: 't1', delta: 'Hello' },
|
|
540
|
+
{ type: 'text-end', id: 't1' },
|
|
541
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'search' },
|
|
542
|
+
{ type: 'tool-input-available', toolCallId: 'call-1', toolName: 'search', input: { q: 'x' } },
|
|
543
|
+
]
|
|
544
|
+
const transport = aiSdkTransport({
|
|
545
|
+
api: '/api/chat',
|
|
546
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
547
|
+
}).forThread('thread-x')
|
|
548
|
+
|
|
549
|
+
const firstIds = (await collect(transport.stream('hi'))).map((p) => (p as { id: string }).id)
|
|
550
|
+
const secondIds = (await collect(transport.stream('hi'))).map((p) => (p as { id: string }).id)
|
|
551
|
+
|
|
552
|
+
expect(secondIds).toEqual(firstIds)
|
|
553
|
+
expect(firstIds).toEqual(['thread-x#start', 'thread-x#0', 'tool#call-1', 'tool#call-1'])
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
test('replaying the identical delta sequence through mergePart converges — it does not double the parts array (the defect this lane exists to make impossible)', async () => {
|
|
557
|
+
const chunks: UIMessageChunk[] = [
|
|
558
|
+
{ type: 'text-start', id: 't1' },
|
|
559
|
+
{ type: 'text-delta', id: 't1', delta: 'Hel' },
|
|
560
|
+
{ type: 'text-delta', id: 't1', delta: 'lo' },
|
|
561
|
+
{ type: 'text-end', id: 't1' },
|
|
562
|
+
]
|
|
563
|
+
const transport = aiSdkTransport({
|
|
564
|
+
api: '/api/chat',
|
|
565
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
566
|
+
}).forThread('thread-x')
|
|
567
|
+
|
|
568
|
+
const firstPass = await collect(transport.stream('hi'))
|
|
569
|
+
let parts: AgentPart[] = []
|
|
570
|
+
for (const part of firstPass) parts = mergePart(parts, part)
|
|
571
|
+
const afterFirstPass = parts
|
|
572
|
+
expect(afterFirstPass).toHaveLength(2) // start + one merged text part
|
|
573
|
+
|
|
574
|
+
// Simulate a resume()/replay that re-diffs the SAME chunk sequence from scratch (a fresh
|
|
575
|
+
// diffChunkStream call starts `prev = undefined` again, so the first snapshot it sees carries
|
|
576
|
+
// the FULL accumulated text at offset 0 — exactly what a real reconnectToStream reconnect
|
|
577
|
+
// looks like). Same bound transport (same chatId) ⇒ identical ids.
|
|
578
|
+
const secondPass = await collect(transport.stream('hi'))
|
|
579
|
+
for (const part of secondPass) parts = mergePart(parts, part)
|
|
580
|
+
|
|
581
|
+
expect(parts).toEqual(afterFirstPass)
|
|
582
|
+
expect(parts).toHaveLength(2)
|
|
583
|
+
const merged = parts.find((p) => p.type === 'text') as { text: string }
|
|
584
|
+
expect(merged.text).toBe('Hello')
|
|
585
|
+
|
|
586
|
+
// What this guards against: naive array-append accumulation (`[...prev, part]`, this lane's
|
|
587
|
+
// predecessor) would have appended the second pass's deltas onto the first pass's, doubling
|
|
588
|
+
// (and garbling) the rendered text instead of converging.
|
|
589
|
+
const naiveAppendTextParts = [...firstPass, ...secondPass].filter((p) => p.type === 'text')
|
|
590
|
+
expect(naiveAppendTextParts.length).toBeGreaterThan(
|
|
591
|
+
parts.filter((p) => p.type === 'text').length,
|
|
592
|
+
)
|
|
268
593
|
})
|
|
269
594
|
})
|