@ably/ai-transport 0.1.0 → 0.3.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 +93 -111
- package/dist/ably-ai-transport.js +2401 -1387
- 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 +116 -42
- package/dist/core/agent.d.ts +44 -0
- package/dist/core/channel-options.d.ts +57 -0
- package/dist/core/codec/codec-event.d.ts +9 -0
- package/dist/core/codec/decoder.d.ts +24 -24
- package/dist/core/codec/define-codec.d.ts +100 -0
- package/dist/core/codec/encoder.d.ts +10 -12
- package/dist/core/codec/field-bag.d.ts +85 -0
- package/dist/core/codec/fields.d.ts +141 -0
- package/dist/core/codec/index.d.ts +8 -2
- package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
- package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
- package/dist/core/codec/input-descriptors.d.ts +281 -0
- package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
- package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
- package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
- package/dist/core/codec/output-descriptors.d.ts +237 -0
- package/dist/core/codec/types.d.ts +470 -119
- package/dist/core/codec/well-known-inputs.d.ts +52 -0
- package/dist/core/transport/agent-session.d.ts +10 -0
- package/dist/core/transport/agent-view.d.ts +296 -0
- package/dist/core/transport/client-session.d.ts +13 -0
- package/dist/core/transport/decode-fold.d.ts +55 -0
- package/dist/core/transport/headers.d.ts +121 -14
- package/dist/core/transport/index.d.ts +5 -6
- 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-history-pages.d.ts +71 -0
- package/dist/core/transport/load-history.d.ts +44 -0
- package/dist/core/transport/pipe-stream.d.ts +9 -9
- package/dist/core/transport/run-manager.d.ts +76 -0
- package/dist/core/transport/session-support.d.ts +55 -0
- package/dist/core/transport/tree.d.ts +523 -109
- package/dist/core/transport/types/agent.d.ts +375 -0
- package/dist/core/transport/types/client.d.ts +201 -0
- package/dist/core/transport/types/shared.d.ts +24 -0
- package/dist/core/transport/types/tree.d.ts +357 -0
- package/dist/core/transport/types/view.d.ts +249 -0
- package/dist/core/transport/types.d.ts +13 -553
- package/dist/core/transport/view.d.ts +390 -84
- package/dist/core/transport/wire-log.d.ts +102 -0
- package/dist/errors.d.ts +27 -10
- package/dist/index.d.ts +8 -9
- package/dist/logger.d.ts +12 -0
- package/dist/react/ably-ai-transport-react.js +1365 -1010
- 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 +37 -0
- package/dist/react/contexts/client-session-provider.d.ts +56 -0
- package/dist/react/create-session-hooks.d.ts +116 -0
- package/dist/react/index.d.ts +13 -12
- package/dist/react/internal/skipped-session.d.ts +8 -0
- package/dist/react/internal/use-resolved-session.d.ts +36 -0
- package/dist/react/use-ably-messages.d.ts +17 -14
- package/dist/react/use-client-session.d.ts +81 -0
- package/dist/react/use-create-view.d.ts +14 -13
- package/dist/react/use-tree.d.ts +30 -15
- package/dist/react/use-view.d.ts +81 -50
- package/dist/utils.d.ts +48 -71
- package/dist/vercel/ably-ai-transport-vercel.js +3257 -2499
- 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/decode-lifecycle.d.ts +9 -0
- package/dist/vercel/codec/events.d.ts +50 -0
- package/dist/vercel/codec/fields.d.ts +44 -0
- package/dist/vercel/codec/fold-content.d.ts +16 -0
- package/dist/vercel/codec/fold-data.d.ts +16 -0
- package/dist/vercel/codec/fold-input.d.ts +67 -0
- package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
- package/dist/vercel/codec/fold-text.d.ts +16 -0
- package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
- package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
- package/dist/vercel/codec/index.d.ts +7 -20
- package/dist/vercel/codec/inputs.d.ts +11 -0
- package/dist/vercel/codec/outputs.d.ts +11 -0
- package/dist/vercel/codec/reducer-state.d.ts +121 -0
- package/dist/vercel/codec/reducer.d.ts +62 -0
- package/dist/vercel/codec/tool-transitions.d.ts +2 -8
- package/dist/vercel/codec/wire-data.d.ts +34 -0
- package/dist/vercel/index.d.ts +5 -5
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +2859 -9705
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -45
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +9 -7
- package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
- package/dist/vercel/react/index.d.ts +1 -2
- package/dist/vercel/react/use-chat-transport.d.ts +30 -26
- package/dist/vercel/react/use-message-sync.d.ts +17 -30
- package/dist/vercel/run-end-reason.d.ts +84 -0
- package/dist/vercel/tool-part.d.ts +21 -0
- package/dist/vercel/transport/chat-transport.d.ts +41 -24
- package/dist/vercel/transport/index.d.ts +24 -20
- package/dist/vercel/transport/run-output-stream.d.ts +54 -0
- package/dist/version.d.ts +2 -0
- package/package.json +31 -24
- package/src/constants.ts +124 -51
- package/src/core/agent.ts +92 -0
- package/src/core/channel-options.ts +89 -0
- package/src/core/codec/codec-event.ts +27 -0
- package/src/core/codec/decoder.ts +202 -105
- package/src/core/codec/define-codec.ts +432 -0
- package/src/core/codec/encoder.ts +114 -107
- package/src/core/codec/field-bag.ts +142 -0
- package/src/core/codec/fields.ts +193 -0
- package/src/core/codec/index.ts +56 -6
- package/src/core/codec/input-descriptor-decoder.ts +97 -0
- package/src/core/codec/input-descriptor-encoder.ts +150 -0
- package/src/core/codec/input-descriptors.ts +373 -0
- package/src/core/codec/lifecycle-tracker.ts +10 -9
- package/src/core/codec/output-descriptor-decoder.ts +139 -0
- package/src/core/codec/output-descriptor-encoder.ts +101 -0
- package/src/core/codec/output-descriptors.ts +307 -0
- package/src/core/codec/types.ts +505 -126
- package/src/core/codec/well-known-inputs.ts +96 -0
- package/src/core/transport/agent-session.ts +1085 -0
- package/src/core/transport/agent-view.ts +738 -0
- package/src/core/transport/client-session.ts +780 -0
- package/src/core/transport/decode-fold.ts +101 -0
- package/src/core/transport/headers.ts +234 -22
- package/src/core/transport/index.ts +27 -27
- package/src/core/transport/internal/bounded-map.ts +27 -0
- package/src/core/transport/invocation.ts +98 -0
- package/src/core/transport/load-history-pages.ts +220 -0
- package/src/core/transport/load-history.ts +271 -0
- package/src/core/transport/pipe-stream.ts +63 -39
- package/src/core/transport/run-manager.ts +243 -0
- package/src/core/transport/session-support.ts +96 -0
- package/src/core/transport/tree.ts +1293 -308
- package/src/core/transport/types/agent.ts +434 -0
- package/src/core/transport/types/client.ts +247 -0
- package/src/core/transport/types/shared.ts +27 -0
- package/src/core/transport/types/tree.ts +393 -0
- package/src/core/transport/types/view.ts +288 -0
- package/src/core/transport/types.ts +13 -706
- package/src/core/transport/view.ts +1229 -450
- package/src/core/transport/wire-log.ts +189 -0
- package/src/errors.ts +29 -9
- package/src/event-emitter.ts +3 -2
- package/src/index.ts +86 -42
- package/src/logger.ts +14 -1
- package/src/react/contexts/client-session-context.ts +41 -0
- package/src/react/contexts/client-session-provider.tsx +222 -0
- package/src/react/create-session-hooks.ts +141 -0
- package/src/react/index.ts +24 -13
- package/src/react/internal/skipped-session.ts +62 -0
- package/src/react/internal/use-resolved-session.ts +63 -0
- package/src/react/use-ably-messages.ts +32 -22
- package/src/react/use-client-session.ts +178 -0
- package/src/react/use-create-view.ts +33 -29
- package/src/react/use-tree.ts +61 -30
- package/src/react/use-view.ts +138 -96
- package/src/utils.ts +83 -131
- package/src/vercel/codec/decode-lifecycle.ts +70 -0
- package/src/vercel/codec/events.ts +85 -0
- package/src/vercel/codec/fields.ts +58 -0
- package/src/vercel/codec/fold-content.ts +54 -0
- package/src/vercel/codec/fold-data.ts +46 -0
- package/src/vercel/codec/fold-input.ts +255 -0
- package/src/vercel/codec/fold-lifecycle.ts +85 -0
- package/src/vercel/codec/fold-text.ts +55 -0
- package/src/vercel/codec/fold-tool-input.ts +86 -0
- package/src/vercel/codec/fold-tool-output.ts +79 -0
- package/src/vercel/codec/index.ts +28 -21
- package/src/vercel/codec/inputs.ts +116 -0
- package/src/vercel/codec/outputs.ts +207 -0
- package/src/vercel/codec/reducer-state.ts +169 -0
- package/src/vercel/codec/reducer.ts +191 -0
- package/src/vercel/codec/tool-transitions.ts +3 -14
- package/src/vercel/codec/wire-data.ts +64 -0
- package/src/vercel/index.ts +7 -19
- package/src/vercel/react/contexts/chat-transport-context.ts +8 -7
- package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
- package/src/vercel/react/index.ts +3 -5
- package/src/vercel/react/use-chat-transport.ts +44 -66
- package/src/vercel/react/use-message-sync.ts +75 -39
- package/src/vercel/run-end-reason.ts +157 -0
- package/src/vercel/tool-part.ts +25 -0
- package/src/vercel/transport/chat-transport.ts +380 -98
- package/src/vercel/transport/index.ts +38 -37
- package/src/vercel/transport/run-output-stream.ts +169 -0
- package/src/version.ts +2 -0
- package/dist/core/transport/client-transport.d.ts +0 -10
- package/dist/core/transport/decode-history.d.ts +0 -43
- package/dist/core/transport/server-transport.d.ts +0 -7
- package/dist/core/transport/stream-router.d.ts +0 -29
- package/dist/core/transport/turn-manager.d.ts +0 -37
- package/dist/react/contexts/transport-context.d.ts +0 -31
- package/dist/react/contexts/transport-provider.d.ts +0 -49
- package/dist/react/create-transport-hooks.d.ts +0 -124
- package/dist/react/use-active-turns.d.ts +0 -12
- package/dist/react/use-client-transport.d.ts +0 -80
- package/dist/vercel/codec/accumulator.d.ts +0 -21
- package/dist/vercel/codec/decoder.d.ts +0 -22
- package/dist/vercel/codec/encoder.d.ts +0 -41
- package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
- package/dist/vercel/tool-approvals.d.ts +0 -124
- package/dist/vercel/tool-events.d.ts +0 -26
- package/src/core/transport/client-transport.ts +0 -977
- package/src/core/transport/decode-history.ts +0 -485
- package/src/core/transport/server-transport.ts +0 -612
- package/src/core/transport/stream-router.ts +0 -136
- package/src/core/transport/turn-manager.ts +0 -165
- package/src/react/contexts/transport-context.ts +0 -37
- package/src/react/contexts/transport-provider.tsx +0 -164
- package/src/react/create-transport-hooks.ts +0 -144
- package/src/react/use-active-turns.ts +0 -72
- package/src/react/use-client-transport.ts +0 -197
- package/src/vercel/codec/accumulator.ts +0 -588
- package/src/vercel/codec/decoder.ts +0 -618
- package/src/vercel/codec/encoder.ts +0 -410
- package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
- package/src/vercel/tool-approvals.ts +0 -380
- package/src/vercel/tool-events.ts +0 -53
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Vercel chat transport: wraps a core
|
|
2
|
+
* Vercel chat transport: wraps a core ClientSession to satisfy the
|
|
3
3
|
* ChatTransport interface that useChat expects.
|
|
4
4
|
*
|
|
5
|
-
* This is a thin adapter — the real logic lives in the core
|
|
5
|
+
* This is a thin adapter — the real logic lives in the core client session.
|
|
6
6
|
* The chat transport maps Vercel's sendMessages/reconnectToStream contract
|
|
7
|
-
* to the core
|
|
7
|
+
* to the core session's send/cancel methods.
|
|
8
8
|
*
|
|
9
9
|
* useChat manages message state before calling sendMessages:
|
|
10
10
|
* - submit-message (new): appends the new user message, passes the full array
|
|
@@ -12,20 +12,35 @@
|
|
|
12
12
|
* passes the truncated array with messageId set
|
|
13
13
|
* - regenerate-message: truncates after the target, passes the truncated array
|
|
14
14
|
*
|
|
15
|
-
* The adapter uses `trigger` to determine the
|
|
16
|
-
*
|
|
15
|
+
* The adapter uses `(trigger, last-message role)` to determine the
|
|
16
|
+
* history/messages split:
|
|
17
|
+
* - submit-message + last message is a user message: that last message is new
|
|
18
|
+
* (publish to channel), rest is history. A new submit and an edit both take
|
|
19
|
+
* this path — an edit just carries a messageId.
|
|
20
|
+
* - submit-message + last message is an assistant already in the tree
|
|
21
|
+
* (continuation): no new messages, entire array is history
|
|
17
22
|
* - regenerate-message: no new messages, entire array is history
|
|
18
23
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* place the response on the
|
|
24
|
+
* For an edit (submit-message with messageId) and for forking off an
|
|
25
|
+
* unresolved tool call, the adapter computes fork metadata (forkOf/parent)
|
|
26
|
+
* from the conversation tree so the server can place the response on the
|
|
27
|
+
* correct branch. Regeneration fork metadata is NOT computed here —
|
|
28
|
+
* `View.regenerate` derives forkOf/parent from the tree itself.
|
|
22
29
|
*/
|
|
23
30
|
|
|
24
31
|
import * as Ably from 'ably';
|
|
25
32
|
import type * as AI from 'ai';
|
|
26
33
|
|
|
27
|
-
import type {
|
|
34
|
+
import type { CodecMessage } from '../../core/codec/index.js';
|
|
35
|
+
import type { ActiveRun, ClientSession, SendOptions } from '../../core/transport/types.js';
|
|
28
36
|
import { ErrorCode } from '../../errors.js';
|
|
37
|
+
import { EventEmitter } from '../../event-emitter.js';
|
|
38
|
+
import { LogLevel, makeLogger } from '../../logger.js';
|
|
39
|
+
import { errorCause, errorMessage } from '../../utils.js';
|
|
40
|
+
import type { VercelInput, VercelOutput, VercelProjection } from '../codec/index.js';
|
|
41
|
+
import { UIMessageCodec } from '../codec/index.js';
|
|
42
|
+
import { isToolPart, type ToolPart } from '../tool-part.js';
|
|
43
|
+
import { createRunOutputStream } from './run-output-stream.js';
|
|
29
44
|
|
|
30
45
|
// ---------------------------------------------------------------------------
|
|
31
46
|
// ChatTransport options
|
|
@@ -49,24 +64,36 @@ export interface SendMessagesRequestContext {
|
|
|
49
64
|
messageId?: string;
|
|
50
65
|
/** Previous messages in the conversation (context for the LLM). */
|
|
51
66
|
history: AI.UIMessage[];
|
|
52
|
-
/** The new message(s) being sent (to publish to the channel). Empty for regeneration. */
|
|
67
|
+
/** The new message(s) being sent (to publish to the channel). Empty for regeneration and for continuations (an auto-submit where the last message is an already-tracked assistant). */
|
|
53
68
|
messages: AI.UIMessage[];
|
|
54
|
-
/** The
|
|
69
|
+
/** The codec-message-id of the message being forked — the edited user message, or the preceding assistant when forking off an unresolved tool call. Undefined for regeneration (View.regenerate derives it) and fresh sends. */
|
|
55
70
|
forkOf?: string;
|
|
56
|
-
/** The
|
|
71
|
+
/** The codec-message-id of the predecessor in the conversation thread. */
|
|
57
72
|
parent?: string;
|
|
58
73
|
}
|
|
59
74
|
|
|
75
|
+
/** Default agent endpoint the transport POSTs invocations to — mirrors Vercel's DefaultChatTransport. */
|
|
76
|
+
const DEFAULT_VERCEL_API = '/api/chat';
|
|
77
|
+
|
|
60
78
|
/** Options for customizing the ChatTransport behavior. */
|
|
61
79
|
export interface ChatTransportOptions {
|
|
62
80
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
* Endpoint the transport POSTs the invocation pointer to, to wake the
|
|
82
|
+
* agent. Mirrors useChat's request-driven contract. Default `/api/chat`.
|
|
83
|
+
*/
|
|
84
|
+
api?: string;
|
|
85
|
+
/** Fetch credentials mode for the invocation POST (e.g. `'include'`). */
|
|
86
|
+
credentials?: RequestCredentials;
|
|
87
|
+
/** Custom fetch implementation for the invocation POST. Defaults to `globalThis.fetch`. */
|
|
88
|
+
fetch?: typeof globalThis.fetch;
|
|
89
|
+
/**
|
|
90
|
+
* Customize the invocation POST before sending. Called by sendMessages()
|
|
91
|
+
* with the conversation context; the returned `body` is merged into the
|
|
92
|
+
* POST body (the run's invocation identifiers always take precedence) and
|
|
93
|
+
* `headers` are added to the request. Use it for auth headers or extra
|
|
94
|
+
* agent metadata.
|
|
68
95
|
* @param context - The conversation context for the current request.
|
|
69
|
-
* @returns The body and headers to
|
|
96
|
+
* @returns The body and headers to merge into the invocation POST.
|
|
70
97
|
*/
|
|
71
98
|
prepareSendMessagesRequest?: (context: SendMessagesRequestContext) => {
|
|
72
99
|
body?: Record<string, unknown>;
|
|
@@ -131,9 +158,9 @@ export interface ChatTransport {
|
|
|
131
158
|
) => Promise<ReadableStream<AI.UIMessageChunk> | null>;
|
|
132
159
|
|
|
133
160
|
/** Close the underlying transport, releasing all resources. */
|
|
134
|
-
close(
|
|
161
|
+
close(): Promise<void>;
|
|
135
162
|
|
|
136
|
-
/** Whether an own-
|
|
163
|
+
/** Whether an own-run stream is currently being consumed by useChat. */
|
|
137
164
|
readonly streaming: boolean;
|
|
138
165
|
|
|
139
166
|
/**
|
|
@@ -153,11 +180,17 @@ export interface ChatTransport {
|
|
|
153
180
|
/**
|
|
154
181
|
* Wrap a ReadableStream in a passthrough TransformStream that resolves a
|
|
155
182
|
* promise when the stream completes or errors. The returned stream passes
|
|
156
|
-
* all chunks through unchanged
|
|
183
|
+
* all chunks through unchanged, and `fail(reason)` errors the readable side
|
|
184
|
+
* useChat consumes without cancelling or otherwise disturbing the source run
|
|
185
|
+
* stream (used when the agent-invocation POST fails).
|
|
157
186
|
* @param source - The original stream to wrap.
|
|
158
|
-
* @returns The wrapped stream
|
|
187
|
+
* @returns The wrapped stream, a `done` promise that resolves when the stream
|
|
188
|
+
* closes, and a `fail` callback that errors the wrapped stream.
|
|
159
189
|
*/
|
|
160
|
-
|
|
190
|
+
|
|
191
|
+
const wrapStreamWithDone = <T>(
|
|
192
|
+
source: ReadableStream<T>,
|
|
193
|
+
): { stream: ReadableStream<T>; done: Promise<void>; fail: (reason: Ably.ErrorInfo) => void } => {
|
|
161
194
|
let resolveDone: () => void;
|
|
162
195
|
const done = new Promise<void>((resolve) => {
|
|
163
196
|
resolveDone = resolve;
|
|
@@ -169,15 +202,26 @@ const wrapStreamWithDone = <T>(source: ReadableStream<T>): { stream: ReadableStr
|
|
|
169
202
|
},
|
|
170
203
|
});
|
|
171
204
|
|
|
172
|
-
//
|
|
173
|
-
//
|
|
205
|
+
// Aborting this signal errors the destination (the readable useChat reads)
|
|
206
|
+
// with the abort reason. `preventCancel` keeps the source run stream intact
|
|
207
|
+
// so the tree/observers are unaffected — only the useChat-facing view fails.
|
|
208
|
+
const failController = new AbortController();
|
|
209
|
+
|
|
210
|
+
// Pipe in the background. If the source errors/cancels, or `fail()` aborts,
|
|
211
|
+
// resolve done so the serialization queue advances.
|
|
174
212
|
// Fire-and-forget: the pipe runs independently; errors surface through
|
|
175
213
|
// the readable side that useChat consumes.
|
|
176
|
-
source.pipeTo(passthrough.writable).catch(() => {
|
|
214
|
+
source.pipeTo(passthrough.writable, { signal: failController.signal, preventCancel: true }).catch(() => {
|
|
177
215
|
resolveDone();
|
|
178
216
|
});
|
|
179
217
|
|
|
180
|
-
return {
|
|
218
|
+
return {
|
|
219
|
+
stream: passthrough.readable,
|
|
220
|
+
done,
|
|
221
|
+
fail: (reason: Ably.ErrorInfo) => {
|
|
222
|
+
failController.abort(reason);
|
|
223
|
+
},
|
|
224
|
+
};
|
|
181
225
|
};
|
|
182
226
|
|
|
183
227
|
// ---------------------------------------------------------------------------
|
|
@@ -190,7 +234,7 @@ const wrapStreamWithDone = <T>(source: ReadableStream<T>): { stream: ReadableStr
|
|
|
190
234
|
* - `input-streaming` / `input-available` — tool call emitted, not yet run.
|
|
191
235
|
* - `approval-requested` — waiting for the user.
|
|
192
236
|
*
|
|
193
|
-
* Excludes `approval-responded` (streamText will run the tool this
|
|
237
|
+
* Excludes `approval-responded` (streamText will run the tool this run)
|
|
194
238
|
* and all terminal `output-*` states.
|
|
195
239
|
* @param msg - The UIMessage to inspect.
|
|
196
240
|
* @returns True when a fork-on-send is warranted to avoid shipping a
|
|
@@ -200,55 +244,197 @@ const hasUnresolvedToolCall = (msg: AI.UIMessage): boolean =>
|
|
|
200
244
|
msg.role === 'assistant' &&
|
|
201
245
|
msg.parts.some(
|
|
202
246
|
(p) =>
|
|
203
|
-
p
|
|
247
|
+
isToolPart(p) &&
|
|
204
248
|
(p.state === 'input-streaming' || p.state === 'input-available' || p.state === 'approval-requested'),
|
|
205
249
|
);
|
|
206
250
|
|
|
251
|
+
/**
|
|
252
|
+
* `dynamic-tool` part states that mean "the LLM produced a tool call and
|
|
253
|
+
* is waiting on it". Used to detect new client-side resolutions in the
|
|
254
|
+
* useChat overlay relative to the tree.
|
|
255
|
+
*/
|
|
256
|
+
const UNRESOLVED_TOOL_STATES = new Set(['input-streaming', 'input-available', 'approval-requested']);
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Walk the useChat message overlay against the session tree and synthesize
|
|
260
|
+
* the {@link VercelInput}s needed to resolve every `dynamic-tool` part the
|
|
261
|
+
* user acted on (executed a tool, approved, denied) but the tree's reduced
|
|
262
|
+
* state hasn't reflected yet.
|
|
263
|
+
*
|
|
264
|
+
* Each input carries the prior assistant's tree codec-message-id (the one
|
|
265
|
+
* holding the original `dynamic-tool` part the resolution targets) in its
|
|
266
|
+
* `codecMessageId` field, so the encoder stamps `codec-message-id`
|
|
267
|
+
* and the reducer's direct-fold path lands the resolution on that assistant
|
|
268
|
+
* in one step — no cross-message redirect-by-toolCallId fallback. Every
|
|
269
|
+
* variant rides the `ai-input` wire, matching its publisher (client → input).
|
|
270
|
+
*
|
|
271
|
+
* The resulting inputs are passed alongside the continuation `view.send`
|
|
272
|
+
* so the channel publish and the continuation POST land as ONE atomic
|
|
273
|
+
* operation — the agent's `loadConversation()` history walk is guaranteed
|
|
274
|
+
* to see them because the channel publish happens before the POST inside
|
|
275
|
+
* `_internalSend`.
|
|
276
|
+
*
|
|
277
|
+
* Three resolutions are produced:
|
|
278
|
+
*
|
|
279
|
+
* - `approval-responded` overlay vs `approval-requested` tree →
|
|
280
|
+
* `tool-approval-response` carrying the user's decision
|
|
281
|
+
* (`approved` = `overlayPart.approval.approved`, i.e. approve or deny)
|
|
282
|
+
* - `output-available` overlay vs unresolved tree → `tool-result`
|
|
283
|
+
* - `output-error` overlay vs unresolved tree → `tool-result-error`
|
|
284
|
+
* @param codecMessages - The visible tree messages paired with their codec-message-ids.
|
|
285
|
+
* @param messages - useChat's local overlay messages.
|
|
286
|
+
* @returns The continuation inputs to publish, in tree order. Each input
|
|
287
|
+
* carries its own `codecMessageId` targeting the prior assistant it folds
|
|
288
|
+
* onto.
|
|
289
|
+
*/
|
|
290
|
+
const deriveContinuationInputs = (
|
|
291
|
+
codecMessages: CodecMessage<AI.UIMessage>[],
|
|
292
|
+
messages: AI.UIMessage[],
|
|
293
|
+
): VercelInput[] => {
|
|
294
|
+
const inputs: VercelInput[] = [];
|
|
295
|
+
for (const overlay of messages) {
|
|
296
|
+
if (overlay.role !== 'assistant') continue;
|
|
297
|
+
// Match the overlay to its tree message by domain id (both sides
|
|
298
|
+
// reconstruct the same stream id), but address the emitted inputs by
|
|
299
|
+
// the tree message's codec-message-id — the agent folds tool
|
|
300
|
+
// resolutions onto the assistant by codec-message-id, never by the
|
|
301
|
+
// domain `message.id`.
|
|
302
|
+
const treeEntry = codecMessages.find((p) => p.message.id === overlay.id);
|
|
303
|
+
if (!treeEntry) continue;
|
|
304
|
+
const { codecMessageId, message: treeMessage } = treeEntry;
|
|
305
|
+
|
|
306
|
+
for (const overlayPart of overlay.parts) {
|
|
307
|
+
if (!isToolPart(overlayPart)) continue;
|
|
308
|
+
// The codec normalises every tool part to `dynamic-tool`, but the
|
|
309
|
+
// AI SDK's useChat overlay emits `tool-${name}` parts for statically
|
|
310
|
+
// declared tools. Match by toolCallId rather than the type prefix
|
|
311
|
+
// so the cross-representation comparison works regardless of which
|
|
312
|
+
// side the tool was declared on.
|
|
313
|
+
const treePart = treeMessage.parts.find(
|
|
314
|
+
(p: AI.UIMessage['parts'][number]): p is ToolPart => isToolPart(p) && p.toolCallId === overlayPart.toolCallId,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// Approval response: useChat's `addToolApprovalResponse` flipped the
|
|
318
|
+
// overlay part to `approval-responded` while the tree still sits on
|
|
319
|
+
// `approval-requested`. Publish a `tool-approval-response` TInput so the
|
|
320
|
+
// agent's projection sees the decision.
|
|
321
|
+
if (overlayPart.state === 'approval-responded' && (!treePart || treePart.state === 'approval-requested')) {
|
|
322
|
+
inputs.push(
|
|
323
|
+
UIMessageCodec.createToolApprovalResponse(codecMessageId, {
|
|
324
|
+
toolCallId: overlayPart.toolCallId,
|
|
325
|
+
approved: overlayPart.approval.approved,
|
|
326
|
+
...(overlayPart.approval.reason === undefined ? {} : { reason: overlayPart.approval.reason }),
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Client-tool resolution: overlay has `output-available` / `output-error`
|
|
333
|
+
// while the tree's part is still unresolved. Construct a TInput
|
|
334
|
+
// variant (not a UIMessageChunk) so the encoder publishes on the
|
|
335
|
+
// `ai-input` wire — client tool results belong on `ai-input`, matching
|
|
336
|
+
// their client publisher, not on `ai-output`.
|
|
337
|
+
if (overlayPart.state !== 'output-available' && overlayPart.state !== 'output-error') continue;
|
|
338
|
+
// Tree already resolved (echo arrived back) — nothing to do.
|
|
339
|
+
if (treePart && !UNRESOLVED_TOOL_STATES.has(treePart.state)) continue;
|
|
340
|
+
|
|
341
|
+
if (overlayPart.state === 'output-available') {
|
|
342
|
+
inputs.push(
|
|
343
|
+
UIMessageCodec.createToolResult(codecMessageId, {
|
|
344
|
+
toolCallId: overlayPart.toolCallId,
|
|
345
|
+
output: overlayPart.output,
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
} else {
|
|
349
|
+
inputs.push(
|
|
350
|
+
UIMessageCodec.createToolResultError(codecMessageId, {
|
|
351
|
+
toolCallId: overlayPart.toolCallId,
|
|
352
|
+
message: overlayPart.errorText,
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return inputs;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Find the codec-message-id immediately preceding the message identified by
|
|
363
|
+
* domain id `domainId` in the flat visible conversation. The target is
|
|
364
|
+
* located by its domain `message.id` (the id useChat references), but the
|
|
365
|
+
* returned value is the predecessor's codec-message-id — never a domain id.
|
|
366
|
+
* Returns undefined if the target is the first message or not found.
|
|
367
|
+
* @param codecMessages - Visible messages paired with their codec-message-ids.
|
|
368
|
+
* @param domainId - The domain id of the target message.
|
|
369
|
+
* @returns The predecessor's codec-message-id, or undefined.
|
|
370
|
+
*/
|
|
371
|
+
const findPredecessorCodecId = (codecMessages: CodecMessage<AI.UIMessage>[], domainId: string): string | undefined => {
|
|
372
|
+
const idx = codecMessages.findIndex((p) => p.message.id === domainId);
|
|
373
|
+
if (idx <= 0) return undefined;
|
|
374
|
+
return codecMessages[idx - 1]?.codecMessageId;
|
|
375
|
+
};
|
|
376
|
+
|
|
207
377
|
// ---------------------------------------------------------------------------
|
|
208
378
|
// Factory
|
|
209
379
|
// ---------------------------------------------------------------------------
|
|
210
380
|
|
|
381
|
+
/** Internal EventEmitter events map backing the transport's streaming state. */
|
|
382
|
+
interface ChatTransportEventsMap {
|
|
383
|
+
/** Fired on every streaming-state transition with the new value. */
|
|
384
|
+
streaming: boolean;
|
|
385
|
+
}
|
|
386
|
+
|
|
211
387
|
/**
|
|
212
|
-
* Create a Vercel ChatTransport from a core
|
|
388
|
+
* Create a Vercel ChatTransport from a core ClientSession.
|
|
213
389
|
*
|
|
214
390
|
* Exposes a `streaming` flag and `onStreamingChange` callback so that
|
|
215
|
-
* `useMessageSync` can gate `setMessages` calls during active own-
|
|
391
|
+
* `useMessageSync` can gate `setMessages` calls during active own-run
|
|
216
392
|
* streams, preventing the push/replace ID mismatch in useChat's `write()`.
|
|
217
393
|
*
|
|
218
394
|
* Note: concurrent `sendMessage` calls from the same user are a useChat
|
|
219
395
|
* limitation that cannot be fixed from the transport layer. The
|
|
220
396
|
* developer must respect useChat's `status` and only call `sendMessage`
|
|
221
397
|
* when status is `'ready'`.
|
|
222
|
-
* @param
|
|
398
|
+
* @param session - The core client session to wrap.
|
|
223
399
|
* @param chatOptions - Optional hooks for customizing request construction.
|
|
224
400
|
* @returns A {@link ChatTransport} compatible with Vercel's useChat hook.
|
|
225
401
|
*/
|
|
226
402
|
export const createChatTransport = (
|
|
227
|
-
|
|
403
|
+
session: ClientSession<VercelInput, VercelOutput, VercelProjection, AI.UIMessage>,
|
|
228
404
|
chatOptions?: ChatTransportOptions,
|
|
229
405
|
): ChatTransport => {
|
|
406
|
+
// -- Invocation POST config (the transport owns waking the agent) ----------
|
|
407
|
+
const api = chatOptions?.api ?? DEFAULT_VERCEL_API;
|
|
408
|
+
const fetchFn = chatOptions?.fetch ?? globalThis.fetch.bind(globalThis);
|
|
409
|
+
const credentials = chatOptions?.credentials;
|
|
410
|
+
|
|
230
411
|
// -- Streaming state -------------------------------------------------------
|
|
412
|
+
// Backed by the shared EventEmitter for listener error isolation (one bad
|
|
413
|
+
// onStreamingChange handler can't prevent others from firing or block the
|
|
414
|
+
// state transition) and uniform emitter behaviour across the SDK. The
|
|
415
|
+
// factory takes no logger, so a silent one is used — listener exceptions are
|
|
416
|
+
// swallowed by the emitter rather than surfaced.
|
|
231
417
|
let _streaming = false;
|
|
232
|
-
const
|
|
418
|
+
const emitter = new EventEmitter<ChatTransportEventsMap>(makeLogger({ logLevel: LogLevel.Silent }));
|
|
233
419
|
|
|
234
420
|
const setStreaming = (value: boolean): void => {
|
|
235
421
|
_streaming = value;
|
|
236
|
-
|
|
237
|
-
try {
|
|
238
|
-
cb(value);
|
|
239
|
-
} catch {
|
|
240
|
-
// Isolate subscriber errors so one bad handler doesn't prevent
|
|
241
|
-
// other subscribers from being notified or block the streaming
|
|
242
|
-
// state transition.
|
|
243
|
-
}
|
|
244
|
-
}
|
|
422
|
+
emitter.emit('streaming', value);
|
|
245
423
|
};
|
|
246
424
|
|
|
247
425
|
// -- sendMessages implementation -------------------------------------------
|
|
248
426
|
|
|
249
427
|
const sendMessages: ChatTransport['sendMessages'] = async (opts) => {
|
|
250
428
|
const { messages, abortSignal, trigger, messageId } = opts;
|
|
251
|
-
|
|
429
|
+
|
|
430
|
+
// The visible messages paired with their codec-message-ids. useChat
|
|
431
|
+
// references messages by their domain `message.id`; we match on that to
|
|
432
|
+
// locate a message in the tree, then route every transport operation by
|
|
433
|
+
// the message's codec-message-id (the SDK never correlates on the domain
|
|
434
|
+
// id, which may differ from the codec-message-id).
|
|
435
|
+
const codecMessages = session.view.getMessages();
|
|
436
|
+
const codecIdByDomainId = new Map(codecMessages.map((m) => [m.message.id, m.codecMessageId]));
|
|
437
|
+
const codecIdOf = (domainId: string): string | undefined => codecIdByDomainId.get(domainId);
|
|
252
438
|
|
|
253
439
|
// useChat calls sendMessages in three distinct modes. We disambiguate
|
|
254
440
|
// by (trigger, last-message role) so each mode dispatches correctly:
|
|
@@ -265,27 +451,29 @@ export const createChatTransport = (
|
|
|
265
451
|
// Continuation mode must NOT publish the assistant as a new message or
|
|
266
452
|
// treat messageId as a fork target — useChat v6's sendAutomaticallyWhen
|
|
267
453
|
// path always sets messageId to the last message id regardless.
|
|
268
|
-
//
|
|
269
|
-
// Client-side tool outputs are expected to be staged on the transport
|
|
270
|
-
// via transport.stageEvents() before this runs; the core transport
|
|
271
|
-
// flushes staged events into the POST body automatically.
|
|
272
454
|
const lastMessage = messages.at(-1);
|
|
273
|
-
const
|
|
274
|
-
const isContinuation = trigger === 'submit-message' && lastMessage?.role === 'assistant' &&
|
|
455
|
+
const lastMessageInTree = !!lastMessage && codecIdByDomainId.has(lastMessage.id);
|
|
456
|
+
const isContinuation = trigger === 'submit-message' && lastMessage?.role === 'assistant' && lastMessageInTree;
|
|
275
457
|
|
|
276
458
|
// Fork-on-unresolved-tool: user sent a new message while the preceding
|
|
277
459
|
// assistant has an unresolved tool call (approval-requested, input-*).
|
|
278
460
|
// Fork the new message off the preceding assistant so the unresolved
|
|
279
|
-
// tool call stays dormant on a sibling branch. Inference this
|
|
461
|
+
// tool call stays dormant on a sibling branch. Inference for this run runs
|
|
280
462
|
// on the clean fork — the LLM never sees the dangling tool_use.
|
|
281
463
|
//
|
|
282
464
|
// Only applies to fresh user-message submits (not continuations, not
|
|
283
465
|
// regenerates, not edits-with-messageId).
|
|
466
|
+
//
|
|
467
|
+
// `messages.at(-1)` is the fresh user-prompt being submitted right now;
|
|
468
|
+
// `messages.at(-2)` is therefore the prior assistant whose tool state
|
|
469
|
+
// we need to inspect for the unresolved-tool gate below.
|
|
284
470
|
const precedingMessage =
|
|
285
471
|
trigger === 'submit-message' && !messageId && lastMessage?.role === 'user' ? messages.at(-2) : undefined;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
472
|
+
// The domain id of the preceding assistant when it carries an unresolved
|
|
473
|
+
// tool call and is present in the tree — the new user message forks off it.
|
|
474
|
+
const forkSourceDomainId =
|
|
475
|
+
precedingMessage && hasUnresolvedToolCall(precedingMessage) && codecIdByDomainId.has(precedingMessage.id)
|
|
476
|
+
? precedingMessage.id
|
|
289
477
|
: undefined;
|
|
290
478
|
|
|
291
479
|
// Determine the history/messages split based on mode.
|
|
@@ -309,35 +497,27 @@ export const createChatTransport = (
|
|
|
309
497
|
// When forking off an unresolved tool call, drop the unresolved
|
|
310
498
|
// assistant from history too — it belongs on the sibling branch, not
|
|
311
499
|
// the ancestor chain of the new message.
|
|
312
|
-
history =
|
|
500
|
+
history = forkSourceDomainId ? messages.slice(0, -2) : messages.slice(0, -1);
|
|
313
501
|
}
|
|
314
502
|
|
|
315
|
-
// Compute fork metadata
|
|
316
|
-
//
|
|
503
|
+
// Compute fork metadata for edit (submit-message with messageId) and
|
|
504
|
+
// fork-on-unresolved-tool. Regenerate is NOT precomputed here —
|
|
505
|
+
// `View.regenerate` derives forkOf/parent from the tree itself and
|
|
506
|
+
// overrides anything we'd set.
|
|
317
507
|
let forkOf: string | undefined;
|
|
318
508
|
let parent: string | undefined;
|
|
319
509
|
|
|
320
|
-
if (messageId && !isContinuation) {
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
forkOf = messageId;
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
forkOf = node.msgId;
|
|
328
|
-
parent = node.parentId;
|
|
329
|
-
}
|
|
330
|
-
} else if (isContinuation) {
|
|
331
|
-
// Continuation: the server's next assistant message is a child of the
|
|
332
|
-
// last assistant (no fork). Pass parent so the server places the new
|
|
333
|
-
// message correctly in the tree. isContinuation narrows lastMessageNode
|
|
334
|
-
// to defined.
|
|
335
|
-
parent = lastMessageNode.msgId;
|
|
336
|
-
} else if (forkSource) {
|
|
510
|
+
if (trigger === 'submit-message' && messageId && !isContinuation) {
|
|
511
|
+
// Edit: messageId is the domain id of the user message being replaced.
|
|
512
|
+
// forkOf = its codec-message-id, parent = the immediately-preceding
|
|
513
|
+
// codec-message-id in the flat conversation.
|
|
514
|
+
forkOf = codecIdOf(messageId);
|
|
515
|
+
parent = findPredecessorCodecId(codecMessages, messageId);
|
|
516
|
+
} else if (forkSourceDomainId) {
|
|
337
517
|
// Fork off the preceding assistant — the new user message becomes a
|
|
338
518
|
// sibling of the unresolved tool call assistant, rooted at its parent.
|
|
339
|
-
forkOf =
|
|
340
|
-
parent =
|
|
519
|
+
forkOf = codecIdOf(forkSourceDomainId);
|
|
520
|
+
parent = findPredecessorCodecId(codecMessages, forkSourceDomainId);
|
|
341
521
|
}
|
|
342
522
|
|
|
343
523
|
let sendBody: Record<string, unknown>;
|
|
@@ -356,38 +536,102 @@ export const createChatTransport = (
|
|
|
356
536
|
sendBody = prepared.body ?? {};
|
|
357
537
|
sendHeaders = prepared.headers;
|
|
358
538
|
} else {
|
|
359
|
-
|
|
360
|
-
const historyNodes = allNodes.filter((n) => historyIds.has(n.message.id));
|
|
361
|
-
sendBody = {
|
|
362
|
-
history: historyNodes,
|
|
363
|
-
chatId: opts.chatId,
|
|
364
|
-
trigger,
|
|
365
|
-
...(messageId !== undefined && { messageId }),
|
|
366
|
-
...(forkOf !== undefined && { forkOf }),
|
|
367
|
-
...(parent !== undefined && { parent }),
|
|
368
|
-
};
|
|
539
|
+
sendBody = {};
|
|
369
540
|
sendHeaders = undefined;
|
|
370
541
|
}
|
|
371
542
|
|
|
372
|
-
const sendOpts: SendOptions = {
|
|
543
|
+
const sendOpts: SendOptions = {};
|
|
373
544
|
if (forkOf !== undefined) sendOpts.forkOf = forkOf;
|
|
374
545
|
if (parent !== undefined) sendOpts.parent = parent;
|
|
546
|
+
// Continuations reuse the suspended assistant's runId so the agent's
|
|
547
|
+
// existing run resumes under a fresh invocation rather than spinning
|
|
548
|
+
// up a brand-new run. `isContinuation` implies `lastMessage` is defined.
|
|
549
|
+
if (isContinuation) {
|
|
550
|
+
// `isContinuation` implies `lastMessage` is defined (it gates on
|
|
551
|
+
// `lastMessage?.role`). Route the runId lookup by codec-message-id.
|
|
552
|
+
const codecId = codecIdOf(lastMessage.id);
|
|
553
|
+
const run = codecId === undefined ? undefined : session.view.runOf(codecId);
|
|
554
|
+
if (run) sendOpts.runId = run.runId;
|
|
555
|
+
}
|
|
375
556
|
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
557
|
+
// Dispatch by mode:
|
|
558
|
+
//
|
|
559
|
+
// - Continuation: derive tool-resolution events from useChat's overlay
|
|
560
|
+
// vs the tree and pair each with the prior assistant's tree codec-message-id —
|
|
561
|
+
// the SDK stamps the wire's `codec-message-id` to that id so the
|
|
562
|
+
// reducer's direct fold path runs (no redirect, no consume).
|
|
563
|
+
// - Regenerate: route through `view.regenerate`. The View mints a
|
|
564
|
+
// wire-only regenerate event (`ait-regenerate`) carrying
|
|
565
|
+
// `forkOf=A1` / `parent=U1` on transport headers. U1 is NOT
|
|
566
|
+
// republished — A1 and A2 group as tree siblings under U1 via the
|
|
567
|
+
// existing forkOf machinery. The LLM receives the truncated history
|
|
568
|
+
// through U1 inclusive via the body.
|
|
569
|
+
// - Fresh send / edit: publish the new user-message input(s) via
|
|
570
|
+
// `view.send`.
|
|
571
|
+
let run: ActiveRun;
|
|
572
|
+
if (isContinuation) {
|
|
573
|
+
const inputs = deriveContinuationInputs(codecMessages, messages);
|
|
574
|
+
run = await session.view.send(inputs, sendOpts);
|
|
575
|
+
} else if (trigger === 'regenerate-message') {
|
|
576
|
+
if (messageId === undefined) {
|
|
577
|
+
throw new Ably.ErrorInfo(
|
|
578
|
+
'unable to regenerate; regenerate-message trigger fired without messageId',
|
|
579
|
+
ErrorCode.InvalidArgument,
|
|
580
|
+
400,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
// useChat passes the assistant's domain id; route by its codec-message-id.
|
|
584
|
+
const regenCodecId = codecIdOf(messageId);
|
|
585
|
+
if (regenCodecId === undefined) {
|
|
586
|
+
throw new Ably.ErrorInfo(
|
|
587
|
+
`unable to regenerate; message not visible: ${messageId}`,
|
|
588
|
+
ErrorCode.InvalidArgument,
|
|
589
|
+
400,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
run = await session.view.regenerate(regenCodecId, sendOpts);
|
|
593
|
+
} else {
|
|
594
|
+
const inputs = newMessages.map((m) => UIMessageCodec.createUserMessage(m));
|
|
595
|
+
run = await session.view.send(inputs, sendOpts);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Build the consumer-facing stream from the Tree's events for this run.
|
|
599
|
+
// Streaming is a useChat concern owned by the Vercel layer; the core
|
|
600
|
+
// session exposes no per-run stream. Key it on
|
|
601
|
+
// `run.inputCodecMessageId` — the triggering input's codec-message-id, which
|
|
602
|
+
// the client owns from send time and the agent echoes as
|
|
603
|
+
// `input-codec-message-id`. The agent mints the runId, supplied as
|
|
604
|
+
// `run.runId` (a promise) for the run-end safety-net.
|
|
605
|
+
const runStream = createRunOutputStream(session, run.runId, run.inputCodecMessageId);
|
|
380
606
|
|
|
381
607
|
if (abortSignal) {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
608
|
+
const onAbort = (): void => {
|
|
609
|
+
// Best-effort cancel via the run handle (knows its own key / runId);
|
|
610
|
+
// the core resolves the runId once the agent mints it.
|
|
611
|
+
void run.cancel();
|
|
612
|
+
// Close the consumer stream immediately so useChat's reader ends
|
|
613
|
+
// without waiting for the agent's run-end round-trip.
|
|
614
|
+
runStream.close();
|
|
615
|
+
};
|
|
616
|
+
// useChat sets `status: 'submitted'` synchronously inside `makeRequest`
|
|
617
|
+
// BEFORE awaiting `transport.sendMessages`. That immediately enables
|
|
618
|
+
// the Stop button in the UI. If the user clicks Stop while
|
|
619
|
+
// `session.view.send` is still awaiting the run-start ack (which
|
|
620
|
+
// can take seconds for a real LLM), useChat aborts the signal before
|
|
621
|
+
// we ever get here. `addEventListener('abort', ...)` does not fire
|
|
622
|
+
// for an already-aborted signal, so we'd silently lose the cancel
|
|
623
|
+
// and the agent would keep streaming.
|
|
624
|
+
if (abortSignal.aborted) {
|
|
625
|
+
onAbort();
|
|
626
|
+
} else {
|
|
627
|
+
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
628
|
+
}
|
|
385
629
|
}
|
|
386
630
|
|
|
387
631
|
// Wrap the stream to detect completion. The streaming flag gates
|
|
388
632
|
// useMessageSync so that setMessages doesn't interfere with
|
|
389
633
|
// useChat's internal write() during active streams.
|
|
390
|
-
const { stream, done } = wrapStreamWithDone(
|
|
634
|
+
const { stream, done, fail } = wrapStreamWithDone(runStream.stream);
|
|
391
635
|
setStreaming(true);
|
|
392
636
|
|
|
393
637
|
// Fire-and-forget: clear the streaming flag when the stream ends.
|
|
@@ -395,6 +639,44 @@ export const createChatTransport = (
|
|
|
395
639
|
setStreaming(false);
|
|
396
640
|
});
|
|
397
641
|
|
|
642
|
+
// Wake the agent: POST the invocation pointer to the configured endpoint.
|
|
643
|
+
// useChat's transport contract is request-driven, so the transport owns
|
|
644
|
+
// this POST (the core session is HTTP-free). Fire-and-forget — `await`
|
|
645
|
+
// would delay the stream return, and the agent's response arrives over
|
|
646
|
+
// the Ably channel, not the HTTP response. The run's invocation
|
|
647
|
+
// identifiers always win over any custom body so the agent can parse it
|
|
648
|
+
// via Invocation.fromJSON. A failed POST means the agent never woke, so
|
|
649
|
+
// error the useChat-facing stream; the core run and observers are
|
|
650
|
+
// untouched.
|
|
651
|
+
const postBody = { ...sendBody, ...run.toInvocation().toJSON() };
|
|
652
|
+
fetchFn(api, {
|
|
653
|
+
method: 'POST',
|
|
654
|
+
headers: { 'Content-Type': 'application/json', ...sendHeaders },
|
|
655
|
+
body: JSON.stringify(postBody),
|
|
656
|
+
...(credentials ? { credentials } : {}),
|
|
657
|
+
})
|
|
658
|
+
.then((response) => {
|
|
659
|
+
if (!response.ok) {
|
|
660
|
+
fail(
|
|
661
|
+
new Ably.ErrorInfo(
|
|
662
|
+
`unable to send; HTTP POST to ${api} returned ${String(response.status)} ${response.statusText}`,
|
|
663
|
+
ErrorCode.SessionSendFailed,
|
|
664
|
+
response.status,
|
|
665
|
+
),
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
})
|
|
669
|
+
.catch((error: unknown) => {
|
|
670
|
+
fail(
|
|
671
|
+
new Ably.ErrorInfo(
|
|
672
|
+
`unable to send; HTTP POST to ${api} failed: ${errorMessage(error)}`,
|
|
673
|
+
ErrorCode.SessionSendFailed,
|
|
674
|
+
500,
|
|
675
|
+
errorCause(error),
|
|
676
|
+
),
|
|
677
|
+
);
|
|
678
|
+
});
|
|
679
|
+
|
|
398
680
|
return stream;
|
|
399
681
|
};
|
|
400
682
|
|
|
@@ -408,16 +690,16 @@ export const createChatTransport = (
|
|
|
408
690
|
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/promise-function-async -- null is required by the AI SDK ChatTransport contract; no await needed
|
|
409
691
|
reconnectToStream: () => Promise.resolve(null),
|
|
410
692
|
|
|
411
|
-
close: async (
|
|
693
|
+
close: async () => session.close(),
|
|
412
694
|
|
|
413
695
|
get streaming(): boolean {
|
|
414
696
|
return _streaming;
|
|
415
697
|
},
|
|
416
698
|
|
|
417
699
|
onStreamingChange: (callback: (streaming: boolean) => void): (() => void) => {
|
|
418
|
-
|
|
700
|
+
emitter.on('streaming', callback);
|
|
419
701
|
return () => {
|
|
420
|
-
|
|
702
|
+
emitter.off('streaming', callback);
|
|
421
703
|
};
|
|
422
704
|
},
|
|
423
705
|
};
|