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
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* `output` field, so the old `part.state === 'output-available'`-only body left a failed call
|
|
6
6
|
* rendering as name+input and nothing else — the failure was silently invisible. Same gap for
|
|
7
7
|
* `output-denied`, indistinguishable from a still-pending call.
|
|
8
|
+
*
|
|
9
|
+
* Plus the `settled` flag's forwarding contract — the field that lets a text renderer drop out of
|
|
10
|
+
* streaming mode once the message it belongs to has finished.
|
|
8
11
|
*/
|
|
9
12
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
13
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
11
14
|
import { PartList } from './part-list'
|
|
12
|
-
import type { ToolCallPart } from './parts'
|
|
15
|
+
import type { AgentPart, ToolCallPart } from './parts'
|
|
13
16
|
|
|
14
17
|
afterEach(cleanup)
|
|
15
18
|
|
|
@@ -80,3 +83,30 @@ describe('DefaultToolCall — terminal states stay visible', () => {
|
|
|
80
83
|
expect(screen.queryByText(/Denied/)).toBeNull()
|
|
81
84
|
})
|
|
82
85
|
})
|
|
86
|
+
|
|
87
|
+
describe('settled — forwarded to every renderer', () => {
|
|
88
|
+
const TEXT: AgentPart = { id: 't1', type: 'text', text: 'hello' }
|
|
89
|
+
|
|
90
|
+
function settledProbe() {
|
|
91
|
+
const seen: boolean[] = []
|
|
92
|
+
const components = {
|
|
93
|
+
text: ({ settled }: { settled: boolean }) => {
|
|
94
|
+
seen.push(settled)
|
|
95
|
+
return <div data-testid="probe">{String(settled)}</div>
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
return { seen, components }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test('defaults to true — a caller that knows nothing about a run renders a finished list', () => {
|
|
102
|
+
const { seen, components } = settledProbe()
|
|
103
|
+
render(<PartList parts={[TEXT]} components={components} />)
|
|
104
|
+
expect(seen).toEqual([true])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('an explicit settled={false} reaches the renderer', () => {
|
|
108
|
+
const { seen, components } = settledProbe()
|
|
109
|
+
render(<PartList parts={[TEXT]} components={components} settled={false} />)
|
|
110
|
+
expect(seen).toEqual([false])
|
|
111
|
+
})
|
|
112
|
+
})
|
package/src/agent/part-list.tsx
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
* <PartList
|
|
20
20
|
* parts={parts}
|
|
21
21
|
* components={{
|
|
22
|
-
* text: ({ part }) =>
|
|
22
|
+
* text: ({ part, settled }) => (
|
|
23
|
+
* <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown>
|
|
24
|
+
* ),
|
|
23
25
|
* }}
|
|
24
26
|
* />
|
|
25
27
|
*/
|
|
@@ -38,37 +40,48 @@ import type {
|
|
|
38
40
|
|
|
39
41
|
// ── Per-type renderer signatures ──────────────────────────────────────────────
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The per-renderer argument. `settled` is `false` only while the message these parts belong to is
|
|
45
|
+
* still streaming — it converges this map toward the `PartRenderContext` a foreign renderer
|
|
46
|
+
* already receives (`./foreign`), rather than adding a third shape. Additive and
|
|
47
|
+
* source-compatible: an existing `({ part }) => …` renderer simply ignores it.
|
|
48
|
+
*
|
|
49
|
+
* Settlement is MONOTONE — a message that has settled never un-settles.
|
|
50
|
+
*/
|
|
51
|
+
type PartRenderArgs<TPart> = {
|
|
52
|
+
part: TPart
|
|
43
53
|
index: number
|
|
44
|
-
|
|
54
|
+
settled: boolean
|
|
55
|
+
}
|
|
45
56
|
|
|
46
|
-
export type
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}) => JSX.Element | null
|
|
57
|
+
export type TextPartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
58
|
+
props: PartRenderArgs<Extract<TPart, { type: 'text' }>>,
|
|
59
|
+
) => JSX.Element | null
|
|
50
60
|
|
|
51
|
-
export type
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}) => JSX.Element | null
|
|
61
|
+
export type ReasoningPartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
62
|
+
props: PartRenderArgs<Extract<TPart, { type: 'reasoning' }>>,
|
|
63
|
+
) => JSX.Element | null
|
|
55
64
|
|
|
56
|
-
export type
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}) => JSX.Element | null
|
|
65
|
+
export type ToolCallPartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
66
|
+
props: PartRenderArgs<Extract<TPart, { type: 'tool' }>>,
|
|
67
|
+
) => JSX.Element | null
|
|
60
68
|
|
|
61
|
-
export type
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
export type SourcePartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
70
|
+
props: PartRenderArgs<Extract<TPart, { type: 'source' }>>,
|
|
71
|
+
) => JSX.Element | null
|
|
72
|
+
|
|
73
|
+
export type ErrorPartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
74
|
+
props: PartRenderArgs<Extract<TPart, { type: 'error' }>>,
|
|
75
|
+
) => JSX.Element | null
|
|
65
76
|
|
|
66
77
|
/**
|
|
67
78
|
* Partial map of per-type renderers. Any omitted key falls back to the headless default.
|
|
68
79
|
*
|
|
69
80
|
* @example
|
|
70
81
|
* const renderers: Partial<AgentPartRenderers> = {
|
|
71
|
-
* text: ({ part }) =>
|
|
82
|
+
* text: ({ part, settled }) => (
|
|
83
|
+
* <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown>
|
|
84
|
+
* ),
|
|
72
85
|
* }
|
|
73
86
|
*/
|
|
74
87
|
export type AgentPartRenderers<TPart extends AgentPart = AgentPart> = {
|
|
@@ -231,10 +244,17 @@ export type PartListProps<TPart extends AgentPart = AgentPart> = {
|
|
|
231
244
|
readonly parts: TPart[]
|
|
232
245
|
/**
|
|
233
246
|
* Override individual part renderers. Omitted keys fall back to the headless defaults.
|
|
234
|
-
* Pass `{ text: ({ part }) => <Markdown streaming density="chat">{part.text}</Markdown> }`
|
|
235
|
-
* enable rich markdown rendering.
|
|
247
|
+
* Pass `{ text: ({ part, settled }) => <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown> }`
|
|
248
|
+
* to enable rich markdown rendering.
|
|
236
249
|
*/
|
|
237
250
|
readonly components?: Partial<AgentPartRenderers<TPart>>
|
|
251
|
+
/**
|
|
252
|
+
* `false` while the message these parts belong to is still streaming — forwarded to every
|
|
253
|
+
* renderer so a text renderer can drop out of streaming mode (and a fenced mermaid/diagram can
|
|
254
|
+
* upgrade) once the turn finishes. Defaults to `true`: a caller that knows nothing about a run
|
|
255
|
+
* is rendering a finished list, and un-settled must be opted into by the thing that does know.
|
|
256
|
+
*/
|
|
257
|
+
readonly settled?: boolean
|
|
238
258
|
}
|
|
239
259
|
|
|
240
260
|
/**
|
|
@@ -251,11 +271,19 @@ export type PartListProps<TPart extends AgentPart = AgentPart> = {
|
|
|
251
271
|
* // With basalt's own Markdown for the text renderer:
|
|
252
272
|
* import { PartList } from 'basalt-ui/agent'
|
|
253
273
|
* import { Markdown } from 'basalt-ui/content'
|
|
254
|
-
* <PartList
|
|
274
|
+
* <PartList
|
|
275
|
+
* parts={parts}
|
|
276
|
+
* components={{
|
|
277
|
+
* text: ({ part, settled }) => (
|
|
278
|
+
* <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown>
|
|
279
|
+
* ),
|
|
280
|
+
* }}
|
|
281
|
+
* />
|
|
255
282
|
*/
|
|
256
283
|
export function PartList<TPart extends AgentPart = AgentPart>({
|
|
257
284
|
parts,
|
|
258
285
|
components,
|
|
286
|
+
settled = true,
|
|
259
287
|
}: PartListProps<TPart>): JSX.Element {
|
|
260
288
|
// Memoised to avoid rebuilding the renderer map on every streaming re-render (hot path).
|
|
261
289
|
// Cast required because DEFAULT_RENDERERS is typed for the base AgentPart, not the generic TPart.
|
|
@@ -277,7 +305,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
|
|
|
277
305
|
const Render = renderers.text
|
|
278
306
|
return (
|
|
279
307
|
<Fragment key={part.id}>
|
|
280
|
-
<Render
|
|
308
|
+
<Render
|
|
309
|
+
part={part as Extract<TPart, { type: 'text' }>}
|
|
310
|
+
index={index}
|
|
311
|
+
settled={settled}
|
|
312
|
+
/>
|
|
281
313
|
</Fragment>
|
|
282
314
|
)
|
|
283
315
|
}
|
|
@@ -285,7 +317,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
|
|
|
285
317
|
const Render = renderers.reasoning
|
|
286
318
|
return (
|
|
287
319
|
<Fragment key={part.id}>
|
|
288
|
-
<Render
|
|
320
|
+
<Render
|
|
321
|
+
part={part as Extract<TPart, { type: 'reasoning' }>}
|
|
322
|
+
index={index}
|
|
323
|
+
settled={settled}
|
|
324
|
+
/>
|
|
289
325
|
</Fragment>
|
|
290
326
|
)
|
|
291
327
|
}
|
|
@@ -293,7 +329,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
|
|
|
293
329
|
const Render = renderers.tool
|
|
294
330
|
return (
|
|
295
331
|
<Fragment key={part.id}>
|
|
296
|
-
<Render
|
|
332
|
+
<Render
|
|
333
|
+
part={part as Extract<TPart, { type: 'tool' }>}
|
|
334
|
+
index={index}
|
|
335
|
+
settled={settled}
|
|
336
|
+
/>
|
|
297
337
|
</Fragment>
|
|
298
338
|
)
|
|
299
339
|
}
|
|
@@ -301,7 +341,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
|
|
|
301
341
|
const Render = renderers.source
|
|
302
342
|
return (
|
|
303
343
|
<Fragment key={part.id}>
|
|
304
|
-
<Render
|
|
344
|
+
<Render
|
|
345
|
+
part={part as Extract<TPart, { type: 'source' }>}
|
|
346
|
+
index={index}
|
|
347
|
+
settled={settled}
|
|
348
|
+
/>
|
|
305
349
|
</Fragment>
|
|
306
350
|
)
|
|
307
351
|
}
|
|
@@ -309,7 +353,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
|
|
|
309
353
|
const Render = renderers.error
|
|
310
354
|
return (
|
|
311
355
|
<Fragment key={part.id}>
|
|
312
|
-
<Render
|
|
356
|
+
<Render
|
|
357
|
+
part={part as Extract<TPart, { type: 'error' }>}
|
|
358
|
+
index={index}
|
|
359
|
+
settled={settled}
|
|
360
|
+
/>
|
|
313
361
|
</Fragment>
|
|
314
362
|
)
|
|
315
363
|
}
|
package/src/agent/parts.test.ts
CHANGED
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
* AgentPart wire format, post-1.11.0 part identity + the seven-state ToolCallPart union.
|
|
4
4
|
*
|
|
5
5
|
* Every AgentPart variant below is validated against parts.ts's `parseAgentPart`, `isStartPart`,
|
|
6
|
-
* and `isToolCallSettled` as they read right now: `id` is required on every variant,
|
|
7
|
-
*
|
|
6
|
+
* and `isToolCallSettled` as they read right now: `id` is required on every variant, `toolCallId`
|
|
7
|
+
* is required on the three in-flight tool states and optional on the four settled ones (see the
|
|
8
|
+
* per-state identity block below), and `tool` parts are validated against the seven-state
|
|
9
|
+
* discriminated union mirroring AI SDK v7's
|
|
8
10
|
* `UIToolInvocation` (see parts.ts's ToolCallPart doc for the ground-truth corrections against the
|
|
9
11
|
* design spec).
|
|
10
12
|
*/
|
|
11
13
|
import { describe, expect, test } from 'bun:test'
|
|
12
14
|
import { isStartPart, isToolCallSettled, parseAgentPart } from './parts'
|
|
13
|
-
import type { AgentPart, ToolCallPart } from './parts'
|
|
15
|
+
import type { AgentPart, ToolCallPart, ToolCallState } from './parts'
|
|
14
16
|
|
|
15
17
|
/** JSON round-trip a value through JSON.stringify → JSON.parse, mirroring wire transport. */
|
|
16
18
|
function roundTrip(value: unknown): unknown {
|
|
@@ -109,10 +111,11 @@ describe('parseAgentPart', () => {
|
|
|
109
111
|
})
|
|
110
112
|
|
|
111
113
|
describe('tool', () => {
|
|
112
|
-
test('rejects a
|
|
114
|
+
test('rejects a non-string toolCallId', () => {
|
|
113
115
|
const raw = {
|
|
114
116
|
id: 'p1',
|
|
115
117
|
type: 'tool',
|
|
118
|
+
toolCallId: 42,
|
|
116
119
|
toolName: 'search',
|
|
117
120
|
state: 'input-available',
|
|
118
121
|
input: {},
|
|
@@ -120,6 +123,120 @@ describe('parseAgentPart', () => {
|
|
|
120
123
|
expect(parseAgentPart(raw)).toBeNull()
|
|
121
124
|
})
|
|
122
125
|
|
|
126
|
+
// ── toolCallId identity, decided per state ──────────────────────────────
|
|
127
|
+
// An empty string is not a value any state accepts: '' normalizes to absent right here, so the
|
|
128
|
+
// pre-1.12.0 sentinel can never reach the type or a consumer of it. What an ABSENT id then
|
|
129
|
+
// means is a per-state decision (see Identified / MaybeIdentified in parts.ts):
|
|
130
|
+
// in-flight (input-streaming, input-available, approval-requested) → REJECTED. The part
|
|
131
|
+
// exists only to be closed by a later state, which re-supplies its toolName and input;
|
|
132
|
+
// unidentified it could never be closed and would render as a forever-pending chip.
|
|
133
|
+
// settled (approval-responded + the three terminal states) → KEPT, unidentified.
|
|
134
|
+
// It carries the OUTCOME; dropping it would erase how the call ended (silent data loss),
|
|
135
|
+
// while keeping it costs only the fold — coalesceParts renders it as its own block.
|
|
136
|
+
//
|
|
137
|
+
// The `satisfies Record<ToolCallState, …>` below is an authoring-time exhaustiveness gate: add
|
|
138
|
+
// a state to the union and this fixture stops typechecking until its identity behaviour is
|
|
139
|
+
// decided here too. It is an EDITOR/tsc guard only — the package tsconfig excludes `*.test.ts`
|
|
140
|
+
// from `bun run typecheck`, and `bun test` type-strips rather than typechecks — so the
|
|
141
|
+
// "covers every state exactly once" test below backs it with a runtime check of the same claim.
|
|
142
|
+
|
|
143
|
+
const UNIDENTIFIED_RAW = {
|
|
144
|
+
'input-streaming': { toolName: 'search', state: 'input-streaming' },
|
|
145
|
+
'input-available': { toolName: 'search', state: 'input-available', input: { q: 'x' } },
|
|
146
|
+
'approval-requested': {
|
|
147
|
+
toolName: 'search',
|
|
148
|
+
state: 'approval-requested',
|
|
149
|
+
input: { q: 'x' },
|
|
150
|
+
approval: { id: 'appr-1' },
|
|
151
|
+
},
|
|
152
|
+
'approval-responded': {
|
|
153
|
+
toolName: 'search',
|
|
154
|
+
state: 'approval-responded',
|
|
155
|
+
input: { q: 'x' },
|
|
156
|
+
approval: { id: 'appr-1', approved: true },
|
|
157
|
+
},
|
|
158
|
+
'output-available': {
|
|
159
|
+
toolName: 'search',
|
|
160
|
+
state: 'output-available',
|
|
161
|
+
input: { q: 'x' },
|
|
162
|
+
output: { hits: 3 },
|
|
163
|
+
},
|
|
164
|
+
'output-error': { toolName: 'search', state: 'output-error', errorText: 'boom' },
|
|
165
|
+
'output-denied': {
|
|
166
|
+
toolName: 'search',
|
|
167
|
+
state: 'output-denied',
|
|
168
|
+
input: { q: 'x' },
|
|
169
|
+
approval: { id: 'appr-1', approved: false },
|
|
170
|
+
},
|
|
171
|
+
} satisfies Record<ToolCallState, Record<string, unknown>>
|
|
172
|
+
|
|
173
|
+
const IN_FLIGHT_STATES = [
|
|
174
|
+
'input-streaming',
|
|
175
|
+
'input-available',
|
|
176
|
+
'approval-requested',
|
|
177
|
+
] as const satisfies readonly ToolCallState[]
|
|
178
|
+
|
|
179
|
+
const SETTLED_STATES = [
|
|
180
|
+
'approval-responded',
|
|
181
|
+
'output-available',
|
|
182
|
+
'output-error',
|
|
183
|
+
'output-denied',
|
|
184
|
+
] as const satisfies readonly ToolCallState[]
|
|
185
|
+
|
|
186
|
+
test('the in-flight/settled split covers every tool state exactly once', () => {
|
|
187
|
+
// Object.keys() is always typed string[] regardless of the object's key type — cast back to
|
|
188
|
+
// ToolCallState[], which every key of UNIDENTIFIED_RAW genuinely is (used as
|
|
189
|
+
// UNIDENTIFIED_RAW[state] elsewhere in this file).
|
|
190
|
+
expect([...IN_FLIGHT_STATES, ...SETTLED_STATES].toSorted()).toEqual(
|
|
191
|
+
(Object.keys(UNIDENTIFIED_RAW) as ToolCallState[]).toSorted(),
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
for (const state of IN_FLIGHT_STATES) {
|
|
196
|
+
test(`${state}: rejects a missing toolCallId — an in-flight part that could never be closed`, () => {
|
|
197
|
+
expect(parseAgentPart({ id: 'p1', type: 'tool', ...UNIDENTIFIED_RAW[state] })).toBeNull()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test(`${state}: rejects an empty-string toolCallId identically (it means absent)`, () => {
|
|
201
|
+
const raw = { id: 'p1', type: 'tool', toolCallId: '', ...UNIDENTIFIED_RAW[state] }
|
|
202
|
+
expect(parseAgentPart(raw)).toBeNull()
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const state of SETTLED_STATES) {
|
|
207
|
+
test(`${state}: a missing toolCallId keeps the part UNIDENTIFIED — it is never dropped`, () => {
|
|
208
|
+
const parsed = parseAgentPart({ id: 'p1', type: 'tool', ...UNIDENTIFIED_RAW[state] })
|
|
209
|
+
expect(parsed).not.toBeNull()
|
|
210
|
+
expect((parsed as ToolCallPart).state).toBe(state)
|
|
211
|
+
expect(Object.hasOwn(parsed as object, 'toolCallId')).toBe(false)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test(`${state}: an empty-string toolCallId normalizes to absent, never to ""`, () => {
|
|
215
|
+
const raw = { id: 'p1', type: 'tool', toolCallId: '', ...UNIDENTIFIED_RAW[state] }
|
|
216
|
+
const parsed = parseAgentPart(raw)
|
|
217
|
+
expect(parsed).not.toBeNull()
|
|
218
|
+
expect(Object.hasOwn(parsed as object, 'toolCallId')).toBe(false)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test(`${state}: a real toolCallId still carries through`, () => {
|
|
222
|
+
const raw = { id: 'p1', type: 'tool', toolCallId: 'call-1', ...UNIDENTIFIED_RAW[state] }
|
|
223
|
+
expect((parseAgentPart(raw) as ToolCallPart | null)?.toolCallId).toBe('call-1')
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
test('approval-responded: a real toolCallId round-trips through JSON', () => {
|
|
228
|
+
const part: ToolCallPart = {
|
|
229
|
+
id: 'p1',
|
|
230
|
+
type: 'tool',
|
|
231
|
+
toolCallId: 'call-1',
|
|
232
|
+
toolName: 'delete_file',
|
|
233
|
+
state: 'approval-responded',
|
|
234
|
+
input: { path: '/tmp/x' },
|
|
235
|
+
approval: { id: 'appr-1', approved: true },
|
|
236
|
+
}
|
|
237
|
+
expect(parseAgentPart(roundTrip(part))).toEqual(part)
|
|
238
|
+
})
|
|
239
|
+
|
|
123
240
|
test('rejects a missing toolName', () => {
|
|
124
241
|
const raw = {
|
|
125
242
|
id: 'p1',
|
package/src/agent/parts.ts
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Exhaustiveness is enforced at every switch via assertNever (imported from '../register').
|
|
5
5
|
* Adding a new variant without a matching case is a tsc error — see the fixture in
|
|
6
|
-
* apps/playground/src/demo/agent-part.type-guard.ts.
|
|
6
|
+
* apps/playground/src/demo/agent-part.type-guard.ts. One caveat for code that folds WIRE data on
|
|
7
|
+
* the render path (`coalesceParts`): keep the compile-time guarantee — bind the value at `never` —
|
|
8
|
+
* but do NOT let the runtime throw, or one malformed chunk kills the whole transcript. See
|
|
9
|
+
* `mergeToolPart`'s default branch for that shape.
|
|
7
10
|
*
|
|
8
11
|
* Eden #231 doctrine: the server-side Elysia route MUST declare `: AsyncGenerator<AgentPart>`
|
|
9
12
|
* explicitly and carry NO t.Object/t.Union response schema. Validate at yield-time. See
|
|
@@ -78,6 +81,10 @@ export type ReasoningPart = PartBase & {
|
|
|
78
81
|
// discriminator `tool-${NAME}`. It IS a required field here (ours, not the SDK's) because the chip
|
|
79
82
|
// needs it; a transport mapping from the SDK must DERIVE it from the discriminator, not read it off
|
|
80
83
|
// the invocation.
|
|
84
|
+
//
|
|
85
|
+
// `toolCallId` is required on the three IN-FLIGHT states and OPTIONAL on the four SETTLED ones —
|
|
86
|
+
// see `Identified` / `MaybeIdentified` below. The absence is modelled in the type, never as a
|
|
87
|
+
// sentinel value.
|
|
81
88
|
|
|
82
89
|
/** The nested approval envelope — mirrors the SDK's shape verbatim, never flattened. */
|
|
83
90
|
type ToolApproval = {
|
|
@@ -90,35 +97,66 @@ type ToolApproval = {
|
|
|
90
97
|
|
|
91
98
|
type ToolCallBase = PartBase & {
|
|
92
99
|
readonly type: 'tool'
|
|
93
|
-
/** Required on all seven states (was optional — see parts.ts history). */
|
|
94
|
-
readonly toolCallId: string
|
|
95
100
|
readonly toolName: string
|
|
96
101
|
/** Wall-clock time from first sighting of this toolCallId to its terminal state. */
|
|
97
102
|
readonly durationMs?: number
|
|
98
103
|
readonly providerExecuted?: boolean
|
|
99
104
|
}
|
|
100
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The three IN-FLIGHT states (`input-streaming`, `input-available`, `approval-requested`) carry an
|
|
108
|
+
* authoritative `toolCallId` — the merge key, and for them it is structural. An in-flight part
|
|
109
|
+
* exists only to be superseded by the settled part for the same call; with no merge key it can
|
|
110
|
+
* never be closed, so it would render as a forever-pending chip standing beside the real result.
|
|
111
|
+
* Nothing is lost by rejecting an unidentified one either: the `toolName` and `input` it carries
|
|
112
|
+
* are re-supplied on the settled part. `parseToolCallPart` therefore rejects these when the id is
|
|
113
|
+
* missing or empty.
|
|
114
|
+
*/
|
|
115
|
+
type Identified = ToolCallBase & { readonly toolCallId: string }
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The four SETTLED states (`approval-responded` plus the three terminal ones) may legitimately
|
|
119
|
+
* arrive without a `toolCallId`, for two different reasons.
|
|
120
|
+
*
|
|
121
|
+
* `approval-responded` is where the WIRE omits it: the SDK's `tool-approval-response` chunk
|
|
122
|
+
* carries none, and it is resolved by reverse lookup on `approval.id` (coalesce.ts keeps the
|
|
123
|
+
* approvalId -> toolCallId side index that does it).
|
|
124
|
+
*
|
|
125
|
+
* The three terminal states admit the absence for a different reason — tolerance, not wire shape.
|
|
126
|
+
* They are self-contained: each carries the OUTCOME of the call, so a missing id costs only the
|
|
127
|
+
* fold (`coalesceParts` keeps an unresolvable part as its own block and never discards it).
|
|
128
|
+
* Rejecting one instead would delete the only record of how the call ended — silent data loss in a
|
|
129
|
+
* transcript, strictly worse than an unfolded block.
|
|
130
|
+
*
|
|
131
|
+
* The absence is modelled here rather than smuggled through an empty-string sentinel: the union
|
|
132
|
+
* previously declared an always-meaningful `string` that the `approval-responded` wire shape
|
|
133
|
+
* cannot supply, which forced a `''` sentinel into the type and an `as unknown as` cast into every
|
|
134
|
+
* test that built one. A `''` toolCallId is not a value any state accepts; `parseToolCallPart`
|
|
135
|
+
* normalizes one to absent at the wire boundary, so it never reaches the type or a consumer.
|
|
136
|
+
*/
|
|
137
|
+
type MaybeIdentified = ToolCallBase & { readonly toolCallId?: string }
|
|
138
|
+
|
|
101
139
|
export type ToolCallPart =
|
|
102
|
-
| (
|
|
103
|
-
| (
|
|
104
|
-
| (
|
|
140
|
+
| (Identified & { readonly state: 'input-streaming'; readonly input?: unknown })
|
|
141
|
+
| (Identified & { readonly state: 'input-available'; readonly input: unknown })
|
|
142
|
+
| (Identified & {
|
|
105
143
|
readonly state: 'approval-requested'
|
|
106
144
|
readonly input: unknown
|
|
107
145
|
readonly approval: ToolApproval & { readonly approved?: never; readonly reason?: never }
|
|
108
146
|
})
|
|
109
|
-
| (
|
|
147
|
+
| (MaybeIdentified & {
|
|
110
148
|
readonly state: 'approval-responded'
|
|
111
149
|
readonly input: unknown
|
|
112
150
|
readonly approval: ToolApproval & { readonly approved: boolean }
|
|
113
151
|
})
|
|
114
|
-
| (
|
|
152
|
+
| (MaybeIdentified & {
|
|
115
153
|
readonly state: 'output-available'
|
|
116
154
|
readonly input: unknown
|
|
117
155
|
readonly output: unknown
|
|
118
156
|
readonly preliminary?: boolean
|
|
119
157
|
readonly approval?: ToolApproval & { readonly approved?: true }
|
|
120
158
|
})
|
|
121
|
-
| (
|
|
159
|
+
| (MaybeIdentified & {
|
|
122
160
|
readonly state: 'output-error'
|
|
123
161
|
readonly input?: unknown
|
|
124
162
|
/** The SDK's field name — there is no field named `error` anywhere in the union. */
|
|
@@ -127,7 +165,7 @@ export type ToolCallPart =
|
|
|
127
165
|
readonly rawInput?: unknown
|
|
128
166
|
readonly approval?: ToolApproval & { readonly approved?: true }
|
|
129
167
|
})
|
|
130
|
-
| (
|
|
168
|
+
| (MaybeIdentified & {
|
|
131
169
|
readonly state: 'output-denied'
|
|
132
170
|
readonly input: unknown
|
|
133
171
|
readonly approval: ToolApproval & { readonly approved: false }
|
|
@@ -236,35 +274,53 @@ export function parseAgentPart(raw: unknown): AgentPart | null {
|
|
|
236
274
|
}
|
|
237
275
|
|
|
238
276
|
/** Validates the state-discriminated `tool` part shape. Rejects the pre-1.11.0 flat shape
|
|
239
|
-
* (`{type:'tool', output}` with no `state`) and any unrecognized `state` value.
|
|
277
|
+
* (`{type:'tool', output}` with no `state`) and any unrecognized `state` value.
|
|
278
|
+
*
|
|
279
|
+
* An empty-string `toolCallId` is normalized to absent here — the one place the wire's degenerate
|
|
280
|
+
* shape is decoded — so `''` never reaches the type or any consumer of it. What an ABSENT id then
|
|
281
|
+
* means is decided per state, exactly as the union declares it (`Identified` /
|
|
282
|
+
* `MaybeIdentified`): fatal on the three in-flight states, which exist only to be closed by a
|
|
283
|
+
* later one and are re-supplied by it; survivable on the four settled states, which carry the
|
|
284
|
+
* outcome and are kept UNIDENTIFIED rather than dropped. Dropping a settled part would be silent
|
|
285
|
+
* data loss — the transcript would simply forget how the call ended. */
|
|
240
286
|
function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPart | null {
|
|
241
|
-
if (typeof obj['toolCallId'] !== 'string') return null
|
|
242
287
|
if (typeof obj['toolName'] !== 'string') return null
|
|
243
288
|
if (typeof obj['state'] !== 'string') return null
|
|
244
289
|
|
|
245
|
-
const
|
|
290
|
+
const rawToolCallId = obj['toolCallId']
|
|
291
|
+
if (rawToolCallId !== undefined && typeof rawToolCallId !== 'string') return null
|
|
292
|
+
const toolCallId =
|
|
293
|
+
typeof rawToolCallId === 'string' && rawToolCallId.length > 0 ? rawToolCallId : undefined
|
|
294
|
+
|
|
295
|
+
const toolName = obj['toolName']
|
|
246
296
|
const common = {
|
|
247
297
|
id,
|
|
248
298
|
type: 'tool' as const,
|
|
249
|
-
toolCallId,
|
|
250
299
|
toolName,
|
|
251
300
|
...(typeof obj['durationMs'] === 'number' ? { durationMs: obj['durationMs'] } : {}),
|
|
252
301
|
...(typeof obj['providerExecuted'] === 'boolean'
|
|
253
302
|
? { providerExecuted: obj['providerExecuted'] }
|
|
254
303
|
: {}),
|
|
255
304
|
}
|
|
305
|
+
// The three in-flight states below require an identified call; the four settled ones spread
|
|
306
|
+
// `identity` instead, which is simply empty when the wire supplied no usable id.
|
|
307
|
+
const identified = toolCallId !== undefined ? { ...common, toolCallId } : null
|
|
308
|
+
const identity = toolCallId !== undefined ? { toolCallId } : {}
|
|
256
309
|
|
|
257
310
|
switch (obj['state']) {
|
|
258
311
|
case 'input-streaming':
|
|
312
|
+
if (identified === null) return null
|
|
259
313
|
return {
|
|
260
|
-
...
|
|
314
|
+
...identified,
|
|
261
315
|
state: 'input-streaming',
|
|
262
316
|
...('input' in obj ? { input: obj['input'] } : {}),
|
|
263
317
|
}
|
|
264
318
|
case 'input-available':
|
|
319
|
+
if (identified === null) return null
|
|
265
320
|
if (!('input' in obj)) return null
|
|
266
|
-
return { ...
|
|
321
|
+
return { ...identified, state: 'input-available', input: obj['input'] }
|
|
267
322
|
case 'approval-requested': {
|
|
323
|
+
if (identified === null) return null
|
|
268
324
|
if (!('input' in obj)) return null
|
|
269
325
|
const approval = parseApproval(obj['approval'])
|
|
270
326
|
// The type declares this state's approval as `{ approved?: never; reason?: never }` — a
|
|
@@ -274,7 +330,7 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
|
|
|
274
330
|
return null
|
|
275
331
|
}
|
|
276
332
|
return {
|
|
277
|
-
...
|
|
333
|
+
...identified,
|
|
278
334
|
state: 'approval-requested',
|
|
279
335
|
input: obj['input'],
|
|
280
336
|
approval: approval as ToolApproval & { approved?: never; reason?: never },
|
|
@@ -284,8 +340,10 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
|
|
|
284
340
|
if (!('input' in obj)) return null
|
|
285
341
|
const approval = parseApproval(obj['approval'])
|
|
286
342
|
if (approval === null || typeof approval.approved !== 'boolean') return null
|
|
343
|
+
// The state the WIRE ships unidentified — `approval.id` is then its only addressing key.
|
|
287
344
|
return {
|
|
288
345
|
...common,
|
|
346
|
+
...identity,
|
|
289
347
|
state: 'approval-responded',
|
|
290
348
|
input: obj['input'],
|
|
291
349
|
approval: approval as ToolApproval & { approved: boolean },
|
|
@@ -297,6 +355,7 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
|
|
|
297
355
|
if (approval === null || approval.approved !== false) return null
|
|
298
356
|
return {
|
|
299
357
|
...common,
|
|
358
|
+
...identity,
|
|
300
359
|
state: 'output-denied',
|
|
301
360
|
input: obj['input'],
|
|
302
361
|
approval: approval as ToolApproval & { approved: false },
|
|
@@ -315,6 +374,7 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
|
|
|
315
374
|
}
|
|
316
375
|
return {
|
|
317
376
|
...common,
|
|
377
|
+
...identity,
|
|
318
378
|
state: 'output-available',
|
|
319
379
|
input: obj['input'],
|
|
320
380
|
output: obj['output'],
|
|
@@ -332,6 +392,7 @@ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPa
|
|
|
332
392
|
}
|
|
333
393
|
return {
|
|
334
394
|
...common,
|
|
395
|
+
...identity,
|
|
335
396
|
state: 'output-error',
|
|
336
397
|
errorText: obj['errorText'],
|
|
337
398
|
...('input' in obj ? { input: obj['input'] } : {}),
|