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
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coalesceParts — merges a rendered turn's parts for display: consecutive text/reasoning runs,
|
|
3
|
+
* and tool parts by toolCallId regardless of adjacency (later state wins, input/toolName from an
|
|
4
|
+
* earlier state survive a later state that omits them, first-occurrence position is kept). An
|
|
5
|
+
* approval-bearing part with no directly usable toolCallId resolves via the approvalId side index.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test'
|
|
8
|
+
import { coalesceParts } from './coalesce'
|
|
9
|
+
import type { AgentPart, ToolCallPart } from './parts'
|
|
10
|
+
|
|
11
|
+
describe('coalesceParts', () => {
|
|
12
|
+
test('merges consecutive text parts into one', () => {
|
|
13
|
+
const parts: AgentPart[] = [
|
|
14
|
+
{ id: 'p1', type: 'text', text: 'Hel' },
|
|
15
|
+
{ id: 'p2', type: 'text', text: 'lo' },
|
|
16
|
+
]
|
|
17
|
+
expect(coalesceParts(parts)).toEqual([{ id: 'p2', type: 'text', text: 'Hello' }])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('merges consecutive reasoning parts into one', () => {
|
|
21
|
+
const parts: AgentPart[] = [
|
|
22
|
+
{ id: 'p1', type: 'reasoning', text: 'thinking' },
|
|
23
|
+
{ id: 'p2', type: 'reasoning', text: ' more' },
|
|
24
|
+
]
|
|
25
|
+
expect(coalesceParts(parts)).toEqual([{ id: 'p2', type: 'reasoning', text: 'thinking more' }])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('does not merge text across a non-text part (a tool call interleaved)', () => {
|
|
29
|
+
const parts: AgentPart[] = [
|
|
30
|
+
{ id: 'p1', type: 'text', text: 'a' },
|
|
31
|
+
{
|
|
32
|
+
id: 'p2',
|
|
33
|
+
type: 'tool',
|
|
34
|
+
toolCallId: 'call-1',
|
|
35
|
+
toolName: 'search',
|
|
36
|
+
state: 'input-available',
|
|
37
|
+
input: {},
|
|
38
|
+
},
|
|
39
|
+
{ id: 'p3', type: 'text', text: 'b' },
|
|
40
|
+
]
|
|
41
|
+
const out = coalesceParts(parts)
|
|
42
|
+
expect(out).toHaveLength(3)
|
|
43
|
+
expect(out[0]?.type).toBe('text')
|
|
44
|
+
expect(out[2]?.type).toBe('text')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('merges tool parts by toolCallId across NON-ADJACENT positions', () => {
|
|
48
|
+
const inputAvailable: ToolCallPart = {
|
|
49
|
+
id: 'p1',
|
|
50
|
+
type: 'tool',
|
|
51
|
+
toolCallId: 'call-1',
|
|
52
|
+
toolName: 'search',
|
|
53
|
+
state: 'input-available',
|
|
54
|
+
input: { q: 'x' },
|
|
55
|
+
}
|
|
56
|
+
const outputAvailable: ToolCallPart = {
|
|
57
|
+
id: 'p3',
|
|
58
|
+
type: 'tool',
|
|
59
|
+
toolCallId: 'call-1',
|
|
60
|
+
toolName: 'search',
|
|
61
|
+
state: 'output-available',
|
|
62
|
+
input: { q: 'x' },
|
|
63
|
+
output: { hits: 3 },
|
|
64
|
+
}
|
|
65
|
+
const parts: AgentPart[] = [
|
|
66
|
+
inputAvailable,
|
|
67
|
+
{ id: 'p2', type: 'text', text: 'meanwhile...' },
|
|
68
|
+
outputAvailable,
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
const out = coalesceParts(parts)
|
|
72
|
+
expect(out).toHaveLength(2)
|
|
73
|
+
// The merged tool part keeps the position of its FIRST occurrence.
|
|
74
|
+
expect(out[0]?.type).toBe('tool')
|
|
75
|
+
expect((out[0] as ToolCallPart).state).toBe('output-available')
|
|
76
|
+
expect(out[1]?.type).toBe('text')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('the later terminal state wins over an earlier one for the same toolCallId', () => {
|
|
80
|
+
const outputAvailable: ToolCallPart = {
|
|
81
|
+
id: 'p1',
|
|
82
|
+
type: 'tool',
|
|
83
|
+
toolCallId: 'call-1',
|
|
84
|
+
toolName: 'search',
|
|
85
|
+
state: 'output-available',
|
|
86
|
+
input: { q: 'x' },
|
|
87
|
+
output: { hits: 3 },
|
|
88
|
+
}
|
|
89
|
+
const outputError: ToolCallPart = {
|
|
90
|
+
id: 'p2',
|
|
91
|
+
type: 'tool',
|
|
92
|
+
toolCallId: 'call-1',
|
|
93
|
+
toolName: 'search',
|
|
94
|
+
state: 'output-error',
|
|
95
|
+
errorText: 'timed out',
|
|
96
|
+
}
|
|
97
|
+
const out = coalesceParts([outputAvailable, outputError])
|
|
98
|
+
expect(out).toHaveLength(1)
|
|
99
|
+
expect((out[0] as ToolCallPart).state).toBe('output-error')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('input from an earlier state survives a later state that omits it', () => {
|
|
103
|
+
const inputAvailable: ToolCallPart = {
|
|
104
|
+
id: 'p1',
|
|
105
|
+
type: 'tool',
|
|
106
|
+
toolCallId: 'call-1',
|
|
107
|
+
toolName: 'search',
|
|
108
|
+
state: 'input-available',
|
|
109
|
+
input: { q: 'x' },
|
|
110
|
+
}
|
|
111
|
+
// Simulates the wire shape where `tool-output-available` carries only `output` — a raw
|
|
112
|
+
// pre-normalization shape our strict type doesn't allow, hence the cast.
|
|
113
|
+
const outputAvailableNoInput = {
|
|
114
|
+
id: 'p2',
|
|
115
|
+
type: 'tool',
|
|
116
|
+
toolCallId: 'call-1',
|
|
117
|
+
toolName: 'search',
|
|
118
|
+
state: 'output-available',
|
|
119
|
+
output: { hits: 3 },
|
|
120
|
+
} as unknown as ToolCallPart
|
|
121
|
+
|
|
122
|
+
const out = coalesceParts([inputAvailable, outputAvailableNoInput])
|
|
123
|
+
expect(out).toHaveLength(1)
|
|
124
|
+
const merged = out[0] as ToolCallPart & { output: unknown }
|
|
125
|
+
expect(merged.state).toBe('output-available')
|
|
126
|
+
expect(merged.input).toEqual({ q: 'x' })
|
|
127
|
+
expect(merged.output).toEqual({ hits: 3 })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('toolName from an earlier state survives a later state that omits it', () => {
|
|
131
|
+
const inputAvailable: ToolCallPart = {
|
|
132
|
+
id: 'p1',
|
|
133
|
+
type: 'tool',
|
|
134
|
+
toolCallId: 'call-1',
|
|
135
|
+
toolName: 'search',
|
|
136
|
+
state: 'input-available',
|
|
137
|
+
input: { q: 'x' },
|
|
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.
|
|
141
|
+
const outputAvailableNoToolName = {
|
|
142
|
+
id: 'p2',
|
|
143
|
+
type: 'tool',
|
|
144
|
+
toolCallId: 'call-1',
|
|
145
|
+
toolName: '',
|
|
146
|
+
state: 'output-available',
|
|
147
|
+
input: { q: 'x' },
|
|
148
|
+
output: { hits: 3 },
|
|
149
|
+
} as unknown as ToolCallPart
|
|
150
|
+
|
|
151
|
+
const out = coalesceParts([inputAvailable, outputAvailableNoToolName])
|
|
152
|
+
expect((out[0] as ToolCallPart).toolName).toBe('search')
|
|
153
|
+
})
|
|
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
|
+
|
|
159
|
+
test('an approval-responded part with no toolCallId is resolved via the approvalId side index', () => {
|
|
160
|
+
const approvalRequested: ToolCallPart = {
|
|
161
|
+
id: 'p1',
|
|
162
|
+
type: 'tool',
|
|
163
|
+
toolCallId: 'call-1',
|
|
164
|
+
toolName: 'delete_file',
|
|
165
|
+
state: 'approval-requested',
|
|
166
|
+
input: { path: '/etc/passwd' },
|
|
167
|
+
approval: { id: 'appr-1' },
|
|
168
|
+
}
|
|
169
|
+
const approvalResponded: ToolCallPart = {
|
|
170
|
+
id: 'p2',
|
|
171
|
+
type: 'tool',
|
|
172
|
+
toolName: 'delete_file',
|
|
173
|
+
state: 'approval-responded',
|
|
174
|
+
input: { path: '/etc/passwd' },
|
|
175
|
+
approval: { id: 'appr-1', approved: true },
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const out = coalesceParts([approvalRequested, approvalResponded])
|
|
179
|
+
expect(out).toHaveLength(1)
|
|
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')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('a tool part unresolvable by either toolCallId or approvalId is kept as-is, never dropped', () => {
|
|
187
|
+
const orphan: ToolCallPart = {
|
|
188
|
+
id: 'p1',
|
|
189
|
+
type: 'tool',
|
|
190
|
+
toolName: 'mystery',
|
|
191
|
+
state: 'approval-responded',
|
|
192
|
+
input: {},
|
|
193
|
+
approval: { id: 'unknown-approval', approved: true },
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const out = coalesceParts([orphan])
|
|
197
|
+
expect(out).toEqual([orphan])
|
|
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
|
+
})
|
|
387
|
+
})
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coalesceParts — merges a rendered-turn's parts array for display.
|
|
3
|
+
*
|
|
4
|
+
* Merges consecutive `text`+`text` and `reasoning`+`reasoning` parts (streamed answers arrive as
|
|
5
|
+
* many small fragments; this renders a streaming reply as one flowing block instead of a stack of
|
|
6
|
+
* fragments). Merges `tool` parts BY `toolCallId` regardless of adjacency — the AI SDK re-emits a
|
|
7
|
+
* tool call's `input-available` then `output-available` as two separate parts, not necessarily
|
|
8
|
+
* adjacent once other parts interleave; without this rule a UI renders two stacked tool blocks for
|
|
9
|
+
* one call. The later state always wins; the merged part keeps the position of its FIRST
|
|
10
|
+
* occurrence (stable ordering); and an `input` (or `toolName`) supplied by an earlier state
|
|
11
|
+
* survives a later state that omits it — `tool-output-available` carries only `output` on the
|
|
12
|
+
* wire, the SDK re-supplies `input` by reading it back off the stored invocation, and a merge that
|
|
13
|
+
* replaces wholesale rather than accumulating would lose it outright.
|
|
14
|
+
*
|
|
15
|
+
* A settled tool part can arrive without a `toolCallId` at all — `approval-responded` plus the
|
|
16
|
+
* three terminal states all carry it as optional (see `MaybeIdentified` in `parts.ts`).
|
|
17
|
+
* `approval-responded` is where the WIRE itself omits it: the SDK's `tool-approval-response` chunk
|
|
18
|
+
* carries no `toolCallId`, only `approval.id`, so it is resolved by reverse lookup on `approval.id`
|
|
19
|
+
* instead — this maintains an `approvalId -> toolCallId` side index while folding and routes such a
|
|
20
|
+
* part through it. A part resolvable by neither key is kept as-is rather than dropped; never
|
|
21
|
+
* silently discard a part.
|
|
22
|
+
*
|
|
23
|
+
* The fold is strictly LEFT-TO-RIGHT with no backtracking: the side index only holds ids seen so
|
|
24
|
+
* far, so an unidentified `approval-responded` arriving BEFORE its `approval-requested` stays its
|
|
25
|
+
* own block and the later request opens a second one. That is deliberate, not an oversight —
|
|
26
|
+
* reconciling backwards would let the request (the later arrival) win under the "later state wins"
|
|
27
|
+
* rule and render an already-answered call as still pending, which is worse than two blocks in an
|
|
28
|
+
* ordering the wire never produces.
|
|
29
|
+
*
|
|
30
|
+
* **This function never throws.** It is public, it runs on the render path, and it folds data that
|
|
31
|
+
* came off a wire — so every anomaly (an unrecognized tool state, an unresolvable id) degrades to
|
|
32
|
+
* "keep the part as it arrived", announced in dev, rather than aborting the render. A throw here
|
|
33
|
+
* would turn one malformed chunk into a permanently dead transcript. Exhaustiveness over the tool
|
|
34
|
+
* states is still enforced, but at COMPILE time only (see `mergeToolPart`'s default branch).
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* import { coalesceParts } from 'basalt-ui/agent'
|
|
38
|
+
* <PartList parts={coalesceParts(parts)} />
|
|
39
|
+
*/
|
|
40
|
+
import type { AgentPart, ReasoningPart, TextPart, ToolCallPart } from './parts'
|
|
41
|
+
|
|
42
|
+
type TextLike = TextPart | ReasoningPart
|
|
43
|
+
|
|
44
|
+
function isTextLikeType(type: AgentPart['type']): type is TextLike['type'] {
|
|
45
|
+
return type === 'text' || type === 'reasoning'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolves a usable toolCallId for `part`, either off the part itself or (for an approval-bearing
|
|
49
|
+
* part missing one) via the approvalId -> toolCallId side index built while folding. */
|
|
50
|
+
function resolveToolCallId(
|
|
51
|
+
part: ToolCallPart,
|
|
52
|
+
toolCallIdByApprovalId: Map<string, string>,
|
|
53
|
+
): string | undefined {
|
|
54
|
+
if (part.toolCallId !== undefined) return part.toolCallId
|
|
55
|
+
const approval = 'approval' in part ? part.approval : undefined
|
|
56
|
+
return approval !== undefined ? toolCallIdByApprovalId.get(approval.id) : undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Registers this part's approval.id -> toolCallId mapping (when both are known) so a later
|
|
60
|
+
* approval-bearing part missing its own toolCallId can still resolve to the right tool call. */
|
|
61
|
+
function registerApprovalIndex(
|
|
62
|
+
part: ToolCallPart,
|
|
63
|
+
toolCallIdByApprovalId: Map<string, string>,
|
|
64
|
+
): void {
|
|
65
|
+
const approval = 'approval' in part ? part.approval : undefined
|
|
66
|
+
if (approval === undefined) return
|
|
67
|
+
if (part.toolCallId === undefined) return
|
|
68
|
+
toolCallIdByApprovalId.set(approval.id, part.toolCallId)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The later state always wins; `toolCallId`/`toolName`/`durationMs`/`providerExecuted` and `input`
|
|
73
|
+
* from the earlier state survive a later state that omits them.
|
|
74
|
+
*
|
|
75
|
+
* The result is RECONSTRUCTED per `next.state`, building only the fields that state legally has —
|
|
76
|
+
* never a blind `{ ...existing, ...next }` spread. A spread lets any field the next state does not
|
|
77
|
+
* redeclare survive onto it: an `output-available` (with `output`, possibly `preliminary`) that
|
|
78
|
+
* fails to `output-error` (which has no `output` key) would keep a stale `output` alongside
|
|
79
|
+
* `errorText`, and a consumer branching on `output` before `errorText` renders a failed call as
|
|
80
|
+
* successful. Only the fields listed above are carried; `output`, `errorText`, `rawInput`,
|
|
81
|
+
* `preliminary` and `approval` come from `next` alone, so nothing from a prior state can leak into
|
|
82
|
+
* a member that has no room for it. Reconstruction is exact enough that tsc proves each returned
|
|
83
|
+
* member valid — there is deliberately no cast here.
|
|
84
|
+
*/
|
|
85
|
+
function mergeToolPart(existing: ToolCallPart, next: ToolCallPart): ToolCallPart {
|
|
86
|
+
const existingInput = 'input' in existing ? existing.input : undefined
|
|
87
|
+
const nextInput = 'input' in next ? next.input : undefined
|
|
88
|
+
const input = nextInput !== undefined ? nextInput : existingInput
|
|
89
|
+
const durationMs = next.durationMs ?? existing.durationMs
|
|
90
|
+
const providerExecuted = next.providerExecuted ?? existing.providerExecuted
|
|
91
|
+
// `toolCallId` is optional on the four settled states (see `MaybeIdentified` in parts.ts), so a
|
|
92
|
+
// later part that arrived unidentified — resolved here through the approvalId side index — keeps
|
|
93
|
+
// the identity an earlier state supplied instead of losing it on the way through the merge.
|
|
94
|
+
const settledToolCallId = next.toolCallId ?? existing.toolCallId
|
|
95
|
+
|
|
96
|
+
const common = {
|
|
97
|
+
id: next.id,
|
|
98
|
+
type: 'tool' as const,
|
|
99
|
+
toolName: next.toolName.length > 0 ? next.toolName : existing.toolName,
|
|
100
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
101
|
+
...(providerExecuted !== undefined ? { providerExecuted } : {}),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
switch (next.state) {
|
|
105
|
+
case 'input-streaming':
|
|
106
|
+
return {
|
|
107
|
+
...common,
|
|
108
|
+
toolCallId: next.toolCallId,
|
|
109
|
+
state: 'input-streaming',
|
|
110
|
+
...(input !== undefined ? { input } : {}),
|
|
111
|
+
}
|
|
112
|
+
case 'input-available':
|
|
113
|
+
return { ...common, toolCallId: next.toolCallId, state: 'input-available', input }
|
|
114
|
+
case 'approval-requested':
|
|
115
|
+
return {
|
|
116
|
+
...common,
|
|
117
|
+
toolCallId: next.toolCallId,
|
|
118
|
+
state: 'approval-requested',
|
|
119
|
+
input,
|
|
120
|
+
approval: next.approval,
|
|
121
|
+
}
|
|
122
|
+
case 'approval-responded':
|
|
123
|
+
return {
|
|
124
|
+
...common,
|
|
125
|
+
...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
|
|
126
|
+
state: 'approval-responded',
|
|
127
|
+
input,
|
|
128
|
+
approval: next.approval,
|
|
129
|
+
}
|
|
130
|
+
case 'output-available':
|
|
131
|
+
return {
|
|
132
|
+
...common,
|
|
133
|
+
...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
|
|
134
|
+
state: 'output-available',
|
|
135
|
+
input,
|
|
136
|
+
output: next.output,
|
|
137
|
+
...(next.preliminary !== undefined ? { preliminary: next.preliminary } : {}),
|
|
138
|
+
...(next.approval !== undefined ? { approval: next.approval } : {}),
|
|
139
|
+
}
|
|
140
|
+
case 'output-error':
|
|
141
|
+
return {
|
|
142
|
+
...common,
|
|
143
|
+
...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
|
|
144
|
+
state: 'output-error',
|
|
145
|
+
errorText: next.errorText,
|
|
146
|
+
...(input !== undefined ? { input } : {}),
|
|
147
|
+
...(next.rawInput !== undefined ? { rawInput: next.rawInput } : {}),
|
|
148
|
+
...(next.approval !== undefined ? { approval: next.approval } : {}),
|
|
149
|
+
}
|
|
150
|
+
case 'output-denied':
|
|
151
|
+
return {
|
|
152
|
+
...common,
|
|
153
|
+
...(settledToolCallId !== undefined ? { toolCallId: settledToolCallId } : {}),
|
|
154
|
+
state: 'output-denied',
|
|
155
|
+
input,
|
|
156
|
+
approval: next.approval,
|
|
157
|
+
}
|
|
158
|
+
default: {
|
|
159
|
+
// Exhaustive at COMPILE time, defensive at RUNTIME. Binding `next` at `never` keeps tsc's
|
|
160
|
+
// guarantee — add a state to `ToolCallPart` without a case above and this line stops
|
|
161
|
+
// compiling — but this branch must NOT throw. `coalesceParts` is public, runs on the render
|
|
162
|
+
// path, and folds wire data: a throw would turn one transient wire anomaly into a
|
|
163
|
+
// permanently dead transcript (the same trade `spliceText` makes in merge.ts, where an
|
|
164
|
+
// out-of-range offset is clamped rather than thrown on). An unrecognized state therefore
|
|
165
|
+
// falls back to the incoming part with only the whitelisted carry-over fields folded in —
|
|
166
|
+
// nothing state-specific from `existing` can leak, because `common` is all that is merged.
|
|
167
|
+
const unhandled: never = next
|
|
168
|
+
// Widened back deliberately: control-flow analysis would keep an annotated const at `never`
|
|
169
|
+
// and make every field read below a compile error, so the assertion is what makes the
|
|
170
|
+
// runtime value usable. It asserts nothing tsc has not already proved on the line above.
|
|
171
|
+
const fallback = unhandled as ToolCallPart
|
|
172
|
+
if (process.env['NODE_ENV'] !== 'production') {
|
|
173
|
+
console.warn(
|
|
174
|
+
`[basalt] coalesceParts: unrecognized tool state ${JSON.stringify(fallback.state)} — kept, merged best-effort`,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
return { ...fallback, ...common }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function coalesceParts<TPart extends AgentPart>(parts: readonly TPart[]): TPart[] {
|
|
183
|
+
const out: TPart[] = []
|
|
184
|
+
const toolIndexByCallId = new Map<string, number>()
|
|
185
|
+
const toolCallIdByApprovalId = new Map<string, string>()
|
|
186
|
+
|
|
187
|
+
for (const part of parts) {
|
|
188
|
+
if (isTextLikeType(part.type)) {
|
|
189
|
+
const last = out[out.length - 1]
|
|
190
|
+
if (last !== undefined && last.type === part.type) {
|
|
191
|
+
const merged = {
|
|
192
|
+
...part,
|
|
193
|
+
text: (last as unknown as TextLike).text + (part as unknown as TextLike).text,
|
|
194
|
+
}
|
|
195
|
+
out[out.length - 1] = merged as TPart
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
out.push(part)
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (part.type === 'tool') {
|
|
203
|
+
const toolPart = part as unknown as ToolCallPart
|
|
204
|
+
registerApprovalIndex(toolPart, toolCallIdByApprovalId)
|
|
205
|
+
const resolvedCallId = resolveToolCallId(toolPart, toolCallIdByApprovalId)
|
|
206
|
+
|
|
207
|
+
if (resolvedCallId === undefined) {
|
|
208
|
+
// Unresolvable by either key — never silently discard.
|
|
209
|
+
out.push(part)
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const existingIndex = toolIndexByCallId.get(resolvedCallId)
|
|
214
|
+
if (existingIndex === undefined) {
|
|
215
|
+
toolIndexByCallId.set(resolvedCallId, out.length)
|
|
216
|
+
out.push(part)
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const existing = out[existingIndex] as unknown as ToolCallPart
|
|
221
|
+
out[existingIndex] = mergeToolPart(existing, toolPart) as unknown as TPart
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
out.push(part)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* foreign.ts — runtime coverage for `narrowAgentPart` (the cheap discriminator, distinct from the
|
|
3
|
+
* validating `parseAgentPart`) and `definePartRenderers` (identity passthrough, the canonical
|
|
4
|
+
* const-generic factory contract). Compile-time coverage for the `PartRenderers`/`ConsumerPart`
|
|
5
|
+
* exhaustiveness contract lives in the playground fixture
|
|
6
|
+
* (`apps/playground/src/demo/agent-part-registry.type-guard.ts`), not here.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test'
|
|
9
|
+
import { definePartRenderers, narrowAgentPart } from './foreign'
|
|
10
|
+
import type { TranscriptPart } from './foreign'
|
|
11
|
+
import type { AgentPart } from './parts'
|
|
12
|
+
|
|
13
|
+
describe('narrowAgentPart', () => {
|
|
14
|
+
// Typed AgentPart[], not TranscriptPart[]: every literal here IS a built-in variant (the whole
|
|
15
|
+
// point of this fixture), and narrowAgentPart's return type is AgentPart | null — comparing it
|
|
16
|
+
// against a TranscriptPart-typed element (which also admits ForeignPart) doesn't type-check.
|
|
17
|
+
const BUILT_IN_SAMPLES: AgentPart[] = [
|
|
18
|
+
{ id: 'p1', type: 'start', runId: 'r1' },
|
|
19
|
+
{ id: 'p2', type: 'text', text: 'hi' },
|
|
20
|
+
{ id: 'p3', type: 'reasoning', text: 'thinking' },
|
|
21
|
+
{
|
|
22
|
+
id: 'p4',
|
|
23
|
+
type: 'tool',
|
|
24
|
+
toolCallId: 'c1',
|
|
25
|
+
toolName: 'search',
|
|
26
|
+
state: 'input-available',
|
|
27
|
+
input: {},
|
|
28
|
+
},
|
|
29
|
+
{ id: 'p5', type: 'source', url: 'https://example.com' },
|
|
30
|
+
{ id: 'p6', type: 'error', message: 'boom' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
for (const part of BUILT_IN_SAMPLES) {
|
|
34
|
+
test(`narrows a built-in '${part.type}' part`, () => {
|
|
35
|
+
expect(narrowAgentPart(part)).toBe(part)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('returns null for a foreign part type', () => {
|
|
40
|
+
const foreign: TranscriptPart = { id: 'p7', type: 'data-chart', spec: {} }
|
|
41
|
+
expect(narrowAgentPart(foreign)).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('definePartRenderers', () => {
|
|
46
|
+
test('identity passthrough — returns the exact map it was given', () => {
|
|
47
|
+
const renderChart = (): null => null
|
|
48
|
+
const map = { 'data-chart': renderChart }
|
|
49
|
+
expect(definePartRenderers(map)).toBe(map)
|
|
50
|
+
})
|
|
51
|
+
})
|