@ably/ai-transport 0.0.1 → 0.1.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.
Files changed (110) hide show
  1. package/README.md +54 -47
  2. package/dist/ably-ai-transport.js +1006 -539
  3. package/dist/ably-ai-transport.js.map +1 -1
  4. package/dist/ably-ai-transport.umd.cjs +1 -1
  5. package/dist/ably-ai-transport.umd.cjs.map +1 -1
  6. package/dist/constants.d.ts +4 -0
  7. package/dist/core/codec/types.d.ts +19 -2
  8. package/dist/core/transport/decode-history.d.ts +8 -6
  9. package/dist/core/transport/headers.d.ts +4 -2
  10. package/dist/core/transport/index.d.ts +4 -1
  11. package/dist/core/transport/pipe-stream.d.ts +3 -2
  12. package/dist/core/transport/stream-router.d.ts +11 -1
  13. package/dist/core/transport/tree.d.ts +171 -0
  14. package/dist/core/transport/turn-manager.d.ts +4 -1
  15. package/dist/core/transport/types.d.ts +270 -119
  16. package/dist/core/transport/view.d.ts +166 -0
  17. package/dist/errors.d.ts +19 -2
  18. package/dist/index.d.ts +3 -1
  19. package/dist/react/ably-ai-transport-react.js +1019 -486
  20. package/dist/react/ably-ai-transport-react.js.map +1 -1
  21. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  22. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  23. package/dist/react/contexts/transport-context.d.ts +31 -0
  24. package/dist/react/contexts/transport-provider.d.ts +49 -0
  25. package/dist/react/create-transport-hooks.d.ts +124 -0
  26. package/dist/react/index.d.ts +14 -8
  27. package/dist/react/use-ably-messages.d.ts +14 -8
  28. package/dist/react/use-active-turns.d.ts +7 -3
  29. package/dist/react/use-client-transport.d.ts +78 -5
  30. package/dist/react/use-create-view.d.ts +22 -0
  31. package/dist/react/use-tree.d.ts +20 -0
  32. package/dist/react/use-view.d.ts +79 -0
  33. package/dist/vercel/ably-ai-transport-vercel.js +1478 -842
  34. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  35. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  36. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  37. package/dist/vercel/codec/tool-transitions.d.ts +50 -0
  38. package/dist/vercel/index.d.ts +3 -0
  39. package/dist/vercel/react/ably-ai-transport-vercel-react.js +9099 -852
  40. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  41. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +45 -1
  42. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  43. package/dist/vercel/react/contexts/chat-transport-context.d.ts +32 -0
  44. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +84 -0
  45. package/dist/vercel/react/index.d.ts +5 -0
  46. package/dist/vercel/react/use-chat-transport.d.ts +61 -20
  47. package/dist/vercel/react/use-message-sync.d.ts +41 -9
  48. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +30 -0
  49. package/dist/vercel/tool-approvals.d.ts +124 -0
  50. package/dist/vercel/tool-events.d.ts +26 -0
  51. package/dist/vercel/transport/chat-transport.d.ts +33 -11
  52. package/dist/vercel/transport/index.d.ts +5 -2
  53. package/package.json +23 -17
  54. package/src/constants.ts +6 -0
  55. package/src/core/codec/encoder.ts +10 -1
  56. package/src/core/codec/types.ts +19 -3
  57. package/src/core/transport/client-transport.ts +382 -364
  58. package/src/core/transport/decode-history.ts +229 -81
  59. package/src/core/transport/headers.ts +6 -2
  60. package/src/core/transport/index.ts +13 -5
  61. package/src/core/transport/pipe-stream.ts +8 -5
  62. package/src/core/transport/server-transport.ts +212 -58
  63. package/src/core/transport/stream-router.ts +21 -3
  64. package/src/core/transport/{conversation-tree.ts → tree.ts} +192 -77
  65. package/src/core/transport/turn-manager.ts +28 -10
  66. package/src/core/transport/types.ts +318 -139
  67. package/src/core/transport/view.ts +840 -0
  68. package/src/errors.ts +21 -1
  69. package/src/index.ts +10 -5
  70. package/src/react/contexts/transport-context.ts +37 -0
  71. package/src/react/contexts/transport-provider.tsx +164 -0
  72. package/src/react/create-transport-hooks.ts +144 -0
  73. package/src/react/index.ts +15 -8
  74. package/src/react/use-ably-messages.ts +34 -16
  75. package/src/react/use-active-turns.ts +28 -17
  76. package/src/react/use-client-transport.ts +184 -24
  77. package/src/react/use-create-view.ts +68 -0
  78. package/src/react/use-tree.ts +53 -0
  79. package/src/react/use-view.ts +233 -0
  80. package/src/react/vite.config.ts +4 -1
  81. package/src/vercel/codec/accumulator.ts +64 -79
  82. package/src/vercel/codec/decoder.ts +11 -8
  83. package/src/vercel/codec/encoder.ts +68 -54
  84. package/src/vercel/codec/index.ts +0 -2
  85. package/src/vercel/codec/tool-transitions.ts +122 -0
  86. package/src/vercel/index.ts +17 -0
  87. package/src/vercel/react/contexts/chat-transport-context.ts +40 -0
  88. package/src/vercel/react/contexts/chat-transport-provider.tsx +122 -0
  89. package/src/vercel/react/index.ts +14 -0
  90. package/src/vercel/react/use-chat-transport.ts +164 -42
  91. package/src/vercel/react/use-message-sync.ts +77 -19
  92. package/src/vercel/react/use-staged-add-tool-approval-response.ts +87 -0
  93. package/src/vercel/react/vite.config.ts +4 -2
  94. package/src/vercel/tool-approvals.ts +380 -0
  95. package/src/vercel/tool-events.ts +53 -0
  96. package/src/vercel/transport/chat-transport.ts +225 -79
  97. package/src/vercel/transport/index.ts +14 -3
  98. package/dist/core/transport/conversation-tree.d.ts +0 -9
  99. package/dist/react/use-conversation-tree.d.ts +0 -20
  100. package/dist/react/use-edit.d.ts +0 -7
  101. package/dist/react/use-history.d.ts +0 -19
  102. package/dist/react/use-messages.d.ts +0 -7
  103. package/dist/react/use-regenerate.d.ts +0 -7
  104. package/dist/react/use-send.d.ts +0 -7
  105. package/src/react/use-conversation-tree.ts +0 -71
  106. package/src/react/use-edit.ts +0 -24
  107. package/src/react/use-history.ts +0 -111
  108. package/src/react/use-messages.ts +0 -32
  109. package/src/react/use-regenerate.ts +0 -24
  110. package/src/react/use-send.ts +0 -25
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Server-side helpers for processing a tool-approval turn.
3
+ *
4
+ * When a Vercel AI SDK tool is marked `needsApproval`, `streamText` pauses
5
+ * after emitting a `dynamic-tool` part in state `approval-requested`. To
6
+ * resume, the server must:
7
+ *
8
+ * 1. Patch the UIMessage history so the pending tool part reflects the
9
+ * user's decision (`approval-responded` or `output-denied`).
10
+ * 2. Strip the client-appended "Approved: …" user message, because
11
+ * `streamText`'s multi-step loop only auto-executes pending tool
12
+ * calls when the conversation ends on a tool/assistant message.
13
+ * 3. Disable `needsApproval` on just-approved tools so the multi-step
14
+ * loop doesn't immediately pause again on the same tool.
15
+ * 4. Redirect the resulting `tool-output-available` / `tool-output-error`
16
+ * chunks back to the ORIGINAL assistant message (the one that held
17
+ * the `approval-requested` part) via `x-ably-amend`, instead of
18
+ * letting them land on the new assistant message this turn produces.
19
+ *
20
+ * `prepareApprovalTurn` covers steps 1–3; `streamResponseWithApprovalRedirect`
21
+ * covers step 4.
22
+ */
23
+
24
+ import type * as AI from 'ai';
25
+ import { convertToModelMessages } from 'ai';
26
+
27
+ import { HEADER_AMEND } from '../constants.js';
28
+ import type { MessageNode, StreamResponseOptions, StreamResult, Turn } from '../core/transport/types.js';
29
+ import { stripUndefined } from '../utils.js';
30
+ import { toolBase } from './codec/tool-transitions.js';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Tool-part transition helpers (private — only used by applyToolApprovalsToHistory)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ // Build the `approval-responded` variant of a DynamicToolUIPart. Pure.
37
+ const applyApprovalResponseToPart = (
38
+ part: AI.DynamicToolUIPart,
39
+ approvalId: string,
40
+ approved: boolean,
41
+ reason: string | undefined,
42
+ ): AI.DynamicToolUIPart =>
43
+ stripUndefined({
44
+ ...toolBase(part),
45
+ state: 'approval-responded' as const,
46
+ input: part.input,
47
+ approval: stripUndefined({ id: approvalId, approved, reason }),
48
+ });
49
+
50
+ // Build the `output-denied` variant of a DynamicToolUIPart. Pure.
51
+ const applyApprovalDeniedToPart = (part: AI.DynamicToolUIPart, approvalId: string): AI.DynamicToolUIPart => ({
52
+ ...toolBase(part),
53
+ state: 'output-denied',
54
+ input: part.input,
55
+ approval: { id: approvalId, approved: false as const },
56
+ });
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Wire type
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * A user's decision on a pending tool approval. The client ships an array of
64
+ * these to the server in the POST body; the server feeds them to
65
+ * `prepareApprovalTurn` (to patch history) and
66
+ * `streamResponseWithApprovalRedirect` (to route tool outputs back to the
67
+ * original assistant message).
68
+ *
69
+ * Intentionally does not carry `toolName` or `input` — those are redundant
70
+ * with what's already on the UIMessage history part.
71
+ */
72
+ export interface ToolApprovalDecision {
73
+ /**
74
+ * The `toolCallId` of the pending `dynamic-tool` part being approved/denied.
75
+ * Must match a part already in the history; decisions that don't match any
76
+ * part are ignored by {@link applyToolApprovalsToHistory}.
77
+ */
78
+ toolCallId: string;
79
+ /** Whether the user approved or denied the tool call. */
80
+ approved: boolean;
81
+ /**
82
+ * The `x-ably-msg-id` of the assistant message whose `dynamic-tool` part
83
+ * is being responded to. When approved and the tool executes successfully,
84
+ * the output is published cross-turn targeting this message.
85
+ */
86
+ targetMsgId: string;
87
+ /** Optional reason accompanying the response. */
88
+ reason?: string;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // History patching
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Patch `dynamic-tool` parts in the history to reflect a batch of approval
97
+ * decisions. Pure — returns a new array; input is not mutated.
98
+ *
99
+ * Approved decisions transition the matching part to `approval-responded`,
100
+ * which `convertToModelMessages` will expand into a `tool-approval-response`
101
+ * model message for `streamText`'s multi-step loop. Denied decisions
102
+ * transition to `output-denied`.
103
+ *
104
+ * Messages and parts whose `toolCallId` is not referenced by any decision
105
+ * are passed through by reference.
106
+ * @param messages - The UIMessage history (user + assistant messages).
107
+ * @param decisions - Approval decisions keyed by `toolCallId`.
108
+ * @returns A new array with matching tool parts transitioned.
109
+ */
110
+ export const applyToolApprovalsToHistory = (
111
+ messages: AI.UIMessage[],
112
+ decisions: ToolApprovalDecision[],
113
+ ): AI.UIMessage[] => {
114
+ if (decisions.length === 0) return messages;
115
+ const byToolCallId = new Map(decisions.map((d) => [d.toolCallId, d]));
116
+
117
+ return messages.map((msg) => {
118
+ let patchedParts: AI.UIMessage['parts'] | undefined;
119
+
120
+ for (const [index, part] of msg.parts.entries()) {
121
+ if (part.type !== 'dynamic-tool') continue;
122
+ const decision = byToolCallId.get(part.toolCallId);
123
+ if (!decision) continue;
124
+
125
+ // Preserve an existing approval id if the part already has one
126
+ // (it was set when the approval-request chunk arrived); otherwise mint
127
+ // a new id so the emitted tool-approval-response has a stable handle.
128
+ const approvalId = part.approval?.id ?? crypto.randomUUID();
129
+ const replacement = decision.approved
130
+ ? applyApprovalResponseToPart(part, approvalId, true, decision.reason)
131
+ : applyApprovalDeniedToPart(part, approvalId);
132
+
133
+ patchedParts ??= [...msg.parts];
134
+ patchedParts[index] = replacement;
135
+ }
136
+
137
+ return patchedParts ? { ...msg, parts: patchedParts } : msg;
138
+ });
139
+ };
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Tool manipulation
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /**
146
+ * Derive the set of tool names that have just been approved by walking the
147
+ * (pre-patch) history for `dynamic-tool` parts whose `toolCallId` matches an
148
+ * approved decision.
149
+ * @param messages - The full UIMessage history.
150
+ * @param decisions - Approval decisions for this request.
151
+ * @returns The set of tool names that were just approved.
152
+ */
153
+ const approvedToolNames = (messages: AI.UIMessage[], decisions: ToolApprovalDecision[]): Set<string> => {
154
+ const approvedIds = new Set(decisions.filter((d) => d.approved).map((d) => d.toolCallId));
155
+ if (approvedIds.size === 0) return new Set();
156
+
157
+ const names = new Set<string>();
158
+ for (const msg of messages) {
159
+ for (const part of msg.parts) {
160
+ if (part.type === 'dynamic-tool' && approvedIds.has(part.toolCallId)) {
161
+ names.add(part.toolName);
162
+ }
163
+ }
164
+ }
165
+ return names;
166
+ };
167
+
168
+ /**
169
+ * Return a tool dict with `needsApproval: false` forced on any tool whose
170
+ * name is in `approvedNames`. Prevents an infinite approval loop when
171
+ * `streamText`'s multi-step loop calls an approved tool again after
172
+ * executing it.
173
+ *
174
+ * The generic uses `object` (not `AI.Tool`) for its value constraint so
175
+ * duplicate peer-dep resolutions — common when the SDK and the consuming app
176
+ * each pull their own copy of `ai` — still type-check. Every real Vercel Tool
177
+ * is structurally an object, so the constraint holds in practice.
178
+ * @param tools - The tool dictionary.
179
+ * @param approvedNames - Names of tools whose `needsApproval` should be disabled.
180
+ * @returns A new tool dict with the flag cleared on matching entries; input returned unchanged when the set is empty.
181
+ */
182
+ const disableApprovalFor = <T extends Record<string, object>>(tools: T, approvedNames: ReadonlySet<string>): T => {
183
+ if (approvedNames.size === 0) return tools;
184
+ const entries = Object.entries(tools).map(([name, def]) =>
185
+ approvedNames.has(name) ? ([name, { ...def, needsApproval: false }] as const) : ([name, def] as const),
186
+ );
187
+ // CAST: Object.fromEntries loses the exact T shape in its return type, but
188
+ // we preserve every key and only set an existing optional field, so the T
189
+ // contract holds at runtime.
190
+ return Object.fromEntries(entries) as T;
191
+ };
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Orchestration
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /** Options for {@link prepareApprovalTurn}. */
198
+ export interface PrepareApprovalTurnOptions<T extends Record<string, object>> {
199
+ /** The full UIMessage history (user + assistant messages for this conversation). */
200
+ messages: AI.UIMessage[];
201
+ /** The user's approval decisions for this request, if any. */
202
+ decisions: ToolApprovalDecision[] | undefined;
203
+ /**
204
+ * The tool dictionary that will be passed to `streamText`. Typed with a
205
+ * structural `object` value constraint so it accepts `Record<string, Tool>`
206
+ * regardless of which copy of the `ai` peer dep typed it.
207
+ */
208
+ tools: T;
209
+ }
210
+
211
+ /** Result of {@link prepareApprovalTurn}. */
212
+ export interface PrepareApprovalTurnResult<T extends Record<string, object>> {
213
+ /** Model-format messages ready to pass to `streamText({ messages })`. */
214
+ modelMessages: AI.ModelMessage[];
215
+ /** Tools with `needsApproval` disabled for any tool that was just approved. */
216
+ tools: T;
217
+ }
218
+
219
+ /**
220
+ * One-shot transform to ready a history + tool dict for a `streamText` call
221
+ * on an approval turn. Returns the patched model-message array and the
222
+ * effective tools dict.
223
+ *
224
+ * When `decisions` is absent or empty, this is a thin wrapper around
225
+ * `convertToModelMessages(messages)` that returns the original tools — so
226
+ * callers can use it uniformly regardless of whether the request carries
227
+ * approvals.
228
+ * @param options - See {@link PrepareApprovalTurnOptions}.
229
+ * @returns See {@link PrepareApprovalTurnResult}.
230
+ */
231
+ export const prepareApprovalTurn = async <T extends Record<string, object>>(
232
+ options: PrepareApprovalTurnOptions<T>,
233
+ ): Promise<PrepareApprovalTurnResult<T>> => {
234
+ const { messages, decisions, tools } = options;
235
+
236
+ if (!decisions || decisions.length === 0) {
237
+ return { modelMessages: await convertToModelMessages(messages), tools };
238
+ }
239
+
240
+ const patched = applyToolApprovalsToHistory(messages, decisions);
241
+ const converted = await convertToModelMessages(patched);
242
+
243
+ // Strip the client-appended "Approved: …" / "Denied: …" user message so
244
+ // `streamText`'s multi-step loop auto-executes the pending tool call.
245
+ const modelMessages = converted.at(-1)?.role === 'user' ? converted.slice(0, -1) : converted;
246
+
247
+ const effectiveTools = disableApprovalFor(tools, approvedToolNames(messages, decisions));
248
+
249
+ return { modelMessages, tools: effectiveTools };
250
+ };
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Stream response with cross-turn redirect
254
+ // ---------------------------------------------------------------------------
255
+
256
+ /** Options for {@link streamResponseWithApprovalRedirect}. */
257
+ export interface StreamResponseWithApprovalRedirectOptions extends StreamResponseOptions<AI.UIMessageChunk> {
258
+ /**
259
+ * The approval decisions this turn is resolving. Only approved decisions
260
+ * redirect tool outputs — denied decisions have already been reflected
261
+ * in the history and produce no tool output to capture.
262
+ */
263
+ decisions: ToolApprovalDecision[] | undefined;
264
+ }
265
+
266
+ /**
267
+ * Pipe a UIMessage chunk stream through the turn's encoder, but redirect
268
+ * `tool-output-available` / `tool-output-error` chunks for approved tools to
269
+ * the original assistant message via `x-ably-amend`.
270
+ *
271
+ * Without this redirect, the tool output would land on the new assistant
272
+ * message produced this turn — leaving the original message stuck in
273
+ * `approval-responded` state. The redirect uses a per-event
274
+ * {@link StreamResponseOptions.resolveWriteOptions} hook: when a matching
275
+ * chunk reaches the encoder, it is published with the target's `msgId`
276
+ * and an `x-ably-amend` header so the client merges the output onto the
277
+ * original message instead of the current-turn one.
278
+ *
279
+ * To preserve "no amendments on cancel" semantics — a partial turn must
280
+ * not leave torn-off tool outputs on the original message — redirect-
281
+ * target chunks are held in a small TransformStream buffer and only
282
+ * released to the encoder when the source stream closes normally. If the
283
+ * turn's `abortSignal` fires before the flush, the buffer is discarded.
284
+ * Non-redirect chunks are enqueued inline and are unaffected by the buffer.
285
+ * @param turn - The active server turn.
286
+ * @param stream - The UIMessage chunk stream to pipe through the encoder.
287
+ * @param options - Stream options plus the approval decisions to redirect.
288
+ * @returns The underlying `streamResponse` result.
289
+ */
290
+ // The redirect-eligible subset of UIMessageChunk — narrow enough for the type
291
+ // guard below to tell TypeScript that `event.toolCallId` is defined.
292
+ type RedirectTargetChunk = Extract<AI.UIMessageChunk, { type: 'tool-output-available' | 'tool-output-error' }>;
293
+
294
+ export const streamResponseWithApprovalRedirect = (
295
+ turn: Turn<AI.UIMessageChunk, AI.UIMessage>,
296
+ stream: ReadableStream<AI.UIMessageChunk>,
297
+ options: StreamResponseWithApprovalRedirectOptions,
298
+ // eslint-disable-next-line @typescript-eslint/promise-function-async -- body only returns other promises; an async wrapper would add a pointless microtask hop
299
+ ): Promise<StreamResult> => {
300
+ const { decisions, ...streamOptions } = options;
301
+
302
+ const targets = new Map<string, string>();
303
+ for (const decision of decisions ?? []) {
304
+ if (decision.approved) targets.set(decision.toolCallId, decision.targetMsgId);
305
+ }
306
+
307
+ if (targets.size === 0) return turn.streamResponse(stream, streamOptions);
308
+
309
+ const isRedirectTarget = (event: AI.UIMessageChunk): event is RedirectTargetChunk =>
310
+ (event.type === 'tool-output-available' || event.type === 'tool-output-error') && targets.has(event.toolCallId);
311
+
312
+ const buffer: AI.UIMessageChunk[] = [];
313
+ const guarded = stream.pipeThrough(
314
+ new TransformStream<AI.UIMessageChunk, AI.UIMessageChunk>({
315
+ transform: (chunk, controller) => {
316
+ if (isRedirectTarget(chunk)) {
317
+ buffer.push(chunk);
318
+ return;
319
+ }
320
+ controller.enqueue(chunk);
321
+ },
322
+ flush: (controller) => {
323
+ if (turn.abortSignal.aborted) return;
324
+ for (const chunk of buffer) controller.enqueue(chunk);
325
+ },
326
+ }),
327
+ );
328
+
329
+ return turn.streamResponse(guarded, {
330
+ ...streamOptions,
331
+ resolveWriteOptions: (event) => {
332
+ if (!isRedirectTarget(event)) return;
333
+ const target = targets.get(event.toolCallId);
334
+ if (target === undefined) return;
335
+ return { messageId: target, extras: { headers: { [HEADER_AMEND]: target } } };
336
+ },
337
+ });
338
+ };
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // History-scan helper (useChat-style routes)
342
+ // ---------------------------------------------------------------------------
343
+
344
+ /**
345
+ * Walk the conversation history and synthesize a {@link ToolApprovalDecision}
346
+ * for each `dynamic-tool` part in `approval-responded` (approved) or
347
+ * `output-denied` (denied) state.
348
+ *
349
+ * Use in server routes where the client flips the tool part state directly
350
+ * (via useChat's `addToolApprovalResponse` and our
351
+ * `useStagedAddToolApprovalResponse`) and ships it through the history
352
+ * overlay instead of a separate `toolApprovals` body field.
353
+ * @param history - The conversation history nodes from the POST body.
354
+ * @returns Approval decisions derived from the history, in walk order.
355
+ */
356
+ export const extractApprovalDecisionsFromHistory = (
357
+ history: readonly MessageNode<AI.UIMessage>[],
358
+ ): ToolApprovalDecision[] => {
359
+ const decisions: ToolApprovalDecision[] = [];
360
+ for (const node of history) {
361
+ for (const part of node.message.parts) {
362
+ if (part.type !== 'dynamic-tool') continue;
363
+ if (part.state === 'approval-responded') {
364
+ decisions.push({
365
+ toolCallId: part.toolCallId,
366
+ approved: true,
367
+ targetMsgId: node.msgId,
368
+ ...(part.approval.reason === undefined ? {} : { reason: part.approval.reason }),
369
+ });
370
+ } else if (part.state === 'output-denied') {
371
+ decisions.push({
372
+ toolCallId: part.toolCallId,
373
+ approved: false,
374
+ targetMsgId: node.msgId,
375
+ });
376
+ }
377
+ }
378
+ }
379
+ return decisions;
380
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Server-side helper for folding client-shipped events into an in-memory
3
+ * history array before handing it to `convertToModelMessages` / `streamText`.
4
+ *
5
+ * When a client-executed tool resolves, the client stages the resulting
6
+ * `tool-output-available` / `tool-output-error` chunk via
7
+ * `transport.stageEvents(msgId, [...])`. The next send flushes it into the
8
+ * POST body's `events` field. The server republishes the event on the
9
+ * channel via `turn.addEvents`, and must also merge it into the in-memory
10
+ * history so the LLM sees the tool result this turn.
11
+ */
12
+
13
+ import type * as AI from 'ai';
14
+
15
+ import type { EventsNode, MessageNode } from '../core/transport/types.js';
16
+ import { createAccumulator } from './codec/accumulator.js';
17
+
18
+ /**
19
+ * Fold a batch of client-shipped events into an in-memory history array.
20
+ *
21
+ * Mirrors the optimistic tree update in
22
+ * `DefaultClientTransport._internalSend` (src/core/transport/client-transport.ts)
23
+ * so the server can rebuild the same message state before handing it to
24
+ * `convertToModelMessages` / `streamText`.
25
+ * @param events - The events shipped by the client.
26
+ * @param nodes - The history messages from the POST body.
27
+ * @returns A new array with tool-result events applied to the matching
28
+ * messages. Non-targeted messages are passed through unchanged.
29
+ */
30
+ export const applyToolEventsToHistory = (
31
+ events: EventsNode<AI.UIMessageChunk>[],
32
+ nodes: MessageNode<AI.UIMessage>[],
33
+ ): MessageNode<AI.UIMessage>[] => {
34
+ if (events.length === 0) return nodes;
35
+ const eventsByMsgId = new Map(events.map((e) => [e.msgId, e]));
36
+
37
+ return nodes.map((node) => {
38
+ const evNode = eventsByMsgId.get(node.msgId);
39
+ if (!evNode) return node;
40
+
41
+ const accumulator = createAccumulator();
42
+ accumulator.initMessage(node.msgId, node.message);
43
+ accumulator.processOutputs(
44
+ evNode.events.map((event) => ({
45
+ kind: 'event' as const,
46
+ event,
47
+ messageId: node.msgId,
48
+ })),
49
+ );
50
+ const updated = accumulator.messages.at(-1);
51
+ return updated ? { ...node, message: updated } : node;
52
+ });
53
+ };