@ably/ai-transport 0.0.1 → 0.2.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/README.md +114 -116
- package/dist/ably-ai-transport.js +1743 -961
- package/dist/ably-ai-transport.js.map +1 -1
- package/dist/ably-ai-transport.umd.cjs +1 -1
- package/dist/ably-ai-transport.umd.cjs.map +1 -1
- package/dist/constants.d.ts +117 -39
- package/dist/core/agent.d.ts +29 -0
- package/dist/core/codec/decoder.d.ts +20 -23
- package/dist/core/codec/encoder.d.ts +11 -8
- package/dist/core/codec/index.d.ts +1 -2
- package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
- package/dist/core/codec/types.d.ts +410 -101
- package/dist/core/transport/agent-session.d.ts +10 -0
- package/dist/core/transport/branch-chain.d.ts +43 -0
- package/dist/core/transport/client-session.d.ts +13 -0
- package/dist/core/transport/decode-fold.d.ts +47 -0
- package/dist/core/transport/headers.d.ts +97 -17
- package/dist/core/transport/index.d.ts +5 -3
- package/dist/core/transport/internal/bounded-map.d.ts +20 -0
- package/dist/core/transport/invocation.d.ts +74 -0
- package/dist/core/transport/load-conversation.d.ts +128 -0
- package/dist/core/transport/load-history.d.ts +39 -0
- package/dist/core/transport/pipe-stream.d.ts +9 -8
- package/dist/core/transport/run-manager.d.ts +78 -0
- package/dist/core/transport/tree.d.ts +435 -0
- package/dist/core/transport/types/agent.d.ts +353 -0
- package/dist/core/transport/types/client.d.ts +168 -0
- package/dist/core/transport/types/shared.d.ts +24 -0
- package/dist/core/transport/types/tree.d.ts +315 -0
- package/dist/core/transport/types/view.d.ts +222 -0
- package/dist/core/transport/types.d.ts +13 -402
- package/dist/core/transport/view.d.ts +354 -0
- package/dist/errors.d.ts +37 -9
- package/dist/index.d.ts +6 -6
- package/dist/logger.d.ts +12 -0
- package/dist/react/ably-ai-transport-react.js +1164 -645
- package/dist/react/ably-ai-transport-react.js.map +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
- package/dist/react/contexts/client-session-context.d.ts +36 -0
- package/dist/react/contexts/client-session-provider.d.ts +53 -0
- package/dist/react/create-session-hooks.d.ts +116 -0
- package/dist/react/index.d.ts +16 -10
- package/dist/react/internal/use-resolved-session.d.ts +36 -0
- package/dist/react/use-ably-messages.d.ts +20 -11
- package/dist/react/use-client-session.d.ts +81 -0
- package/dist/react/use-create-view.d.ts +23 -0
- package/dist/react/use-tree.d.ts +35 -0
- package/dist/react/use-view.d.ts +110 -0
- package/dist/utils.d.ts +32 -23
- package/dist/vercel/ably-ai-transport-vercel.js +2748 -1625
- package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
- package/dist/vercel/codec/decoder.d.ts +5 -18
- package/dist/vercel/codec/encoder.d.ts +6 -36
- package/dist/vercel/codec/events.d.ts +51 -0
- package/dist/vercel/codec/index.d.ts +24 -12
- package/dist/vercel/codec/reducer.d.ts +144 -0
- package/dist/vercel/codec/tool-transitions.d.ts +50 -0
- package/dist/vercel/index.d.ts +4 -2
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +10298 -1410
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +70 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +33 -0
- package/dist/vercel/react/contexts/chat-transport-provider.d.ts +96 -0
- package/dist/vercel/react/index.d.ts +4 -0
- package/dist/vercel/react/use-chat-transport.d.ts +66 -21
- package/dist/vercel/react/use-message-sync.d.ts +31 -12
- package/dist/vercel/run-end-reason.d.ts +29 -0
- package/dist/vercel/transport/chat-transport.d.ts +71 -30
- package/dist/vercel/transport/index.d.ts +25 -18
- package/dist/vercel/transport/run-output-stream.d.ts +56 -0
- package/dist/version.d.ts +2 -0
- package/package.json +47 -34
- package/src/constants.ts +126 -47
- package/src/core/agent.ts +68 -0
- package/src/core/codec/decoder.ts +71 -98
- package/src/core/codec/encoder.ts +115 -58
- package/src/core/codec/index.ts +13 -6
- package/src/core/codec/lifecycle-tracker.ts +10 -9
- package/src/core/codec/types.ts +438 -106
- package/src/core/transport/agent-session.ts +1344 -0
- package/src/core/transport/branch-chain.ts +58 -0
- package/src/core/transport/client-session.ts +775 -0
- package/src/core/transport/decode-fold.ts +91 -0
- package/src/core/transport/headers.ts +182 -19
- package/src/core/transport/index.ts +29 -22
- package/src/core/transport/internal/bounded-map.ts +27 -0
- package/src/core/transport/invocation.ts +98 -0
- package/src/core/transport/load-conversation.ts +355 -0
- package/src/core/transport/load-history.ts +269 -0
- package/src/core/transport/pipe-stream.ts +58 -40
- package/src/core/transport/run-manager.ts +249 -0
- package/src/core/transport/tree.ts +1167 -0
- package/src/core/transport/types/agent.ts +407 -0
- package/src/core/transport/types/client.ts +211 -0
- package/src/core/transport/types/shared.ts +27 -0
- package/src/core/transport/types/tree.ts +344 -0
- package/src/core/transport/types/view.ts +259 -0
- package/src/core/transport/types.ts +13 -527
- package/src/core/transport/view.ts +1271 -0
- package/src/errors.ts +42 -9
- package/src/event-emitter.ts +3 -2
- package/src/index.ts +55 -39
- package/src/logger.ts +14 -1
- package/src/react/contexts/client-session-context.ts +41 -0
- package/src/react/contexts/client-session-provider.tsx +186 -0
- package/src/react/create-session-hooks.ts +141 -0
- package/src/react/index.ts +27 -10
- package/src/react/internal/use-resolved-session.ts +63 -0
- package/src/react/use-ably-messages.ts +47 -19
- package/src/react/use-client-session.ts +201 -0
- package/src/react/use-create-view.ts +72 -0
- package/src/react/use-tree.ts +84 -0
- package/src/react/use-view.ts +275 -0
- package/src/react/vite.config.ts +4 -1
- package/src/utils.ts +63 -45
- package/src/vercel/codec/decoder.ts +336 -255
- package/src/vercel/codec/encoder.ts +348 -196
- package/src/vercel/codec/events.ts +87 -0
- package/src/vercel/codec/index.ts +59 -14
- package/src/vercel/codec/reducer.ts +977 -0
- package/src/vercel/codec/tool-transitions.ts +122 -0
- package/src/vercel/index.ts +7 -3
- package/src/vercel/react/contexts/chat-transport-context.ts +41 -0
- package/src/vercel/react/contexts/chat-transport-provider.tsx +150 -0
- package/src/vercel/react/index.ts +13 -1
- package/src/vercel/react/use-chat-transport.ts +162 -42
- package/src/vercel/react/use-message-sync.ts +121 -22
- package/src/vercel/react/vite.config.ts +4 -2
- package/src/vercel/run-end-reason.ts +78 -0
- package/src/vercel/transport/chat-transport.ts +553 -113
- package/src/vercel/transport/index.ts +40 -28
- package/src/vercel/transport/run-output-stream.ts +170 -0
- package/src/version.ts +2 -0
- package/dist/core/transport/client-transport.d.ts +0 -10
- package/dist/core/transport/conversation-tree.d.ts +0 -9
- package/dist/core/transport/decode-history.d.ts +0 -41
- package/dist/core/transport/server-transport.d.ts +0 -7
- package/dist/core/transport/stream-router.d.ts +0 -19
- package/dist/core/transport/turn-manager.d.ts +0 -34
- package/dist/react/use-active-turns.d.ts +0 -8
- package/dist/react/use-client-transport.d.ts +0 -7
- package/dist/react/use-conversation-tree.d.ts +0 -20
- package/dist/react/use-edit.d.ts +0 -7
- package/dist/react/use-history.d.ts +0 -19
- package/dist/react/use-messages.d.ts +0 -7
- package/dist/react/use-regenerate.d.ts +0 -7
- package/dist/react/use-send.d.ts +0 -7
- package/dist/vercel/codec/accumulator.d.ts +0 -21
- package/src/core/transport/client-transport.ts +0 -959
- package/src/core/transport/conversation-tree.ts +0 -434
- package/src/core/transport/decode-history.ts +0 -337
- package/src/core/transport/server-transport.ts +0 -458
- package/src/core/transport/stream-router.ts +0 -118
- package/src/core/transport/turn-manager.ts +0 -147
- package/src/react/use-active-turns.ts +0 -61
- package/src/react/use-client-transport.ts +0 -37
- package/src/react/use-conversation-tree.ts +0 -71
- package/src/react/use-edit.ts +0 -24
- package/src/react/use-history.ts +0 -111
- package/src/react/use-messages.ts +0 -32
- package/src/react/use-regenerate.ts +0 -24
- package/src/react/use-send.ts +0 -25
- package/src/vercel/codec/accumulator.ts +0 -603
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel AI SDK reducer.
|
|
3
|
+
*
|
|
4
|
+
* Pure `(init, fold)` over the `VercelInput | VercelOutput` union. Folds
|
|
5
|
+
* input variants (user-message, tool-result, tool-result-error,
|
|
6
|
+
* tool-approval-response) and `UIMessageChunk` outputs into a
|
|
7
|
+
* VercelProjection holding `UIMessage[]` plus internal stream-tracker
|
|
8
|
+
* state.
|
|
9
|
+
*
|
|
10
|
+
* The reducer is stateless: every fold is `(state, event, meta) → state'`,
|
|
11
|
+
* with no instance state. Mutation in place is allowed — the projection
|
|
12
|
+
* is single-owner.
|
|
13
|
+
*
|
|
14
|
+
* Idempotency is **per conflict key**, not stream-wide: when two events
|
|
15
|
+
* compete for the same logical state (e.g. two `tool-output-available`
|
|
16
|
+
* for the same `toolCallId`), the higher-serial one wins and the other
|
|
17
|
+
* is dropped. Unrelated events arrive freely in any order. See
|
|
18
|
+
* `_conflictKeyOf` for the per-variant key derivation.
|
|
19
|
+
*
|
|
20
|
+
* Client-published tool resolutions (`ToolResult`, `ToolResultError`,
|
|
21
|
+
* `ToolApprovalResponse`) carry `codecMessageId` targeting the assistant
|
|
22
|
+
* they amend; the reducer applies the resolution onto that assistant's
|
|
23
|
+
* `dynamic-tool` part directly. If the assistant has not yet arrived in
|
|
24
|
+
* the projection (out-of-order delivery), the resolution is buffered in
|
|
25
|
+
* `pendingToolResolutions` and re-evaluated on each subsequent fold.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type * as AI from 'ai';
|
|
29
|
+
|
|
30
|
+
import type {
|
|
31
|
+
CodecMessage,
|
|
32
|
+
ReducerMeta,
|
|
33
|
+
ToolApprovalResponse,
|
|
34
|
+
ToolResult,
|
|
35
|
+
ToolResultError,
|
|
36
|
+
} from '../../core/codec/types.js';
|
|
37
|
+
import { stripUndefined } from '../../utils.js';
|
|
38
|
+
import type {
|
|
39
|
+
VercelInput,
|
|
40
|
+
VercelOutput,
|
|
41
|
+
VercelToolApprovalResponsePayload,
|
|
42
|
+
VercelToolResultErrorPayload,
|
|
43
|
+
VercelToolResultPayload,
|
|
44
|
+
} from './events.js';
|
|
45
|
+
import { toolBase, transitionToolPart } from './tool-transitions.js';
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Internal tracker state
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Tracks an in-progress tool part within a UIMessage. Text and reasoning
|
|
53
|
+
* parts don't need this — we write to them directly via partIndex. Tool
|
|
54
|
+
* parts need an extra `inputText` buffer because deltas arrive as raw
|
|
55
|
+
* JSON fragments that must be accumulated before parsing.
|
|
56
|
+
*/
|
|
57
|
+
interface ToolPartTracker {
|
|
58
|
+
/** Index in the message's parts array. */
|
|
59
|
+
partIndex: number;
|
|
60
|
+
/** Accumulated streaming input text (for JSON parsing on completion). */
|
|
61
|
+
inputText: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Per-codecMessageId tracking state for in-progress streams within a UIMessage. */
|
|
65
|
+
interface MessageTrackers {
|
|
66
|
+
/** Text stream id → partIndex. */
|
|
67
|
+
text: Map<string, number>;
|
|
68
|
+
/** Reasoning stream id → partIndex. */
|
|
69
|
+
reasoning: Map<string, number>;
|
|
70
|
+
/** Tool call id → tracker. */
|
|
71
|
+
tools: Map<string, ToolPartTracker>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Projection
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The per-Run state produced by the Vercel codec's reducer.
|
|
80
|
+
*
|
|
81
|
+
* The SDK reads only `messages` (via `Codec.getMessages`). The remaining
|
|
82
|
+
* fields are internal to the reducer; they happen to live on the
|
|
83
|
+
* projection because the projection is the only thing the reducer can
|
|
84
|
+
* carry from fold to fold (it has no instance state).
|
|
85
|
+
*/
|
|
86
|
+
export interface VercelProjection {
|
|
87
|
+
/**
|
|
88
|
+
* UIMessages produced or modified in this Run, in publication order,
|
|
89
|
+
* each paired with its codec-message-id. The reducer correlates strictly
|
|
90
|
+
* on `codecMessageId`; `message.id` is preserved verbatim from the source
|
|
91
|
+
* (the AI SDK stream's `start.messageId` for assistants, the caller's id
|
|
92
|
+
* for user messages) and is never used as an identity key.
|
|
93
|
+
*/
|
|
94
|
+
messages: CodecMessage<AI.UIMessage>[];
|
|
95
|
+
/**
|
|
96
|
+
* Per-conflict-key high-water-marks. Maps a codec-derived conflict key
|
|
97
|
+
* (see `_conflictKeyOf`) to the highest `meta.serial` already folded for
|
|
98
|
+
* that key. Events whose serial is `<=` the stored value are dropped as
|
|
99
|
+
* duplicates of an already-incorporated operation. Events that have no
|
|
100
|
+
* conflict key (additive content, lifecycle markers) are folded
|
|
101
|
+
* unconditionally.
|
|
102
|
+
*/
|
|
103
|
+
conflictSerials: Map<string, string>;
|
|
104
|
+
/** Per-codecMessageId tracker state for streamed parts. Internal — do not access. */
|
|
105
|
+
trackers: Map<string, MessageTrackers>;
|
|
106
|
+
/**
|
|
107
|
+
* Tool-resolution events that arrived before any assistant in this
|
|
108
|
+
* projection had a matching `toolCallId`. Re-evaluated on every
|
|
109
|
+
* subsequent fold so that an out-of-order tool output is folded as
|
|
110
|
+
* soon as the corresponding assistant lands.
|
|
111
|
+
*/
|
|
112
|
+
pendingToolResolutions: PendingToolResolution[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A buffered tool resolution waiting for its assistant message to arrive.
|
|
117
|
+
* The reducer scans pending entries after every successful fold so an
|
|
118
|
+
* out-of-order tool output is promoted as soon as the matching assistant
|
|
119
|
+
* is added to the projection.
|
|
120
|
+
*/
|
|
121
|
+
interface PendingToolResolution {
|
|
122
|
+
/** The codec-message-id of the assistant the resolution targets. */
|
|
123
|
+
targetCodecMessageId: string;
|
|
124
|
+
/** Tool call this resolution targets. */
|
|
125
|
+
toolCallId: string;
|
|
126
|
+
/** Serial of the wire message — used by the conflict-key check on promotion. */
|
|
127
|
+
serial: string;
|
|
128
|
+
/** Variant of the tool-resolution used to transition the assistant's tool part. */
|
|
129
|
+
resolution:
|
|
130
|
+
| { kind: 'tool-result'; output: unknown }
|
|
131
|
+
| { kind: 'tool-result-error'; message: string }
|
|
132
|
+
| { kind: 'tool-approval-response'; approved: boolean; reason?: string };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// init
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build an empty initial projection.
|
|
141
|
+
* @returns A fresh VercelProjection with no messages and no tracker state.
|
|
142
|
+
*/
|
|
143
|
+
export const init = (): VercelProjection => ({
|
|
144
|
+
messages: [],
|
|
145
|
+
conflictSerials: new Map(),
|
|
146
|
+
trackers: new Map(),
|
|
147
|
+
pendingToolResolutions: [],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// fold
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Fold one input or output event into the projection. Mutates and returns
|
|
156
|
+
* `state`.
|
|
157
|
+
*
|
|
158
|
+
* Idempotency is per conflict key (see `_conflictKeyOf`): if the event has
|
|
159
|
+
* a conflict key and the projection has already folded an event for that
|
|
160
|
+
* key at a higher-or-equal serial, this call is a no-op. Events without a
|
|
161
|
+
* conflict key (additive content, lifecycle markers) are folded
|
|
162
|
+
* unconditionally. Orphan events (e.g. tool-output for an unknown
|
|
163
|
+
* toolCallId) are dropped silently inside the per-variant fold helpers.
|
|
164
|
+
* @param state - Projection to fold into (may be mutated in place).
|
|
165
|
+
* @param event - Input or output event to fold.
|
|
166
|
+
* @param meta - Transport-derived metadata (serial, optional messageId).
|
|
167
|
+
* @returns The same projection reference, possibly mutated.
|
|
168
|
+
*/
|
|
169
|
+
export const fold = (
|
|
170
|
+
state: VercelProjection,
|
|
171
|
+
event: VercelInput | VercelOutput,
|
|
172
|
+
meta: ReducerMeta,
|
|
173
|
+
): VercelProjection => {
|
|
174
|
+
if (meta.serial) {
|
|
175
|
+
const key = _conflictKeyOf(event, meta);
|
|
176
|
+
if (key !== undefined) {
|
|
177
|
+
const seen = state.conflictSerials.get(key);
|
|
178
|
+
if (seen !== undefined && meta.serial <= seen) {
|
|
179
|
+
return state;
|
|
180
|
+
}
|
|
181
|
+
state.conflictSerials.set(key, meta.serial);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (_isInput(event)) {
|
|
186
|
+
switch (event.kind) {
|
|
187
|
+
case 'user-message': {
|
|
188
|
+
_foldUserMessage(state, event.message, meta);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case 'regenerate': {
|
|
192
|
+
// Regenerate input — wire-only signal. Carries no projection state;
|
|
193
|
+
// the agent reads `target` / `parent` from the wire headers via
|
|
194
|
+
// the input-event lookup path. No fold work to do here.
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case 'tool-result': {
|
|
198
|
+
_foldClientToolResult(state, event, meta);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case 'tool-result-error': {
|
|
202
|
+
_foldClientToolResultError(state, event, meta);
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case 'tool-approval-response': {
|
|
206
|
+
_foldToolApprovalResponse(state, event, meta);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
_foldChunk(state, event, meta);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Re-evaluate pending tool resolutions in case the just-folded event
|
|
215
|
+
// produced the assistant they were waiting on. Cheap when the list is
|
|
216
|
+
// empty (the common case).
|
|
217
|
+
if (state.pendingToolResolutions.length > 0) {
|
|
218
|
+
_retryPendingResolutions(state);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return state;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Narrow the union to TInput vs TOutput by the discriminator field name.
|
|
226
|
+
* VercelInput variants carry `kind`; VercelOutput variants carry `type`.
|
|
227
|
+
* @param event - The event to narrow.
|
|
228
|
+
* @returns True when the event is a VercelInput, false for VercelOutput.
|
|
229
|
+
*/
|
|
230
|
+
const _isInput = (event: VercelInput | VercelOutput): event is VercelInput => 'kind' in event;
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Conflict-key derivation
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Derive a per-event conflict key, or `undefined` if the event doesn't
|
|
238
|
+
* compete with any other event for shared state. Used by `fold` to scope
|
|
239
|
+
* the high-water-mark check to genuine conflicts (e.g. two
|
|
240
|
+
* `tool-output-available` for the same `toolCallId`) rather than to every
|
|
241
|
+
* event in the stream.
|
|
242
|
+
* @param event - The event being folded.
|
|
243
|
+
* @param meta - Transport-derived metadata (used for events keyed by codec-message-id).
|
|
244
|
+
* @returns The conflict key, or `undefined` if the event is additive / independent.
|
|
245
|
+
*/
|
|
246
|
+
const _conflictKeyOf = (event: VercelInput | VercelOutput, meta: ReducerMeta): string | undefined => {
|
|
247
|
+
if (_isInput(event)) {
|
|
248
|
+
switch (event.kind) {
|
|
249
|
+
case 'user-message': {
|
|
250
|
+
// Dedup re-publishes of the same user message by its wire
|
|
251
|
+
// codec-message-id, never by the domain `message.id`. Without a
|
|
252
|
+
// codec-message-id there is nothing to correlate on, so the fold
|
|
253
|
+
// is left unconditional.
|
|
254
|
+
return meta.messageId === undefined ? undefined : `user-msg:${meta.messageId}`;
|
|
255
|
+
}
|
|
256
|
+
case 'tool-approval-response': {
|
|
257
|
+
return `tool-approval:${event.payload.toolCallId}`;
|
|
258
|
+
}
|
|
259
|
+
// Client tool results compete for the same final state of the tool
|
|
260
|
+
// call (against agent-side `tool-output-available`/`tool-output-error`
|
|
261
|
+
// chunks and against `tool-output-denied`/`tool-approval-request`).
|
|
262
|
+
// Highest serial wins. Shares the `tool-output:` namespace with the
|
|
263
|
+
// agent-side chunks below.
|
|
264
|
+
case 'tool-result':
|
|
265
|
+
case 'tool-result-error': {
|
|
266
|
+
return `tool-output:${event.payload.toolCallId}`;
|
|
267
|
+
}
|
|
268
|
+
case 'regenerate': {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
switch (event.type) {
|
|
275
|
+
// Tool-input state machine, keyed by toolCallId.
|
|
276
|
+
case 'tool-input-start':
|
|
277
|
+
case 'tool-input-available':
|
|
278
|
+
case 'tool-input-error': {
|
|
279
|
+
return `${event.type}:${event.toolCallId}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// All "tool-output-ish" output variants compete for the same final
|
|
283
|
+
// state of the tool call. Shares the `tool-output:` namespace with
|
|
284
|
+
// the client-published input variants above.
|
|
285
|
+
case 'tool-output-available':
|
|
286
|
+
case 'tool-output-error':
|
|
287
|
+
case 'tool-output-denied':
|
|
288
|
+
case 'tool-approval-request': {
|
|
289
|
+
return `tool-output:${event.toolCallId}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Per-stream start/end markers: duplicates would create phantom parts
|
|
293
|
+
// or wipe accumulated text. Keyed by (codec-message-id, stream-id).
|
|
294
|
+
case 'text-start':
|
|
295
|
+
case 'text-end':
|
|
296
|
+
case 'reasoning-start':
|
|
297
|
+
case 'reasoning-end': {
|
|
298
|
+
return `${event.type}:${meta.messageId ?? ''}:${event.id}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Message-level markers, keyed by codec-message-id.
|
|
302
|
+
case 'finish':
|
|
303
|
+
case 'message-metadata': {
|
|
304
|
+
return `${event.type}:${meta.messageId ?? ''}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Purely additive or independent — never dedup:
|
|
308
|
+
// text-delta / reasoning-delta / tool-input-delta (additive content)
|
|
309
|
+
// start / start-step / finish-step / abort / error (lifecycle)
|
|
310
|
+
// file / source-url / source-document (independent attachments)
|
|
311
|
+
// data-* (opaque to the reducer)
|
|
312
|
+
default: {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Input folds
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
const _foldUserMessage = (state: VercelProjection, message: AI.UIMessage, meta: ReducerMeta): VercelProjection => {
|
|
323
|
+
// Correlate the projection entry on the wire codec-message-id; the
|
|
324
|
+
// caller-supplied `message.id` is preserved verbatim and surfaced to the
|
|
325
|
+
// application unchanged. Without a codec-message-id the message has no
|
|
326
|
+
// identity to key on, so it is appended as a fresh entry.
|
|
327
|
+
const codecMessageId = meta.messageId;
|
|
328
|
+
if (codecMessageId === undefined) {
|
|
329
|
+
state.messages.push({ codecMessageId: message.id, message });
|
|
330
|
+
return state;
|
|
331
|
+
}
|
|
332
|
+
const existingIdx = state.messages.findIndex((e) => e.codecMessageId === codecMessageId);
|
|
333
|
+
if (existingIdx === -1) {
|
|
334
|
+
state.messages.push({ codecMessageId, message });
|
|
335
|
+
} else {
|
|
336
|
+
state.messages[existingIdx] = { codecMessageId, message };
|
|
337
|
+
}
|
|
338
|
+
return state;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Fold a client-published `ToolResult`. The input carries
|
|
343
|
+
* `codecMessageId` pointing at the assistant whose `dynamic-tool` part
|
|
344
|
+
* holds the matching `toolCallId`. If the assistant and its matching
|
|
345
|
+
* `dynamic-tool` part are both present, fold directly; otherwise pend
|
|
346
|
+
* until that tool part arrives.
|
|
347
|
+
* @param state - Projection to fold into.
|
|
348
|
+
* @param event - The tool-result input (codecMessageId + domain payload).
|
|
349
|
+
* @param meta - Transport-derived metadata.
|
|
350
|
+
* @returns The same projection reference.
|
|
351
|
+
*/
|
|
352
|
+
const _foldClientToolResult = (
|
|
353
|
+
state: VercelProjection,
|
|
354
|
+
event: ToolResult<VercelToolResultPayload>,
|
|
355
|
+
meta: ReducerMeta,
|
|
356
|
+
): VercelProjection => {
|
|
357
|
+
const { toolCallId, output } = event.payload;
|
|
358
|
+
const owner = _findOwner(state, event.codecMessageId, toolCallId);
|
|
359
|
+
if (owner) {
|
|
360
|
+
owner.message.parts[owner.tracker.partIndex] = transitionToolPart(owner.part, {
|
|
361
|
+
type: 'tool-output-available',
|
|
362
|
+
toolCallId,
|
|
363
|
+
output,
|
|
364
|
+
});
|
|
365
|
+
return state;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
state.pendingToolResolutions.push({
|
|
369
|
+
targetCodecMessageId: event.codecMessageId,
|
|
370
|
+
toolCallId,
|
|
371
|
+
serial: meta.serial,
|
|
372
|
+
resolution: { kind: 'tool-result', output },
|
|
373
|
+
});
|
|
374
|
+
return state;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Fold a client-published `ToolResultError`. Mirrors
|
|
379
|
+
* {@link _foldClientToolResult} but with the error transition.
|
|
380
|
+
* @param state - Projection to fold into.
|
|
381
|
+
* @param event - The tool-result-error input (codecMessageId + domain payload).
|
|
382
|
+
* @param meta - Transport-derived metadata.
|
|
383
|
+
* @returns The same projection reference.
|
|
384
|
+
*/
|
|
385
|
+
const _foldClientToolResultError = (
|
|
386
|
+
state: VercelProjection,
|
|
387
|
+
event: ToolResultError<VercelToolResultErrorPayload>,
|
|
388
|
+
meta: ReducerMeta,
|
|
389
|
+
): VercelProjection => {
|
|
390
|
+
const { toolCallId, message } = event.payload;
|
|
391
|
+
const owner = _findOwner(state, event.codecMessageId, toolCallId);
|
|
392
|
+
if (owner) {
|
|
393
|
+
owner.message.parts[owner.tracker.partIndex] = transitionToolPart(owner.part, {
|
|
394
|
+
type: 'tool-output-error',
|
|
395
|
+
toolCallId,
|
|
396
|
+
errorText: message,
|
|
397
|
+
});
|
|
398
|
+
return state;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
state.pendingToolResolutions.push({
|
|
402
|
+
targetCodecMessageId: event.codecMessageId,
|
|
403
|
+
toolCallId,
|
|
404
|
+
serial: meta.serial,
|
|
405
|
+
resolution: { kind: 'tool-result-error', message },
|
|
406
|
+
});
|
|
407
|
+
return state;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Fold a client-published `ToolApprovalResponse`. The input carries
|
|
412
|
+
* `codecMessageId` pointing at the assistant whose `dynamic-tool` part
|
|
413
|
+
* holds the matching `toolCallId`. Approval → `approval-responded`;
|
|
414
|
+
* denial → `output-denied` via {@link transitionToolPart}.
|
|
415
|
+
* @param state - Projection to fold into.
|
|
416
|
+
* @param event - The approval-response input.
|
|
417
|
+
* @param meta - Transport-derived metadata.
|
|
418
|
+
* @returns The same projection reference.
|
|
419
|
+
*/
|
|
420
|
+
const _foldToolApprovalResponse = (
|
|
421
|
+
state: VercelProjection,
|
|
422
|
+
event: ToolApprovalResponse<VercelToolApprovalResponsePayload>,
|
|
423
|
+
meta: ReducerMeta,
|
|
424
|
+
): VercelProjection => {
|
|
425
|
+
const { toolCallId, approved, reason } = event.payload;
|
|
426
|
+
const owner = _findOwner(state, event.codecMessageId, toolCallId);
|
|
427
|
+
if (owner) {
|
|
428
|
+
owner.message.parts[owner.tracker.partIndex] = _approvalTransition(owner.part, approved, reason);
|
|
429
|
+
return state;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
state.pendingToolResolutions.push({
|
|
433
|
+
targetCodecMessageId: event.codecMessageId,
|
|
434
|
+
toolCallId,
|
|
435
|
+
serial: meta.serial,
|
|
436
|
+
resolution: {
|
|
437
|
+
kind: 'tool-approval-response',
|
|
438
|
+
approved,
|
|
439
|
+
...(reason === undefined ? {} : { reason }),
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
return state;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
interface OwnerLookup {
|
|
446
|
+
message: AI.UIMessage;
|
|
447
|
+
tracker: ToolPartTracker;
|
|
448
|
+
part: AI.DynamicToolUIPart;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const _findOwner = (state: VercelProjection, codecMessageId: string, toolCallId: string): OwnerLookup | undefined => {
|
|
452
|
+
const entry = state.messages.find((e) => e.codecMessageId === codecMessageId);
|
|
453
|
+
if (!entry) return undefined;
|
|
454
|
+
const trackers = _ensureTrackers(state, codecMessageId);
|
|
455
|
+
const found = _getToolPart(entry.message, trackers, toolCallId);
|
|
456
|
+
if (!found) return undefined;
|
|
457
|
+
return { message: entry.message, tracker: found.tracker, part: found.part };
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Locate the `dynamic-tool` part for a `toolCallId` anywhere in the projection.
|
|
462
|
+
* Agent-emitted second-pass tool outputs (after an approved tool runs) are
|
|
463
|
+
* stamped with a fresh codec-message-id that differs from the assistant holding
|
|
464
|
+
* the tool call, so they can't be found via `meta.messageId` — they fold onto
|
|
465
|
+
* whichever message holds the matching tool call (created in the first pass or
|
|
466
|
+
* by an approval response).
|
|
467
|
+
* @param state - Projection to scan.
|
|
468
|
+
* @param toolCallId - The tool call to locate.
|
|
469
|
+
* @returns The owning message, tracker, and part, or `undefined` if absent.
|
|
470
|
+
*/
|
|
471
|
+
const _findToolPartOwner = (state: VercelProjection, toolCallId: string): OwnerLookup | undefined => {
|
|
472
|
+
for (const entry of state.messages) {
|
|
473
|
+
const trackers = state.trackers.get(entry.codecMessageId);
|
|
474
|
+
if (!trackers) continue;
|
|
475
|
+
const found = _getToolPart(entry.message, trackers, toolCallId);
|
|
476
|
+
if (found) return { message: entry.message, tracker: found.tracker, part: found.part };
|
|
477
|
+
}
|
|
478
|
+
return undefined;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Build the next `dynamic-tool` part shape for an approval response.
|
|
483
|
+
*
|
|
484
|
+
* For `approved=true`, transition to `approval-responded` so the AI SDK's
|
|
485
|
+
* multi-step loop will auto-run the tool on the next step.
|
|
486
|
+
* `transitionToolPart` has no shape for this transition, so we synthesize
|
|
487
|
+
* the part directly.
|
|
488
|
+
*
|
|
489
|
+
* For `approved=false`, delegate to `transitionToolPart` with a synthetic
|
|
490
|
+
* `tool-output-denied` chunk so denial mirrors the chunk-driven path.
|
|
491
|
+
* @param part - The existing `dynamic-tool` part being transitioned.
|
|
492
|
+
* @param approved - Whether the user approved the tool execution.
|
|
493
|
+
* @param reason - Optional human-readable reason.
|
|
494
|
+
* @returns The replacement `dynamic-tool` part.
|
|
495
|
+
*/
|
|
496
|
+
const _approvalTransition = (
|
|
497
|
+
part: AI.DynamicToolUIPart,
|
|
498
|
+
approved: boolean,
|
|
499
|
+
reason: string | undefined,
|
|
500
|
+
): AI.DynamicToolUIPart => {
|
|
501
|
+
if (approved) {
|
|
502
|
+
return {
|
|
503
|
+
...toolBase(part),
|
|
504
|
+
state: 'approval-responded',
|
|
505
|
+
input: 'input' in part ? part.input : undefined,
|
|
506
|
+
approval: {
|
|
507
|
+
id: 'approval' in part && part.approval ? part.approval.id : '',
|
|
508
|
+
approved: true,
|
|
509
|
+
...(reason === undefined ? {} : { reason }),
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return transitionToolPart(part, {
|
|
514
|
+
type: 'tool-output-denied',
|
|
515
|
+
toolCallId: part.toolCallId,
|
|
516
|
+
...(reason === undefined ? {} : { reason }),
|
|
517
|
+
});
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Re-attempt every pending tool resolution against the current projection.
|
|
522
|
+
* Successfully promoted entries are removed from the pending list. Cheap:
|
|
523
|
+
* bounded by the number of pending entries.
|
|
524
|
+
* @param state - Projection to walk and mutate.
|
|
525
|
+
*/
|
|
526
|
+
const _retryPendingResolutions = (state: VercelProjection): void => {
|
|
527
|
+
const next: PendingToolResolution[] = [];
|
|
528
|
+
for (const pending of state.pendingToolResolutions) {
|
|
529
|
+
const owner = _findOwner(state, pending.targetCodecMessageId, pending.toolCallId);
|
|
530
|
+
if (!owner) {
|
|
531
|
+
next.push(pending);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
switch (pending.resolution.kind) {
|
|
535
|
+
case 'tool-result': {
|
|
536
|
+
owner.message.parts[owner.tracker.partIndex] = transitionToolPart(owner.part, {
|
|
537
|
+
type: 'tool-output-available',
|
|
538
|
+
toolCallId: pending.toolCallId,
|
|
539
|
+
output: pending.resolution.output,
|
|
540
|
+
});
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
case 'tool-result-error': {
|
|
544
|
+
owner.message.parts[owner.tracker.partIndex] = transitionToolPart(owner.part, {
|
|
545
|
+
type: 'tool-output-error',
|
|
546
|
+
toolCallId: pending.toolCallId,
|
|
547
|
+
errorText: pending.resolution.message,
|
|
548
|
+
});
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
case 'tool-approval-response': {
|
|
552
|
+
owner.message.parts[owner.tracker.partIndex] = _approvalTransition(
|
|
553
|
+
owner.part,
|
|
554
|
+
pending.resolution.approved,
|
|
555
|
+
pending.resolution.reason,
|
|
556
|
+
);
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
state.pendingToolResolutions = next;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
// UIMessageChunk fold
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
|
|
568
|
+
const _foldChunk = (state: VercelProjection, chunk: VercelOutput, meta: ReducerMeta): VercelProjection => {
|
|
569
|
+
const messageId = meta.messageId;
|
|
570
|
+
if (messageId === undefined) {
|
|
571
|
+
// Without a target codec-message-id, a chunk has nowhere to land. Drop.
|
|
572
|
+
return state;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
switch (chunk.type) {
|
|
576
|
+
case 'start':
|
|
577
|
+
case 'start-step':
|
|
578
|
+
case 'finish-step':
|
|
579
|
+
case 'finish':
|
|
580
|
+
case 'abort':
|
|
581
|
+
case 'error':
|
|
582
|
+
case 'message-metadata': {
|
|
583
|
+
return _foldLifecycle(state, chunk, messageId);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
case 'text-start':
|
|
587
|
+
case 'text-delta':
|
|
588
|
+
case 'text-end':
|
|
589
|
+
case 'reasoning-start':
|
|
590
|
+
case 'reasoning-delta':
|
|
591
|
+
case 'reasoning-end': {
|
|
592
|
+
return _foldTextOrReasoning(state, chunk, messageId);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
case 'tool-input-start':
|
|
596
|
+
case 'tool-input-delta':
|
|
597
|
+
case 'tool-input-available':
|
|
598
|
+
case 'tool-input-error': {
|
|
599
|
+
return _foldToolInput(state, chunk, messageId);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
case 'tool-output-available':
|
|
603
|
+
case 'tool-output-error':
|
|
604
|
+
case 'tool-output-denied':
|
|
605
|
+
case 'tool-approval-request': {
|
|
606
|
+
return _foldToolOutput(state, chunk, messageId);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
case 'file':
|
|
610
|
+
case 'source-url':
|
|
611
|
+
case 'source-document': {
|
|
612
|
+
return _foldContentPart(state, chunk, messageId);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
default: {
|
|
616
|
+
if (chunk.type.startsWith('data-')) {
|
|
617
|
+
return _foldDataPart(state, chunk, messageId);
|
|
618
|
+
}
|
|
619
|
+
return state;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
// Message + tracker helpers
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
const _ensureMessage = (state: VercelProjection, codecMessageId: string): AI.UIMessage => {
|
|
629
|
+
let entry = state.messages.find((e) => e.codecMessageId === codecMessageId);
|
|
630
|
+
if (!entry) {
|
|
631
|
+
// No source id seen yet — seed the domain `message.id` with the
|
|
632
|
+
// codec-message-id as a fallback. The `start` chunk overwrites it with
|
|
633
|
+
// the stream's `messageId` when the stream provides one.
|
|
634
|
+
entry = { codecMessageId, message: { id: codecMessageId, role: 'assistant', parts: [] } };
|
|
635
|
+
state.messages.push(entry);
|
|
636
|
+
}
|
|
637
|
+
return entry.message;
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const _ensureTrackers = (state: VercelProjection, messageId: string): MessageTrackers => {
|
|
641
|
+
let trackers = state.trackers.get(messageId);
|
|
642
|
+
if (!trackers) {
|
|
643
|
+
trackers = { text: new Map(), reasoning: new Map(), tools: new Map() };
|
|
644
|
+
state.trackers.set(messageId, trackers);
|
|
645
|
+
}
|
|
646
|
+
return trackers;
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const _getToolPart = (
|
|
650
|
+
message: AI.UIMessage,
|
|
651
|
+
trackers: MessageTrackers,
|
|
652
|
+
toolCallId: string,
|
|
653
|
+
): { tracker: ToolPartTracker; part: AI.DynamicToolUIPart } | undefined => {
|
|
654
|
+
const tracker = trackers.tools.get(toolCallId);
|
|
655
|
+
if (!tracker) return undefined;
|
|
656
|
+
const part = message.parts[tracker.partIndex];
|
|
657
|
+
if (part?.type !== 'dynamic-tool') return undefined;
|
|
658
|
+
return { tracker, part };
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
// Lifecycle events
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
const _foldLifecycle = (
|
|
666
|
+
state: VercelProjection,
|
|
667
|
+
chunk: Extract<
|
|
668
|
+
AI.UIMessageChunk,
|
|
669
|
+
{ type: 'start' | 'start-step' | 'finish-step' | 'finish' | 'abort' | 'error' | 'message-metadata' }
|
|
670
|
+
>,
|
|
671
|
+
messageId: string,
|
|
672
|
+
): VercelProjection => {
|
|
673
|
+
switch (chunk.type) {
|
|
674
|
+
case 'start': {
|
|
675
|
+
// The projection entry is keyed on the wire codec-message-id
|
|
676
|
+
// (`messageId`); every subsequent chunk for this message correlates on
|
|
677
|
+
// that, independent of `message.id`. So we faithfully reproduce the
|
|
678
|
+
// stream's own `messageId` on the reconstructed `UIMessage.id` (the
|
|
679
|
+
// value surfaced to the application) without risk of orphaning later
|
|
680
|
+
// chunks. When the stream omits it, the codec-message-id seeded by
|
|
681
|
+
// `_ensureMessage` stands as the fallback id.
|
|
682
|
+
const message = _ensureMessage(state, messageId);
|
|
683
|
+
if (chunk.messageId !== undefined) message.id = chunk.messageId;
|
|
684
|
+
if (chunk.messageMetadata !== undefined) message.metadata = chunk.messageMetadata;
|
|
685
|
+
return state;
|
|
686
|
+
}
|
|
687
|
+
case 'start-step': {
|
|
688
|
+
const message = _ensureMessage(state, messageId);
|
|
689
|
+
message.parts.push({ type: 'step-start' });
|
|
690
|
+
return state;
|
|
691
|
+
}
|
|
692
|
+
case 'finish-step': {
|
|
693
|
+
// Reset text/reasoning stream trackers so a follow-up step can start
|
|
694
|
+
// new parts with potentially-reused stream ids.
|
|
695
|
+
const trackers = state.trackers.get(messageId);
|
|
696
|
+
if (trackers) {
|
|
697
|
+
trackers.text.clear();
|
|
698
|
+
trackers.reasoning.clear();
|
|
699
|
+
}
|
|
700
|
+
return state;
|
|
701
|
+
}
|
|
702
|
+
case 'finish': {
|
|
703
|
+
const message = state.messages.find((e) => e.codecMessageId === messageId)?.message;
|
|
704
|
+
if (message && chunk.messageMetadata !== undefined) {
|
|
705
|
+
message.metadata = chunk.messageMetadata;
|
|
706
|
+
}
|
|
707
|
+
// Tracker state retained — late events still resolvable; cleanup happens at Run end.
|
|
708
|
+
return state;
|
|
709
|
+
}
|
|
710
|
+
case 'abort':
|
|
711
|
+
case 'error': {
|
|
712
|
+
// No state mutation — run termination is observed via the wire run-end
|
|
713
|
+
// event, not the projection.
|
|
714
|
+
return state;
|
|
715
|
+
}
|
|
716
|
+
case 'message-metadata': {
|
|
717
|
+
const message = state.messages.find((e) => e.codecMessageId === messageId)?.message;
|
|
718
|
+
if (message && chunk.messageMetadata !== undefined) {
|
|
719
|
+
message.metadata = chunk.messageMetadata;
|
|
720
|
+
}
|
|
721
|
+
return state;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
// Text and reasoning streaming
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
const _foldTextOrReasoning = (
|
|
731
|
+
state: VercelProjection,
|
|
732
|
+
chunk: Extract<
|
|
733
|
+
AI.UIMessageChunk,
|
|
734
|
+
{ type: 'text-start' | 'text-delta' | 'text-end' | 'reasoning-start' | 'reasoning-delta' | 'reasoning-end' }
|
|
735
|
+
>,
|
|
736
|
+
messageId: string,
|
|
737
|
+
): VercelProjection => {
|
|
738
|
+
const message = _ensureMessage(state, messageId);
|
|
739
|
+
const trackers = _ensureTrackers(state, messageId);
|
|
740
|
+
|
|
741
|
+
const isText = chunk.type.startsWith('text-');
|
|
742
|
+
const partType = isText ? 'text' : 'reasoning';
|
|
743
|
+
const activeMap = isText ? trackers.text : trackers.reasoning;
|
|
744
|
+
|
|
745
|
+
switch (chunk.type) {
|
|
746
|
+
case 'text-start':
|
|
747
|
+
case 'reasoning-start': {
|
|
748
|
+
activeMap.set(chunk.id, message.parts.length);
|
|
749
|
+
message.parts.push({ type: partType, text: '' });
|
|
750
|
+
return state;
|
|
751
|
+
}
|
|
752
|
+
case 'text-delta':
|
|
753
|
+
case 'reasoning-delta': {
|
|
754
|
+
const idx = activeMap.get(chunk.id);
|
|
755
|
+
if (idx === undefined) return state;
|
|
756
|
+
const part = message.parts[idx];
|
|
757
|
+
if (part?.type === partType) {
|
|
758
|
+
part.text += chunk.delta;
|
|
759
|
+
}
|
|
760
|
+
return state;
|
|
761
|
+
}
|
|
762
|
+
case 'text-end':
|
|
763
|
+
case 'reasoning-end': {
|
|
764
|
+
activeMap.delete(chunk.id);
|
|
765
|
+
return state;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
// Tool input streaming
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
|
|
774
|
+
const _foldToolInput = (
|
|
775
|
+
state: VercelProjection,
|
|
776
|
+
chunk: Extract<
|
|
777
|
+
AI.UIMessageChunk,
|
|
778
|
+
{ type: 'tool-input-start' | 'tool-input-delta' | 'tool-input-available' | 'tool-input-error' }
|
|
779
|
+
>,
|
|
780
|
+
messageId: string,
|
|
781
|
+
): VercelProjection => {
|
|
782
|
+
const message = _ensureMessage(state, messageId);
|
|
783
|
+
const trackers = _ensureTrackers(state, messageId);
|
|
784
|
+
|
|
785
|
+
switch (chunk.type) {
|
|
786
|
+
case 'tool-input-start': {
|
|
787
|
+
const partIndex = message.parts.length;
|
|
788
|
+
message.parts.push({ ...toolBase(chunk), state: 'input-streaming', input: undefined });
|
|
789
|
+
trackers.tools.set(chunk.toolCallId, { partIndex, inputText: '' });
|
|
790
|
+
return state;
|
|
791
|
+
}
|
|
792
|
+
case 'tool-input-delta': {
|
|
793
|
+
const tracker = trackers.tools.get(chunk.toolCallId);
|
|
794
|
+
if (!tracker) return state;
|
|
795
|
+
tracker.inputText += chunk.inputTextDelta;
|
|
796
|
+
|
|
797
|
+
let parsedInput: unknown;
|
|
798
|
+
try {
|
|
799
|
+
// CAST: JSON.parse returns any; unknown is the safe trust-boundary type.
|
|
800
|
+
parsedInput = JSON.parse(tracker.inputText) as unknown;
|
|
801
|
+
} catch {
|
|
802
|
+
parsedInput = undefined;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const found = _getToolPart(message, trackers, chunk.toolCallId);
|
|
806
|
+
if (!found) return state;
|
|
807
|
+
message.parts[found.tracker.partIndex] = {
|
|
808
|
+
...toolBase(found.part),
|
|
809
|
+
state: 'input-streaming',
|
|
810
|
+
input: parsedInput,
|
|
811
|
+
};
|
|
812
|
+
return state;
|
|
813
|
+
}
|
|
814
|
+
case 'tool-input-available': {
|
|
815
|
+
const found = _getToolPart(message, trackers, chunk.toolCallId);
|
|
816
|
+
if (!found) return state;
|
|
817
|
+
message.parts[found.tracker.partIndex] = {
|
|
818
|
+
...toolBase(found.part),
|
|
819
|
+
state: 'input-available',
|
|
820
|
+
input: chunk.input,
|
|
821
|
+
};
|
|
822
|
+
return state;
|
|
823
|
+
}
|
|
824
|
+
case 'tool-input-error': {
|
|
825
|
+
const found = _getToolPart(message, trackers, chunk.toolCallId);
|
|
826
|
+
if (found) {
|
|
827
|
+
message.parts[found.tracker.partIndex] = {
|
|
828
|
+
...toolBase(found.part),
|
|
829
|
+
state: 'output-error',
|
|
830
|
+
input: chunk.input,
|
|
831
|
+
errorText: chunk.errorText,
|
|
832
|
+
};
|
|
833
|
+
} else {
|
|
834
|
+
const partIndex = message.parts.length;
|
|
835
|
+
message.parts.push({
|
|
836
|
+
...toolBase(chunk),
|
|
837
|
+
state: 'output-error',
|
|
838
|
+
input: chunk.input,
|
|
839
|
+
errorText: chunk.errorText,
|
|
840
|
+
});
|
|
841
|
+
trackers.tools.set(chunk.toolCallId, { partIndex, inputText: '' });
|
|
842
|
+
}
|
|
843
|
+
return state;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
// ---------------------------------------------------------------------------
|
|
849
|
+
// Tool output transitions (agent-published chunks)
|
|
850
|
+
// ---------------------------------------------------------------------------
|
|
851
|
+
|
|
852
|
+
const _foldToolOutput = (
|
|
853
|
+
state: VercelProjection,
|
|
854
|
+
chunk: Extract<
|
|
855
|
+
AI.UIMessageChunk,
|
|
856
|
+
{ type: 'tool-output-available' | 'tool-output-error' | 'tool-output-denied' | 'tool-approval-request' }
|
|
857
|
+
>,
|
|
858
|
+
messageId: string,
|
|
859
|
+
): VercelProjection => {
|
|
860
|
+
// `tool-output-available` / `tool-output-error` after an approved tool runs
|
|
861
|
+
// are emitted by streamText's continuation pass under a fresh
|
|
862
|
+
// codec-message-id that differs from the assistant holding the tool call.
|
|
863
|
+
// Resolve the owning part by toolCallId across the whole projection so the
|
|
864
|
+
// output folds onto the original message. Deliberately do NOT materialise
|
|
865
|
+
// `messageId` first — that would leave a phantom empty message behind the
|
|
866
|
+
// fresh id. Drop on miss: a tool output with no matching tool call has no
|
|
867
|
+
// anchor to attach to.
|
|
868
|
+
if (chunk.type === 'tool-output-available' || chunk.type === 'tool-output-error') {
|
|
869
|
+
const owner = _findToolPartOwner(state, chunk.toolCallId);
|
|
870
|
+
if (!owner) return state;
|
|
871
|
+
owner.message.parts[owner.tracker.partIndex] = transitionToolPart(owner.part, chunk);
|
|
872
|
+
return state;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// `tool-approval-request` (first pass) creates the part on the run's own
|
|
876
|
+
// message; `tool-output-denied` transitions that same part. Both key on the
|
|
877
|
+
// stamped messageId.
|
|
878
|
+
const message = _ensureMessage(state, messageId);
|
|
879
|
+
const trackers = _ensureTrackers(state, messageId);
|
|
880
|
+
|
|
881
|
+
const found = _getToolPart(message, trackers, chunk.toolCallId);
|
|
882
|
+
if (!found) return state;
|
|
883
|
+
|
|
884
|
+
message.parts[found.tracker.partIndex] = transitionToolPart(found.part, chunk);
|
|
885
|
+
return state;
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
// ---------------------------------------------------------------------------
|
|
889
|
+
// File / source content parts
|
|
890
|
+
// ---------------------------------------------------------------------------
|
|
891
|
+
|
|
892
|
+
const _foldContentPart = (
|
|
893
|
+
state: VercelProjection,
|
|
894
|
+
chunk: Extract<AI.UIMessageChunk, { type: 'file' | 'source-url' | 'source-document' }>,
|
|
895
|
+
messageId: string,
|
|
896
|
+
): VercelProjection => {
|
|
897
|
+
const message = _ensureMessage(state, messageId);
|
|
898
|
+
|
|
899
|
+
switch (chunk.type) {
|
|
900
|
+
case 'file': {
|
|
901
|
+
message.parts.push({ type: 'file', mediaType: chunk.mediaType, url: chunk.url });
|
|
902
|
+
return state;
|
|
903
|
+
}
|
|
904
|
+
case 'source-url': {
|
|
905
|
+
message.parts.push(
|
|
906
|
+
stripUndefined({
|
|
907
|
+
type: 'source-url' as const,
|
|
908
|
+
sourceId: chunk.sourceId,
|
|
909
|
+
url: chunk.url,
|
|
910
|
+
title: chunk.title,
|
|
911
|
+
}),
|
|
912
|
+
);
|
|
913
|
+
return state;
|
|
914
|
+
}
|
|
915
|
+
case 'source-document': {
|
|
916
|
+
message.parts.push(
|
|
917
|
+
stripUndefined({
|
|
918
|
+
type: 'source-document' as const,
|
|
919
|
+
sourceId: chunk.sourceId,
|
|
920
|
+
mediaType: chunk.mediaType,
|
|
921
|
+
title: chunk.title,
|
|
922
|
+
filename: chunk.filename,
|
|
923
|
+
}),
|
|
924
|
+
);
|
|
925
|
+
return state;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
// ---------------------------------------------------------------------------
|
|
931
|
+
// data-* parts
|
|
932
|
+
// ---------------------------------------------------------------------------
|
|
933
|
+
|
|
934
|
+
const _foldDataPart = (
|
|
935
|
+
state: VercelProjection,
|
|
936
|
+
chunk: Extract<AI.UIMessageChunk, { type: `data-${string}` }>,
|
|
937
|
+
messageId: string,
|
|
938
|
+
): VercelProjection => {
|
|
939
|
+
if (chunk.transient) return state;
|
|
940
|
+
|
|
941
|
+
const message = _ensureMessage(state, messageId);
|
|
942
|
+
|
|
943
|
+
// CAST: chunk.type is `data-${string}` which satisfies DataUIPart, but
|
|
944
|
+
// TypeScript cannot verify the template literal matches a specific
|
|
945
|
+
// UIMessagePart variant at the type level.
|
|
946
|
+
const dataPart = stripUndefined({
|
|
947
|
+
type: chunk.type,
|
|
948
|
+
id: chunk.id,
|
|
949
|
+
data: chunk.data,
|
|
950
|
+
}) as AI.UIMessage['parts'][number];
|
|
951
|
+
|
|
952
|
+
if (chunk.id !== undefined) {
|
|
953
|
+
const idx = message.parts.findIndex((p) => p.type === chunk.type && 'id' in p && p.id === chunk.id);
|
|
954
|
+
if (idx !== -1) {
|
|
955
|
+
message.parts[idx] = dataPart;
|
|
956
|
+
return state;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
message.parts.push(dataPart);
|
|
961
|
+
return state;
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
// ---------------------------------------------------------------------------
|
|
965
|
+
// getMessages
|
|
966
|
+
// ---------------------------------------------------------------------------
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Extract the UIMessage list from a projection, each paired with its
|
|
970
|
+
* codec-message-id. Client-published tool resolutions amend existing
|
|
971
|
+
* assistants in place via `kind: 'tool-result'` etc. — they never
|
|
972
|
+
* materialise as their own UIMessage in the projection, so no filtering is
|
|
973
|
+
* needed here.
|
|
974
|
+
* @param projection - Projection produced by `init` + repeated `fold` calls.
|
|
975
|
+
* @returns The visible messages with their codec-message-ids, in publication order.
|
|
976
|
+
*/
|
|
977
|
+
export const getMessages = (projection: VercelProjection): CodecMessage<AI.UIMessage>[] => projection.messages;
|