basalt-ui 1.10.0 → 1.11.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 +2 -2
- package/CHANGELOG.md +10 -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/coalesce.d.ts +26 -0
- package/dist/agent/coalesce.d.ts.map +1 -0
- package/dist/agent/coalesce.js +69 -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/history.d.ts +2 -0
- package/dist/agent/history.d.ts.map +1 -1
- package/dist/agent/id.d.ts +35 -0
- package/dist/agent/id.d.ts.map +1 -0
- package/dist/agent/id.js +14 -0
- package/dist/agent/index.d.ts +9 -4
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +15 -3
- package/dist/agent/merge.d.ts +8 -0
- package/dist/agent/merge.d.ts.map +1 -0
- package/dist/agent/merge.js +23 -0
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +16 -6
- package/dist/agent/parts.d.ts +100 -21
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +128 -11
- 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/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-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.js +75 -23
- package/dist/agent-chat/index.d.ts +2 -0
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- 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 +102 -43
- 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/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +1 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/surfaces.d.ts +2 -2
- package/dist/surfaces.js +2 -2
- package/llms.txt +4 -4
- package/package.json +1 -1
- package/src/agent/ai-sdk-transport.test.ts +338 -40
- package/src/agent/ai-sdk-transport.ts +206 -53
- package/src/agent/coalesce.test.ts +194 -0
- package/src/agent/coalesce.ts +118 -0
- package/src/agent/foreign.test.ts +47 -0
- package/src/agent/foreign.ts +140 -0
- package/src/agent/history.ts +2 -0
- package/src/agent/id.test.ts +71 -0
- package/src/agent/id.ts +43 -0
- package/src/agent/index.ts +26 -3
- package/src/agent/merge.test.ts +119 -0
- package/src/agent/merge.ts +67 -0
- package/src/agent/part-list.test.tsx +82 -0
- package/src/agent/part-list.tsx +31 -6
- package/src/agent/parts.test.ts +401 -59
- package/src/agent/parts.ts +262 -32
- package/src/agent/transport.test.ts +70 -0
- package/src/agent/transport.ts +72 -20
- package/src/agent/use-agent-stream.test.tsx +47 -17
- package/src/agent/use-agent-stream.ts +13 -3
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +34 -6
- package/src/agent/use-agent-thread-runs.test.tsx +397 -14
- package/src/agent/use-agent-thread-runs.ts +170 -26
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +4 -2
- package/src/agent-chat/index.ts +4 -0
- package/src/agent-chat/thread-message.test.tsx +211 -0
- package/src/agent-chat/thread-message.tsx +235 -62
- package/src/agent-chat/tool-chip.test.tsx +139 -0
- package/src/agent-chat/tool-chip.tsx +219 -0
- package/src/guard/check-source.test.ts +8 -4
- package/src/guard/index.ts +1 -3
- package/src/index.ts +2 -0
- package/src/surfaces.ts +2 -2
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
|
|
12
|
+
describe('narrowAgentPart', () => {
|
|
13
|
+
const BUILT_IN_SAMPLES: TranscriptPart[] = [
|
|
14
|
+
{ id: 'p1', type: 'start', runId: 'r1' },
|
|
15
|
+
{ id: 'p2', type: 'text', text: 'hi' },
|
|
16
|
+
{ id: 'p3', type: 'reasoning', text: 'thinking' },
|
|
17
|
+
{
|
|
18
|
+
id: 'p4',
|
|
19
|
+
type: 'tool',
|
|
20
|
+
toolCallId: 'c1',
|
|
21
|
+
toolName: 'search',
|
|
22
|
+
state: 'input-available',
|
|
23
|
+
input: {},
|
|
24
|
+
},
|
|
25
|
+
{ id: 'p5', type: 'source', url: 'https://example.com' },
|
|
26
|
+
{ id: 'p6', type: 'error', message: 'boom' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
for (const part of BUILT_IN_SAMPLES) {
|
|
30
|
+
test(`narrows a built-in '${part.type}' part`, () => {
|
|
31
|
+
expect(narrowAgentPart(part)).toBe(part)
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('returns null for a foreign part type', () => {
|
|
36
|
+
const foreign: TranscriptPart = { id: 'p7', type: 'data-chart', spec: {} }
|
|
37
|
+
expect(narrowAgentPart(foreign)).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('definePartRenderers', () => {
|
|
42
|
+
test('identity passthrough — returns the exact map it was given', () => {
|
|
43
|
+
const renderChart = (): null => null
|
|
44
|
+
const map = { 'data-chart': renderChart }
|
|
45
|
+
expect(definePartRenderers(map)).toBe(map)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* foreign.ts — the open half of the transcript's part-rendering registry.
|
|
3
|
+
*
|
|
4
|
+
* `AgentPart` (`./parts`) is a closed, exhaustively-switched union — adding a variant without
|
|
5
|
+
* updating every consuming switch is a tsc error (`PartList`'s `assertNever` gate). A transport
|
|
6
|
+
* that streams app-specific structured data (a chart spec, a progress ping) needs a part type
|
|
7
|
+
* `AgentPart` will never carry — closing the union around it would defeat the whole point of the
|
|
8
|
+
* gate. This file is the seam: `ForeignPart` describes what basalt does NOT know, `ConsumerPart`
|
|
9
|
+
* resolves the augmented union a consumer registers via `declare module 'basalt-ui'`, and
|
|
10
|
+
* `definePartRenderers` types an exhaustive renderer map against it. See
|
|
11
|
+
* `agent-chat/thread-message.tsx`'s three-step resolution order for how this plugs into
|
|
12
|
+
* `ThreadTranscript`: a consumer renderer wins first, `narrowAgentPart` routes the built-in six to
|
|
13
|
+
* `PartList`'s own exhaustive switch second, and `fallbackRenderer` catches anything left.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* // app-side: src/features/chat/parts.ts
|
|
17
|
+
* type ChartPart = { type: 'data-chart'; id: string; spec: ChartSpec }
|
|
18
|
+
* declare module 'basalt-ui' {
|
|
19
|
+
* interface BasaltRegister { parts: ChartPart }
|
|
20
|
+
* }
|
|
21
|
+
* export const chatRenderers = definePartRenderers({
|
|
22
|
+
* 'data-chart': ({ part, settled }) => (settled ? <Chart spec={part.spec} /> : <ChartSkeleton />),
|
|
23
|
+
* })
|
|
24
|
+
*/
|
|
25
|
+
import type { ReactNode } from 'react'
|
|
26
|
+
import type { BasaltRegister } from '../register'
|
|
27
|
+
import type { ChatMessage } from './history'
|
|
28
|
+
import type { AgentPart } from './parts'
|
|
29
|
+
|
|
30
|
+
// ── ForeignPart / TranscriptPart ──────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** A part basalt does not know. NOT a member of AgentPart — the built-in union stays closed. */
|
|
33
|
+
export type ForeignPart = {
|
|
34
|
+
readonly type: string
|
|
35
|
+
readonly id: string
|
|
36
|
+
readonly [k: string]: unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What a transcript actually holds — basalt's own six variants plus anything a consumer registers. */
|
|
40
|
+
export type TranscriptPart = AgentPart | ForeignPart
|
|
41
|
+
|
|
42
|
+
// ── ConsumerPart ───────────────────────────────────────────────────────────────
|
|
43
|
+
//
|
|
44
|
+
// JUDGMENT POINT — this is a sibling conditional, NOT a reuse of `register.ts`'s `Slot<K,
|
|
45
|
+
// Constraint>`, and deliberately so:
|
|
46
|
+
//
|
|
47
|
+
// `Slot<K, Constraint>` is built for MAP-shaped slots (series, commands, overlays,
|
|
48
|
+
// notifications): its un-augmented fallback is the never-keyed empty object `{}`, which is the
|
|
49
|
+
// correct zero for a MAP (`keyof {}` is `never`, so the derived key-union — e.g. `SeriesKey` — is
|
|
50
|
+
// `never` too). `parts` is UNION-shaped, not map-shaped: there is no key to look anything up by,
|
|
51
|
+
// the type itself IS the legal value set. So the un-augmented fallback here has to be `never`
|
|
52
|
+
// (the empty union), not `{}`.
|
|
53
|
+
//
|
|
54
|
+
// Concretely, reusing `Slot<'parts', ForeignPart>` would hand back `{}` when un-augmented (an
|
|
55
|
+
// empty object does not itself satisfy `ForeignPart`'s `{ type: string; id: string }` shape, but
|
|
56
|
+
// `Slot`'s fallback branch returns it unconditionally regardless of `Constraint`), and then
|
|
57
|
+
// `PartRenderers` below — which reads `ConsumerPart['type']` on the augmented branch and checks
|
|
58
|
+
// `[ConsumerPart] extends [never]` to pick between the two branches — would see a non-`never`,
|
|
59
|
+
// property-less `{}` and take the WRONG branch (or fail to compile the indexed access) even with
|
|
60
|
+
// zero augmentation, exactly the case that must compile cleanly. `Slot`'s fallback isn't a
|
|
61
|
+
// parameter (nor should it be — every existing map slot wants `{}`), so this is a sibling
|
|
62
|
+
// conditional purpose-built for a union slot, not a gap in `Slot` itself.
|
|
63
|
+
/** The consumer's registered foreign-part union, or `never` when un-augmented. */
|
|
64
|
+
export type ConsumerPart = BasaltRegister extends { parts: infer P extends ForeignPart } ? P : never
|
|
65
|
+
|
|
66
|
+
// ── PartRenderers ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Un-augmented: a loose string-keyed map (any renderer, any key — there is nothing to check
|
|
70
|
+
* against). Augmented: a MISSING key is a tsc error (`definePartRenderers` requires the full
|
|
71
|
+
* registered union), and each renderer's `part` is narrowed to exactly its variant. Same
|
|
72
|
+
* bound-check mechanism as `SeriesKey` (`register.ts:26-58`), applied to a union instead of a map.
|
|
73
|
+
*
|
|
74
|
+
* NOT caught: a STALE (extra, unregistered) key. `<const T extends Constraint>` inference does
|
|
75
|
+
* not excess-property-check an object-literal argument the way a direct assignment to a fixed
|
|
76
|
+
* target type would (verified against this repo's tsc — see
|
|
77
|
+
* `apps/playground/src/demo/agent-part-registry.type-guard.ts`'s Fixture 3) — an extra key
|
|
78
|
+
* compiles as dead code rather than erroring. Catching it would need an `Exact<T, Shape>`-style
|
|
79
|
+
* wrapper that departs from the `spec: T` "canonical token-factory contract" every other `defineX`
|
|
80
|
+
* in this codebase follows verbatim; that trade was left as a documented gap rather than a one-off
|
|
81
|
+
* factory shape.
|
|
82
|
+
*/
|
|
83
|
+
export type PartRenderers = [ConsumerPart] extends [never]
|
|
84
|
+
? Readonly<Record<string, PartRenderer<ForeignPart>>>
|
|
85
|
+
: { readonly [K in ConsumerPart['type']]: PartRenderer<Extract<ConsumerPart, { type: K }>> }
|
|
86
|
+
|
|
87
|
+
/** Everything a foreign-part renderer needs beyond the part itself. */
|
|
88
|
+
export type PartRenderContext<TPart = ForeignPart> = {
|
|
89
|
+
readonly part: TPart
|
|
90
|
+
readonly messageId: string
|
|
91
|
+
readonly partId: string
|
|
92
|
+
/** false while this part belongs to the in-flight tail of a streaming turn. */
|
|
93
|
+
readonly settled: boolean
|
|
94
|
+
readonly role: ChatMessage['role']
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type PartRenderer<TPart = ForeignPart> = (ctx: PartRenderContext<TPart>) => ReactNode
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Canonical const-generic factory (`packages/basalt-ui/CLAUDE.md`, "Canonical token-factory
|
|
101
|
+
* contract") — identity passthrough, exact-keyed return, no builder, no config bag. Mirrors
|
|
102
|
+
* `defineSeries` (`tokens/index.ts:648-650`) and `defineNotifications`
|
|
103
|
+
* (`notifications/define-notifications.ts:112-115`).
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* const chatRenderers = definePartRenderers({
|
|
107
|
+
* 'data-chart': ({ part }) => <Chart spec={part.spec} />,
|
|
108
|
+
* })
|
|
109
|
+
*/
|
|
110
|
+
export function definePartRenderers<const T extends PartRenderers>(map: T): T {
|
|
111
|
+
return map
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── narrowAgentPart ──────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
const AGENT_PART_TYPES: ReadonlySet<string> = new Set([
|
|
117
|
+
'start',
|
|
118
|
+
'text',
|
|
119
|
+
'reasoning',
|
|
120
|
+
'tool',
|
|
121
|
+
'source',
|
|
122
|
+
'error',
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Returns `part` as an `AgentPart` when its `type` is one of the six built-in variants, else
|
|
127
|
+
* `null`. Deliberately NOT routed through `parseAgentPart`: that function is a VALIDATING parser
|
|
128
|
+
* for untrusted wire data — it checks every field a variant requires (`runId`, `toolCallId`,
|
|
129
|
+
* `approval`, ...) and would reject an already-well-typed in-memory `AgentPart` on shape grounds
|
|
130
|
+
* if it doesn't happen to match the wire representation byte-for-byte. `narrowAgentPart` is the
|
|
131
|
+
* cheap complement: a plain discriminator check on a value already known to be a `TranscriptPart`,
|
|
132
|
+
* used purely to route rendering — it trusts the caller's typing rather than re-validating it.
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* const agentPart = narrowAgentPart(part)
|
|
136
|
+
* if (agentPart !== null) return <PartList parts={[agentPart]} />
|
|
137
|
+
*/
|
|
138
|
+
export function narrowAgentPart(part: TranscriptPart): AgentPart | null {
|
|
139
|
+
return AGENT_PART_TYPES.has(part.type) ? (part as AgentPart) : null
|
|
140
|
+
}
|
package/src/agent/history.ts
CHANGED
|
@@ -43,6 +43,8 @@ export type ChatMessage<TPart = AgentPart> = {
|
|
|
43
43
|
readonly role: 'user' | 'assistant'
|
|
44
44
|
readonly parts: TPart[]
|
|
45
45
|
readonly createdAt: number
|
|
46
|
+
/** How the turn ended. Absent on user messages and on pre-1.11 persisted assistant messages. */
|
|
47
|
+
readonly finish?: 'complete' | 'stopped' | 'error'
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
// ── createChatHistoryStore options ────────────────────────────────────────────
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* withPartIds — stamps `${runId}#${n}` onto any draft part arriving without an id; idempotent for
|
|
3
|
+
* drafts that already have one (untouched, and doesn't advance the counter).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test'
|
|
6
|
+
import { withPartIds } from './id'
|
|
7
|
+
|
|
8
|
+
async function* gen<T>(values: T[]): AsyncGenerator<T> {
|
|
9
|
+
for (const value of values) yield value
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function collect<T>(source: AsyncGenerator<T>): Promise<T[]> {
|
|
13
|
+
const out: T[] = []
|
|
14
|
+
for await (const value of source) out.push(value)
|
|
15
|
+
return out
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('withPartIds', () => {
|
|
19
|
+
test('stamps a sequential id on every draft missing one', async () => {
|
|
20
|
+
const drafts = [
|
|
21
|
+
{ type: 'text', text: 'a' },
|
|
22
|
+
{ type: 'text', text: 'b' },
|
|
23
|
+
{ type: 'text', text: 'c' },
|
|
24
|
+
]
|
|
25
|
+
const result = await collect(withPartIds('run-1', gen(drafts)))
|
|
26
|
+
expect(result).toEqual([
|
|
27
|
+
{ type: 'text', text: 'a', id: 'run-1#0' },
|
|
28
|
+
{ type: 'text', text: 'b', id: 'run-1#1' },
|
|
29
|
+
{ type: 'text', text: 'c', id: 'run-1#2' },
|
|
30
|
+
])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('leaves a part that already has an id untouched', async () => {
|
|
34
|
+
const drafts = [{ type: 'text', text: 'a', id: 'custom-id' }]
|
|
35
|
+
const result = await collect(withPartIds('run-1', gen(drafts)))
|
|
36
|
+
expect(result).toEqual([{ type: 'text', text: 'a', id: 'custom-id' }])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('is idempotent: re-running withPartIds over already-identified parts changes nothing', async () => {
|
|
40
|
+
const drafts = [{ type: 'text', text: 'a' }]
|
|
41
|
+
const once = await collect(withPartIds('run-1', gen(drafts)))
|
|
42
|
+
const twice = await collect(withPartIds('run-1', gen(once)))
|
|
43
|
+
expect(twice).toEqual(once)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('does not advance the counter for parts that already carry an id', async () => {
|
|
47
|
+
const drafts = [
|
|
48
|
+
{ type: 'text', text: 'a', id: 'preset' },
|
|
49
|
+
{ type: 'text', text: 'b' },
|
|
50
|
+
]
|
|
51
|
+
const result = await collect(withPartIds('run-1', gen(drafts)))
|
|
52
|
+
expect(result).toEqual([
|
|
53
|
+
{ type: 'text', text: 'a', id: 'preset' },
|
|
54
|
+
{ type: 'text', text: 'b', id: 'run-1#0' },
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('mixed run: only missing ids are stamped, sequentially among themselves', async () => {
|
|
59
|
+
const drafts = [
|
|
60
|
+
{ type: 'text', text: 'a' },
|
|
61
|
+
{ type: 'text', text: 'b', id: 'preset' },
|
|
62
|
+
{ type: 'text', text: 'c' },
|
|
63
|
+
]
|
|
64
|
+
const result = await collect(withPartIds('run-42', gen(drafts)))
|
|
65
|
+
expect(result).toEqual([
|
|
66
|
+
{ type: 'text', text: 'a', id: 'run-42#0' },
|
|
67
|
+
{ type: 'text', text: 'b', id: 'preset' },
|
|
68
|
+
{ type: 'text', text: 'c', id: 'run-42#1' },
|
|
69
|
+
])
|
|
70
|
+
})
|
|
71
|
+
})
|
package/src/agent/id.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* withPartIds — stamps a stable id onto every draft part arriving without one.
|
|
3
|
+
*
|
|
4
|
+
* A transport is allowed to yield a draft (`AgentPartDraft` — `id` optional); this wraps such a
|
|
5
|
+
* generator and normalizes it into a fully-identified stream, minting `${runId}#${n}` for any
|
|
6
|
+
* part that arrives without an `id`. A part that already carries an id passes through untouched
|
|
7
|
+
* (idempotent) and does not advance the counter.
|
|
8
|
+
*
|
|
9
|
+
* NOT a replay-convergence mechanism. The minted id is a bare per-call sequence number with no
|
|
10
|
+
* relationship to content or offset — it identifies "the nth id-less part THIS invocation saw",
|
|
11
|
+
* nothing more. Calling `withPartIds` again over a resumed/replayed stream restarts its counter at
|
|
12
|
+
* 0, so the resumed stream's first delta mints the SAME id as the original run's first delta
|
|
13
|
+
* (`${runId}#0`) regardless of whether it carries the same content — if the resumed transport
|
|
14
|
+
* re-sends the full text as one chunk while the original sent it as several deltas, mergePart's
|
|
15
|
+
* tail-splice will corrupt the accumulated text rather than converge it. A transport that needs
|
|
16
|
+
* genuine replay convergence (one asserting `ResumableAgentTransport`'s `idempotentReplay: true`)
|
|
17
|
+
* MUST mint its own content-stable ids — e.g. `aiSdkTransport`'s `${chatId}#${index}` /
|
|
18
|
+
* `tool#${toolCallId}` — and must not rely on this helper for that guarantee. Combining
|
|
19
|
+
* `withPartIds` with a transport that asserts `idempotentReplay: true` is only safe when that
|
|
20
|
+
* transport already mints stable ids itself, in which case every part already carries an `id` and
|
|
21
|
+
* `withPartIds` is a no-op passthrough (see the idempotent behavior above) — it is never the thing
|
|
22
|
+
* providing replay safety.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const identified = withPartIds(runId, transport.stream(input, signal))
|
|
26
|
+
* for await (const part of identified) {
|
|
27
|
+
* // part.id is always defined here
|
|
28
|
+
* }
|
|
29
|
+
*/
|
|
30
|
+
export async function* withPartIds<TPart extends { id?: string }>(
|
|
31
|
+
runId: string,
|
|
32
|
+
source: AsyncGenerator<TPart>,
|
|
33
|
+
): AsyncGenerator<TPart & { id: string }> {
|
|
34
|
+
let n = 0
|
|
35
|
+
for await (const part of source) {
|
|
36
|
+
if (part.id !== undefined) {
|
|
37
|
+
yield part as TPart & { id: string }
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
yield { ...part, id: `${runId}#${n}` }
|
|
41
|
+
n += 1
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/agent/index.ts
CHANGED
|
@@ -34,18 +34,41 @@
|
|
|
34
34
|
// ── AgentPart discriminated union ─────────────────────────────────────────────
|
|
35
35
|
export type {
|
|
36
36
|
AgentPart,
|
|
37
|
+
AgentPartDraft,
|
|
38
|
+
Drafted,
|
|
37
39
|
StartPart,
|
|
38
40
|
TextPart,
|
|
39
41
|
ReasoningPart,
|
|
40
42
|
ToolCallPart,
|
|
43
|
+
ToolCallState,
|
|
41
44
|
SourcePart,
|
|
42
45
|
ErrorPart,
|
|
43
46
|
} from './parts'
|
|
44
|
-
export { parseAgentPart, isStartPart } from './parts'
|
|
47
|
+
export { parseAgentPart, isStartPart, isToolCallSettled, TERMINAL_TOOL_STATES } from './parts'
|
|
48
|
+
|
|
49
|
+
// ── mergePart (identity-addressed accumulator) ───────────────────────────────
|
|
50
|
+
export { mergePart } from './merge'
|
|
51
|
+
|
|
52
|
+
// ── withPartIds (drafts → identified parts) ──────────────────────────────────
|
|
53
|
+
export { withPartIds } from './id'
|
|
54
|
+
|
|
55
|
+
// ── coalesceParts (adjacent + by-id merge for display) ───────────────────────
|
|
56
|
+
export { coalesceParts } from './coalesce'
|
|
57
|
+
|
|
58
|
+
// ── foreign parts (the open half of the part-renderer registry) ──────────────
|
|
59
|
+
export { definePartRenderers, narrowAgentPart } from './foreign'
|
|
60
|
+
export type {
|
|
61
|
+
ForeignPart,
|
|
62
|
+
TranscriptPart,
|
|
63
|
+
ConsumerPart,
|
|
64
|
+
PartRenderers,
|
|
65
|
+
PartRenderContext,
|
|
66
|
+
PartRenderer,
|
|
67
|
+
} from './foreign'
|
|
45
68
|
|
|
46
69
|
// ── AgentTransport + edenTransport ───────────────────────────────────────────
|
|
47
|
-
export type { AgentTransport } from './transport'
|
|
48
|
-
export { edenTransport } from './transport'
|
|
70
|
+
export type { AgentTransport, ResumableAgentTransport } from './transport'
|
|
71
|
+
export { edenTransport, isResumable } from './transport'
|
|
49
72
|
|
|
50
73
|
// ── aiSdkTransport (recommended default, optional peer: ai) ──────────────────
|
|
51
74
|
export { aiSdkTransport } from './ai-sdk-transport'
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mergePart — the identity-addressed accumulator. The behaviour under test that matters most:
|
|
3
|
+
* replaying a whole run from character offset 0 rebuilds the identical parts array it produced
|
|
4
|
+
* the first time — it cannot double the content.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test'
|
|
7
|
+
import { mergePart } from './merge'
|
|
8
|
+
import type { TranscriptPart } from './foreign'
|
|
9
|
+
import type { AgentPart, TextPart, ToolCallPart } from './parts'
|
|
10
|
+
|
|
11
|
+
describe('mergePart', () => {
|
|
12
|
+
test('appends a part whose id is not yet present', () => {
|
|
13
|
+
const parts: AgentPart[] = [{ id: 'p1', type: 'text', text: 'Hi' }]
|
|
14
|
+
const next: AgentPart = { id: 'p2', type: 'text', text: 'there' }
|
|
15
|
+
expect(mergePart(parts, next)).toEqual([
|
|
16
|
+
{ id: 'p1', type: 'text', text: 'Hi' },
|
|
17
|
+
{ id: 'p2', type: 'text', text: 'there' },
|
|
18
|
+
])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('an undefined offset splices at the tail (append)', () => {
|
|
22
|
+
const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hel' }]
|
|
23
|
+
const next: TextPart = { id: 'p1', type: 'text', text: 'lo' }
|
|
24
|
+
const [merged] = mergePart(parts, next)
|
|
25
|
+
expect(merged?.text).toBe('Hello')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('splices an explicit offset into the middle of the existing text', () => {
|
|
29
|
+
const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hello world' }]
|
|
30
|
+
// Replace the 5-char span starting at offset 6 ("world") with "there"
|
|
31
|
+
const next: TextPart = { id: 'p1', type: 'text', text: 'there', offset: 6 }
|
|
32
|
+
const [merged] = mergePart(parts, next)
|
|
33
|
+
expect(merged?.text).toBe('Hello there')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('reasoning splices the same way as text', () => {
|
|
37
|
+
const parts = [{ id: 'p1', type: 'reasoning' as const, text: 'thinking' }]
|
|
38
|
+
const next = { id: 'p1', type: 'reasoning' as const, text: ' more' }
|
|
39
|
+
const [merged] = mergePart(parts, next)
|
|
40
|
+
expect(merged?.text).toBe('thinking more')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('offset-0 full replay rebuilds an identical array — it does not double the content', () => {
|
|
44
|
+
// First pass: two deltas building "Hello world" from nothing.
|
|
45
|
+
let parts: TextPart[] = []
|
|
46
|
+
parts = mergePart(parts, { id: 'p1', type: 'text', text: 'Hello' })
|
|
47
|
+
parts = mergePart(parts, { id: 'p1', type: 'text', text: ' world', offset: 5 })
|
|
48
|
+
expect(parts).toHaveLength(1)
|
|
49
|
+
expect(parts[0]?.text).toBe('Hello world')
|
|
50
|
+
const firstPassResult = parts
|
|
51
|
+
|
|
52
|
+
// Replay from character 0: the whole run resent as one delta at offset 0.
|
|
53
|
+
const replayed = mergePart(parts, { id: 'p1', type: 'text', text: 'Hello world', offset: 0 })
|
|
54
|
+
|
|
55
|
+
expect(replayed).toHaveLength(1)
|
|
56
|
+
expect(replayed[0]?.text).toBe('Hello world')
|
|
57
|
+
expect(replayed).toEqual(firstPassResult.map((p) => ({ ...p, offset: 0 })))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('non-text/reasoning variants replace wholesale rather than merging fields', () => {
|
|
61
|
+
const parts: ToolCallPart[] = [
|
|
62
|
+
{
|
|
63
|
+
id: 'p1',
|
|
64
|
+
type: 'tool',
|
|
65
|
+
toolCallId: 'call-1',
|
|
66
|
+
toolName: 'search',
|
|
67
|
+
state: 'input-available',
|
|
68
|
+
input: { q: 'x' },
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
const next: ToolCallPart = {
|
|
72
|
+
id: 'p1',
|
|
73
|
+
type: 'tool',
|
|
74
|
+
toolCallId: 'call-1',
|
|
75
|
+
toolName: 'search',
|
|
76
|
+
state: 'output-available',
|
|
77
|
+
input: { q: 'x' },
|
|
78
|
+
output: { hits: 3 },
|
|
79
|
+
}
|
|
80
|
+
expect(mergePart(parts, next)).toEqual([next])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('a rewrite keeps the original position — ordering is stable', () => {
|
|
84
|
+
const parts: AgentPart[] = [
|
|
85
|
+
{ id: 'a', type: 'text', text: 'first' },
|
|
86
|
+
{ id: 'b', type: 'text', text: 'second' },
|
|
87
|
+
{ id: 'c', type: 'text', text: 'third' },
|
|
88
|
+
]
|
|
89
|
+
const next: AgentPart = { id: 'b', type: 'text', text: '!!', offset: 6 }
|
|
90
|
+
const merged = mergePart(parts, next)
|
|
91
|
+
expect(merged.map((p) => p.id)).toEqual(['a', 'b', 'c'])
|
|
92
|
+
expect((merged[1] as TextPart).text).toBe('second!!')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// B2 convergence fix: mergePart is constrained on PartLike (structural `id` + `type`), not the
|
|
96
|
+
// closed AgentPart union, precisely so a ForeignPart (never text-like) flows through the same
|
|
97
|
+
// accumulator. This is the compile-time contract `useAgentThreadRuns<TranscriptPart>` depends on.
|
|
98
|
+
test('a foreign part (never text-like) survives a round trip unchanged', () => {
|
|
99
|
+
const parts: TranscriptPart[] = []
|
|
100
|
+
const first = mergePart(parts, {
|
|
101
|
+
id: 'f1',
|
|
102
|
+
type: 'data-toolProgress',
|
|
103
|
+
tool: 'search',
|
|
104
|
+
message: 'searching',
|
|
105
|
+
})
|
|
106
|
+
expect(first).toEqual([
|
|
107
|
+
{ id: 'f1', type: 'data-toolProgress', tool: 'search', message: 'searching' },
|
|
108
|
+
])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('a replayed foreign part (same id) replaces wholesale rather than duplicating', () => {
|
|
112
|
+
let parts: TranscriptPart[] = []
|
|
113
|
+
parts = mergePart(parts, { id: 'f1', type: 'data-toolProgress', message: 'searching' })
|
|
114
|
+
parts = mergePart(parts, { id: 'f1', type: 'data-toolProgress', message: 'done' })
|
|
115
|
+
|
|
116
|
+
expect(parts).toHaveLength(1)
|
|
117
|
+
expect(parts).toEqual([{ id: 'f1', type: 'data-toolProgress', message: 'done' }])
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mergePart — identity-addressed accumulator over a part array.
|
|
3
|
+
*
|
|
4
|
+
* Appending a part whose `id` is already present in `parts` REWRITES that entry in place rather
|
|
5
|
+
* than pushing a duplicate — ordering stays stable (a rewritten part keeps its original position).
|
|
6
|
+
* `text`/`reasoning` splice the incoming delta into the existing text at `next.offset` (an
|
|
7
|
+
* undefined offset means "append at the tail"); every other variant — including any `ForeignPart`,
|
|
8
|
+
* which is never text-like — replaces wholesale.
|
|
9
|
+
*
|
|
10
|
+
* The invariant this holds, and the reason `offset` exists at all: replaying a whole run from
|
|
11
|
+
* character 0 rebuilds the identical parts array it produced the first time — it cannot double
|
|
12
|
+
* it. Splicing a full-length delta at offset 0 overwrites the existing text in place (the
|
|
13
|
+
* trailing slice covers nothing once the incoming text's length matches what's already there),
|
|
14
|
+
* so a resumed/replayed stream converges instead of duplicating content.
|
|
15
|
+
*
|
|
16
|
+
* Constrained on `PartLike` (structural: `id` + `type`), NOT `AgentPart` — a widened, backward-
|
|
17
|
+
* compatible relaxation so `TranscriptPart` (`AgentPart | ForeignPart`, ./foreign) flows through
|
|
18
|
+
* the same accumulator a transport-streamed foreign part must pass through on its way from the
|
|
19
|
+
* transport into `useAgentThreadRuns`'s `runs` map. `AgentPart`'s closed union is untouched —
|
|
20
|
+
* only the constraint widens, never the union itself.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* let parts: AgentPart[] = []
|
|
24
|
+
* parts = mergePart(parts, { id: 'p1', type: 'text', text: 'Hel' })
|
|
25
|
+
* parts = mergePart(parts, { id: 'p1', type: 'text', text: 'lo' }) // splices at the tail
|
|
26
|
+
* // parts === [{ id: 'p1', type: 'text', text: 'Hello' }]
|
|
27
|
+
*/
|
|
28
|
+
import type { ReasoningPart, TextPart } from './parts'
|
|
29
|
+
|
|
30
|
+
type TextLike = TextPart | ReasoningPart
|
|
31
|
+
|
|
32
|
+
/** The structural bound `mergePart` (and the hooks that call it) actually need — an `AgentPart` or
|
|
33
|
+
* a `ForeignPart` both satisfy it, so this is the widened constraint, not a new closed union. */
|
|
34
|
+
export type PartLike = { readonly id: string; readonly type: string }
|
|
35
|
+
|
|
36
|
+
// Widened from `AgentPart['type']` to `string`: a plain type-predicate narrowing, so accepting a
|
|
37
|
+
// wider input (any string, including a ForeignPart's) is safe — it still only returns true for the
|
|
38
|
+
// two text-like literals, and a ForeignPart's type string always falls through it to the
|
|
39
|
+
// wholesale-replace path below.
|
|
40
|
+
function isTextLikeType(type: string): type is TextLike['type'] {
|
|
41
|
+
return type === 'text' || type === 'reasoning'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Splices `next`'s text into `existing`'s text at `next.offset` (tail-append when undefined). */
|
|
45
|
+
function spliceText(existing: TextLike, next: TextLike): string {
|
|
46
|
+
const offset = next.offset ?? existing.text.length
|
|
47
|
+
return existing.text.slice(0, offset) + next.text + existing.text.slice(offset + next.text.length)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function mergePart<TPart extends PartLike>(parts: readonly TPart[], next: TPart): TPart[] {
|
|
51
|
+
const index = parts.findIndex((part) => part.id === next.id)
|
|
52
|
+
if (index === -1) return [...parts, next]
|
|
53
|
+
|
|
54
|
+
const existing = parts[index] as TPart
|
|
55
|
+
const spliceable = existing.type === next.type && isTextLikeType(next.type)
|
|
56
|
+
|
|
57
|
+
const merged: TPart = spliceable
|
|
58
|
+
? ({
|
|
59
|
+
...next,
|
|
60
|
+
text: spliceText(existing as unknown as TextLike, next as unknown as TextLike),
|
|
61
|
+
} as TPart)
|
|
62
|
+
: next
|
|
63
|
+
|
|
64
|
+
const out = parts.slice()
|
|
65
|
+
out[index] = merged
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PartList's headless default tool-call renderer — a failed/denied call must stay visible.
|
|
3
|
+
*
|
|
4
|
+
* Regression coverage: after the seven-state ToolCallPart split, `output-error` carries no
|
|
5
|
+
* `output` field, so the old `part.state === 'output-available'`-only body left a failed call
|
|
6
|
+
* rendering as name+input and nothing else — the failure was silently invisible. Same gap for
|
|
7
|
+
* `output-denied`, indistinguishable from a still-pending call.
|
|
8
|
+
*/
|
|
9
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
11
|
+
import { PartList } from './part-list'
|
|
12
|
+
import type { ToolCallPart } from './parts'
|
|
13
|
+
|
|
14
|
+
afterEach(cleanup)
|
|
15
|
+
|
|
16
|
+
const BASE = { id: 'p1', type: 'tool' as const, toolCallId: 'c1', toolName: 'search' }
|
|
17
|
+
|
|
18
|
+
describe('DefaultToolCall — terminal states stay visible', () => {
|
|
19
|
+
test('output-error renders the errorText', () => {
|
|
20
|
+
const part: ToolCallPart = { ...BASE, state: 'output-error', errorText: 'boom' }
|
|
21
|
+
render(<PartList parts={[part]} />)
|
|
22
|
+
expect(screen.getByText('boom')).not.toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('output-error with no validated input falls back to rendering rawInput', () => {
|
|
26
|
+
const part: ToolCallPart = {
|
|
27
|
+
...BASE,
|
|
28
|
+
state: 'output-error',
|
|
29
|
+
errorText: 'boom',
|
|
30
|
+
rawInput: { q: 'x' },
|
|
31
|
+
}
|
|
32
|
+
render(<PartList parts={[part]} />)
|
|
33
|
+
expect(screen.getByText(/"q": "x"/)).not.toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('output-error with validated input present does not also render rawInput', () => {
|
|
37
|
+
const part: ToolCallPart = {
|
|
38
|
+
...BASE,
|
|
39
|
+
state: 'output-error',
|
|
40
|
+
errorText: 'boom',
|
|
41
|
+
input: { q: 'x' },
|
|
42
|
+
rawInput: { q: 'stale' },
|
|
43
|
+
}
|
|
44
|
+
render(<PartList parts={[part]} />)
|
|
45
|
+
expect(screen.queryByText(/stale/)).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('output-denied is visibly denied, not indistinguishable from a pending call', () => {
|
|
49
|
+
const part: ToolCallPart = {
|
|
50
|
+
...BASE,
|
|
51
|
+
state: 'output-denied',
|
|
52
|
+
input: { q: 'x' },
|
|
53
|
+
approval: { id: 'a1', approved: false, reason: 'too risky' },
|
|
54
|
+
}
|
|
55
|
+
render(<PartList parts={[part]} />)
|
|
56
|
+
expect(screen.getByText(/Denied/)).not.toBeNull()
|
|
57
|
+
expect(screen.getByText(/too risky/)).not.toBeNull()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('output-denied with no reason still renders a visible denial', () => {
|
|
61
|
+
const part: ToolCallPart = {
|
|
62
|
+
...BASE,
|
|
63
|
+
state: 'output-denied',
|
|
64
|
+
input: { q: 'x' },
|
|
65
|
+
approval: { id: 'a1', approved: false },
|
|
66
|
+
}
|
|
67
|
+
render(<PartList parts={[part]} />)
|
|
68
|
+
expect(screen.getByText('Denied')).not.toBeNull()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('output-available is unaffected — still renders output, no error/denied line', () => {
|
|
72
|
+
const part: ToolCallPart = {
|
|
73
|
+
...BASE,
|
|
74
|
+
state: 'output-available',
|
|
75
|
+
input: { q: 'x' },
|
|
76
|
+
output: { hits: 3 },
|
|
77
|
+
}
|
|
78
|
+
render(<PartList parts={[part]} />)
|
|
79
|
+
expect(screen.getByText(/"hits": 3/)).not.toBeNull()
|
|
80
|
+
expect(screen.queryByText(/Denied/)).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
})
|