@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,775 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core client-side session, parameterized by codec.
|
|
3
|
+
*
|
|
4
|
+
* Composes the conversation Tree to handle the full client-side lifecycle.
|
|
5
|
+
* `connect()` subscribes to the Ably channel (which implicitly attaches it).
|
|
6
|
+
* The same subscription, decoder, and channel are reused across runs.
|
|
7
|
+
*
|
|
8
|
+
* The client publishes user messages directly to the channel via the shared
|
|
9
|
+
* codec encoder. It does not send HTTP: waking an agent is the application's
|
|
10
|
+
* concern — it POSTs `run.toInvocation().toJSON()` to its own endpoint if and
|
|
11
|
+
* when it wants one woken (the Vercel ChatTransport does this for useChat
|
|
12
|
+
* parity). The agent locates the triggering input event by its `event-id`
|
|
13
|
+
* header and publishes run lifecycle events (run-start, run-end) plus assistant
|
|
14
|
+
* chunks, minting and stamping the invocation-id itself. The channel is the
|
|
15
|
+
* durable session record; agents that weren't running at publish time can
|
|
16
|
+
* resume by reading channel rewind.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as Ably from 'ably';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
EVENT_CANCEL,
|
|
23
|
+
EVENT_RUN_END,
|
|
24
|
+
HEADER_CODEC_MESSAGE_ID,
|
|
25
|
+
HEADER_ERROR_CODE,
|
|
26
|
+
HEADER_ERROR_MESSAGE,
|
|
27
|
+
HEADER_EVENT_ID,
|
|
28
|
+
HEADER_INPUT_CODEC_MESSAGE_ID,
|
|
29
|
+
HEADER_INVOCATION_ID,
|
|
30
|
+
HEADER_PARENT,
|
|
31
|
+
HEADER_ROLE,
|
|
32
|
+
HEADER_RUN_ID,
|
|
33
|
+
HEADER_RUN_REASON,
|
|
34
|
+
} from '../../constants.js';
|
|
35
|
+
import { ErrorCode } from '../../errors.js';
|
|
36
|
+
import { EventEmitter } from '../../event-emitter.js';
|
|
37
|
+
import type { Logger } from '../../logger.js';
|
|
38
|
+
import { LogLevel, makeLogger } from '../../logger.js';
|
|
39
|
+
import { getTransportHeaders } from '../../utils.js';
|
|
40
|
+
import { registerAgent } from '../agent.js';
|
|
41
|
+
import type { CodecInputEvent, CodecOutputEvent, Decoder, Encoder } from '../codec/types.js';
|
|
42
|
+
import { applyWireMessage } from './decode-fold.js';
|
|
43
|
+
import { buildTransportHeaders } from './headers.js';
|
|
44
|
+
import { Invocation } from './invocation.js';
|
|
45
|
+
import type { DefaultTree } from './tree.js';
|
|
46
|
+
import { createTree } from './tree.js';
|
|
47
|
+
import type { ActiveRun, ClientSession, ClientSessionOptions, RunEndReason, SendOptions, Tree, View } from './types.js';
|
|
48
|
+
import { createView, type DefaultView } from './view.js';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Returned from `on()` when the session is already closed — the subscription
|
|
52
|
+
* is silently ignored since no further events will fire.
|
|
53
|
+
*/
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentional no-op
|
|
55
|
+
const noopUnsubscribe = (): void => {};
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Internal state machine
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
enum ClientSessionState {
|
|
62
|
+
READY = 'ready',
|
|
63
|
+
CLOSED = 'closed',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Event map for the session's typed EventEmitter
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
interface ClientSessionEventsMap {
|
|
71
|
+
error: Ably.ErrorInfo;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Implementation
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
// Spec: AIT-CT1
|
|
79
|
+
class DefaultClientSession<
|
|
80
|
+
TInput extends CodecInputEvent,
|
|
81
|
+
TOutput extends CodecOutputEvent,
|
|
82
|
+
TProjection,
|
|
83
|
+
TMessage,
|
|
84
|
+
> implements ClientSession<TInput, TOutput, TProjection, TMessage> {
|
|
85
|
+
private readonly _channel: Ably.RealtimeChannel;
|
|
86
|
+
private readonly _codec: ClientSessionOptions<TInput, TOutput, TProjection, TMessage>['codec'];
|
|
87
|
+
private readonly _clientId: string | undefined;
|
|
88
|
+
private readonly _logger: Logger;
|
|
89
|
+
|
|
90
|
+
// Typed event emitter — the session emits only 'error'; all data events live on Tree/View
|
|
91
|
+
private readonly _emitter: EventEmitter<ClientSessionEventsMap>;
|
|
92
|
+
|
|
93
|
+
// Sub-components
|
|
94
|
+
private readonly _tree: DefaultTree<TInput, TOutput, TProjection>;
|
|
95
|
+
private readonly _view: DefaultView<TInput, TOutput, TProjection, TMessage>;
|
|
96
|
+
private readonly _views = new Set<DefaultView<TInput, TOutput, TProjection, TMessage>>();
|
|
97
|
+
private readonly _decoder: Decoder<TInput, TOutput>;
|
|
98
|
+
/**
|
|
99
|
+
* Shared encoder for the lifetime of the session. The client only ever
|
|
100
|
+
* uses `publishInput` (input wire), so the encoder's stream tracker map
|
|
101
|
+
* stays empty across the session. Closed once on session close.
|
|
102
|
+
*/
|
|
103
|
+
private readonly _encoder: Encoder<TInput, TOutput>;
|
|
104
|
+
|
|
105
|
+
// Spec: AIT-CT10, AIT-CT10a
|
|
106
|
+
readonly tree: Tree<TOutput, TProjection>;
|
|
107
|
+
readonly view: View<TInput, TMessage>;
|
|
108
|
+
|
|
109
|
+
// Channel subscription is established lazily on connect()
|
|
110
|
+
private _connectPromise: Promise<void> | undefined;
|
|
111
|
+
private readonly _onMessage: (msg: Ably.InboundMessage) => void;
|
|
112
|
+
|
|
113
|
+
private _state = ClientSessionState.READY;
|
|
114
|
+
private _hasAttachedOnce: boolean;
|
|
115
|
+
private readonly _onChannelStateChange: Ably.channelEventCallback;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Backing settlers for each in-flight run's `ActiveRun.runId` promise.
|
|
119
|
+
* Resolved with the agent-minted run-id when the matching `ai-run-start`
|
|
120
|
+
* (fresh send) or `ai-run-resume` (continuation) is observed; rejected if
|
|
121
|
+
* the session closes first. There is no deadline —
|
|
122
|
+
* `send()` resolves on publish and does not block on run-start.
|
|
123
|
+
*
|
|
124
|
+
* Keyed by the triggering input's codec-message-id — the handle the client
|
|
125
|
+
* owns at send time, which the agent echoes back on run-start as
|
|
126
|
+
* `input-codec-message-id`. This is uniform across fresh sends and
|
|
127
|
+
* continuations (a continuation is itself an input event — tool-approval or
|
|
128
|
+
* tool-result — with its own codec-message-id), so reconciliation never
|
|
129
|
+
* depends on a client-minted run/invocation id.
|
|
130
|
+
*/
|
|
131
|
+
private readonly _pendingRunStarts = new Map<
|
|
132
|
+
string,
|
|
133
|
+
{ resolve: (runId: string) => void; reject: (e: Ably.ErrorInfo) => void }
|
|
134
|
+
>();
|
|
135
|
+
|
|
136
|
+
constructor(options: ClientSessionOptions<TInput, TOutput, TProjection, TMessage>) {
|
|
137
|
+
// Spec: AIT-CT1a, AIT-CT1a2 — register this SDK on both the connection
|
|
138
|
+
// (options.agents) and channel-attach (params.agent) paths. Idempotent
|
|
139
|
+
// across sessions sharing one client.
|
|
140
|
+
const channelOptions = registerAgent(options.client, options.codec);
|
|
141
|
+
this._channel = options.client.channels.get(options.channelName, channelOptions);
|
|
142
|
+
this._codec = options.codec;
|
|
143
|
+
this._clientId = options.clientId;
|
|
144
|
+
this._logger = (options.logger ?? makeLogger({ logLevel: LogLevel.Silent })).withContext({
|
|
145
|
+
component: 'ClientSession',
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
this._emitter = new EventEmitter<ClientSessionEventsMap>(this._logger);
|
|
149
|
+
this._hasAttachedOnce = this._channel.state === 'attached';
|
|
150
|
+
|
|
151
|
+
// Compose sub-components
|
|
152
|
+
this._tree = createTree<TInput, TOutput, TProjection>(this._codec, this._logger);
|
|
153
|
+
this._view = createView<TInput, TOutput, TProjection, TMessage>({
|
|
154
|
+
tree: this._tree,
|
|
155
|
+
channel: this._channel,
|
|
156
|
+
codec: this._codec,
|
|
157
|
+
sendDelegate: this._internalSend.bind(this),
|
|
158
|
+
logger: this._logger,
|
|
159
|
+
onClose: () => this._views.delete(this._view),
|
|
160
|
+
});
|
|
161
|
+
this._decoder = this._codec.createDecoder();
|
|
162
|
+
this._encoder = this._codec.createEncoder(
|
|
163
|
+
this._channel,
|
|
164
|
+
this._clientId === undefined ? undefined : { clientId: this._clientId },
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
this._views.add(this._view);
|
|
168
|
+
|
|
169
|
+
// Public accessors (typed as narrow interfaces)
|
|
170
|
+
this.tree = this._tree;
|
|
171
|
+
this.view = this._view;
|
|
172
|
+
|
|
173
|
+
// Seed tree with initial messages — the session assigns a codecMessageId
|
|
174
|
+
// per seed message. Each seed becomes a run-less input node (no run-id —
|
|
175
|
+
// the client never mints one); the parent chain mirrors the original seed
|
|
176
|
+
// sequence (a user→user input chain the Tree threads kind-blind).
|
|
177
|
+
if (options.messages) {
|
|
178
|
+
let prevMsgId: string | undefined;
|
|
179
|
+
for (const msg of options.messages) {
|
|
180
|
+
const codecMessageId = crypto.randomUUID();
|
|
181
|
+
const seedHeaders: Record<string, string> = {
|
|
182
|
+
[HEADER_CODEC_MESSAGE_ID]: codecMessageId,
|
|
183
|
+
[HEADER_ROLE]: 'user',
|
|
184
|
+
};
|
|
185
|
+
if (prevMsgId) seedHeaders[HEADER_PARENT] = prevMsgId;
|
|
186
|
+
this._tree.applyMessage({ inputs: [this._codec.createUserMessage(msg)], outputs: [] }, seedHeaders);
|
|
187
|
+
prevMsgId = codecMessageId;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Spec: AIT-CT2
|
|
192
|
+
// Listener function reference — bound now so it can be unsubscribed on close.
|
|
193
|
+
this._onMessage = (ablyMessage: Ably.InboundMessage) => {
|
|
194
|
+
this._handleMessage(ablyMessage);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Listen for channel state changes that break message continuity.
|
|
198
|
+
// _hasAttachedOnce is seeded from the channel's current state so that
|
|
199
|
+
// pre-attached channels are handled correctly. It distinguishes the
|
|
200
|
+
// initial attach (expected) from a genuine discontinuity.
|
|
201
|
+
this._onChannelStateChange = (stateChange: Ably.ChannelStateChange) => {
|
|
202
|
+
this._handleChannelStateChange(stateChange);
|
|
203
|
+
};
|
|
204
|
+
this._channel.on(this._onChannelStateChange);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Public connection API
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
// Spec: AIT-CT2
|
|
212
|
+
// eslint-disable-next-line @typescript-eslint/promise-function-async -- preserve reference equality across calls
|
|
213
|
+
connect(): Promise<void> {
|
|
214
|
+
if (this._state === ClientSessionState.CLOSED) {
|
|
215
|
+
return Promise.reject(new Ably.ErrorInfo('unable to connect; session is closed', ErrorCode.SessionClosed, 400));
|
|
216
|
+
}
|
|
217
|
+
if (this._connectPromise) return this._connectPromise;
|
|
218
|
+
|
|
219
|
+
this._logger.trace('DefaultClientSession.connect();');
|
|
220
|
+
// Subscribe before attach (RTL7g) — subscribe implicitly attaches the channel.
|
|
221
|
+
this._connectPromise = this._channel.subscribe(this._onMessage).then(
|
|
222
|
+
() => {
|
|
223
|
+
this._logger.debug('DefaultClientSession.connect(); subscribed and attached');
|
|
224
|
+
},
|
|
225
|
+
(error: unknown) => {
|
|
226
|
+
const errInfo = new Ably.ErrorInfo(
|
|
227
|
+
`unable to subscribe to channel; ${error instanceof Error ? error.message : String(error)}`,
|
|
228
|
+
ErrorCode.SessionSubscriptionError,
|
|
229
|
+
500,
|
|
230
|
+
error instanceof Ably.ErrorInfo ? error : undefined,
|
|
231
|
+
);
|
|
232
|
+
this._logger.error('DefaultClientSession.connect(); subscribe failed');
|
|
233
|
+
this._emitter.emit('error', errInfo);
|
|
234
|
+
throw errInfo;
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
return this._connectPromise;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async _requireConnected(method: string): Promise<void> {
|
|
241
|
+
if (!this._connectPromise) {
|
|
242
|
+
throw new Ably.ErrorInfo(
|
|
243
|
+
`unable to ${method}; connect() must be called before ${method}()`,
|
|
244
|
+
ErrorCode.InvalidArgument,
|
|
245
|
+
400,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return this._connectPromise;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Message subscription handler
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
private _handleMessage(ablyMessage: Ably.InboundMessage): void {
|
|
256
|
+
if (this._state === ClientSessionState.CLOSED) return;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
// Spec: AIT-CT16a
|
|
260
|
+
// Live-only: surface an agent error carried on a run-end BEFORE applying
|
|
261
|
+
// it, preserving the original 'error'-before-tree-'run' emit ordering.
|
|
262
|
+
// Consumers that expose a per-run stream (e.g. the Vercel ChatTransport)
|
|
263
|
+
// error their stream off this event. The agent only publishes run-end
|
|
264
|
+
// after run-start, so no pending-run-start tracker is outstanding.
|
|
265
|
+
if (ablyMessage.name === EVENT_RUN_END) {
|
|
266
|
+
const headers = getTransportHeaders(ablyMessage);
|
|
267
|
+
// CAST: agent always writes a valid RunEndReason; default to 'complete' for robustness
|
|
268
|
+
const reason = (headers[HEADER_RUN_REASON] ?? 'complete') as RunEndReason;
|
|
269
|
+
if (reason === 'error') {
|
|
270
|
+
const codeRaw = headers[HEADER_ERROR_CODE];
|
|
271
|
+
const parsedCode = codeRaw === undefined ? Number.NaN : Number(codeRaw);
|
|
272
|
+
const code = Number.isFinite(parsedCode) ? parsedCode : ErrorCode.SessionSubscriptionError;
|
|
273
|
+
const message = headers[HEADER_ERROR_MESSAGE] ?? 'agent reported an error';
|
|
274
|
+
const statusCode = code >= 10000 && code < 60000 ? Math.floor(code / 100) : 500;
|
|
275
|
+
const errInfo = new Ably.ErrorInfo(message, code, statusCode);
|
|
276
|
+
this._logger.error('ClientSession._handleMessage(); agent error received', {
|
|
277
|
+
runId: headers[HEADER_RUN_ID],
|
|
278
|
+
invocationId: headers[HEADER_INVOCATION_ID],
|
|
279
|
+
code,
|
|
280
|
+
});
|
|
281
|
+
this._emitter.emit('error', errInfo);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Reconstruct the tree via the shared decode-fold engine — the same path
|
|
286
|
+
// the View's history replay uses, so the live loop can't drift from it.
|
|
287
|
+
const event = applyWireMessage(this._tree, this._decoder, ablyMessage);
|
|
288
|
+
|
|
289
|
+
// Live-only: resolve the pending `runId` promise on a fresh run-start or
|
|
290
|
+
// a continuation run-resume. Key by the echoed `input-codec-message-id`
|
|
291
|
+
// — the mirror of the arming key on `_pendingRunStarts` (see that
|
|
292
|
+
// field's JSDoc). Every send carries at least one input, so the agent
|
|
293
|
+
// always echoes it.
|
|
294
|
+
if (event && (event.type === 'start' || event.type === 'resume')) {
|
|
295
|
+
const startedKey = getTransportHeaders(ablyMessage)[HEADER_INPUT_CODEC_MESSAGE_ID];
|
|
296
|
+
if (startedKey !== undefined) {
|
|
297
|
+
const pending = this._pendingRunStarts.get(startedKey);
|
|
298
|
+
if (pending) {
|
|
299
|
+
this._pendingRunStarts.delete(startedKey);
|
|
300
|
+
// Resolve the run handle's `runId` promise with the agent-minted id.
|
|
301
|
+
pending.resolve(event.runId);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Emit ably-message AFTER the apply so View subscribers can find the
|
|
307
|
+
// owning node in `_lastVisibleNodeKeySet` (keyed by run-id for reply runs
|
|
308
|
+
// and codec-message-id for inputs), which is refreshed by the tree
|
|
309
|
+
// 'update' events the apply triggers.
|
|
310
|
+
this._tree.emitAblyMessage(ablyMessage);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
const cause = error instanceof Ably.ErrorInfo ? error : undefined;
|
|
313
|
+
this._emitter.emit(
|
|
314
|
+
'error',
|
|
315
|
+
new Ably.ErrorInfo(
|
|
316
|
+
`unable to process channel message; ${error instanceof Error ? error.message : String(error)}`,
|
|
317
|
+
ErrorCode.SessionSubscriptionError,
|
|
318
|
+
500,
|
|
319
|
+
cause,
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Channel state change handler
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
// Spec: AIT-CT19, AIT-CT19a
|
|
330
|
+
private _handleChannelStateChange(stateChange: Ably.ChannelStateChange): void {
|
|
331
|
+
if (this._state === ClientSessionState.CLOSED) return;
|
|
332
|
+
|
|
333
|
+
const { current, resumed } = stateChange;
|
|
334
|
+
|
|
335
|
+
// Track the initial attach so we don't treat it as a discontinuity
|
|
336
|
+
if (current === 'attached' && !this._hasAttachedOnce) {
|
|
337
|
+
this._hasAttachedOnce = true;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Continuity-breaking states:
|
|
342
|
+
// - FAILED, SUSPENDED, DETACHED: no more messages expected (or gap)
|
|
343
|
+
// - ATTACHED with resumed: false (UPDATE): messages were lost
|
|
344
|
+
const continuityLost =
|
|
345
|
+
current === 'failed' || current === 'suspended' || current === 'detached' || (current === 'attached' && !resumed);
|
|
346
|
+
|
|
347
|
+
if (!continuityLost) return;
|
|
348
|
+
|
|
349
|
+
this._logger.error('ClientSession._handleChannelStateChange(); channel continuity lost', {
|
|
350
|
+
current,
|
|
351
|
+
resumed,
|
|
352
|
+
previous: stateChange.previous,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const err = new Ably.ErrorInfo(
|
|
356
|
+
`unable to deliver events; channel continuity lost (${current}${current === 'attached' ? ', resumed: false' : ''})`,
|
|
357
|
+
ErrorCode.ChannelContinuityLost,
|
|
358
|
+
500,
|
|
359
|
+
stateChange.reason,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
// Surface the loss via the session `error` event. Consumers that expose a
|
|
363
|
+
// per-run stream (e.g. the Vercel ChatTransport) error their stream off
|
|
364
|
+
// this event; observer-run state lives entirely in the Tree's projection
|
|
365
|
+
// and stays consistent regardless of continuity loss.
|
|
366
|
+
this._emitter.emit('error', err);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Cancel helpers
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Tear down local state for a send whose channel publish failed.
|
|
375
|
+
* Idempotent.
|
|
376
|
+
* @param codecMessageIds - The codec-message-ids of the failed send's
|
|
377
|
+
* optimistic input nodes (the client mints no run-id, so the optimistic
|
|
378
|
+
* inserts are keyed by their codec-message-ids).
|
|
379
|
+
*/
|
|
380
|
+
private _cleanupFailedSend(codecMessageIds: string[]): void {
|
|
381
|
+
for (const codecMessageId of codecMessageIds) {
|
|
382
|
+
// Drop the optimistic input node only if the publish never produced a
|
|
383
|
+
// server-assigned serial (i.e. nothing live observed it). A server-acked
|
|
384
|
+
// node is part of the canonical channel state and must stay; the View /
|
|
385
|
+
// observers already see it. A fresh send's optimistic inserts are input
|
|
386
|
+
// nodes (keyed by codec-message-id).
|
|
387
|
+
const node = this._tree.getNodeByCodecMessageId(codecMessageId);
|
|
388
|
+
if (node?.kind === 'input' && node.serial === undefined) {
|
|
389
|
+
// An input node's key is its codec-message-id, so delete by it directly.
|
|
390
|
+
this._tree.delete(node.codecMessageId);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Public API
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
// Spec: AIT-CT10b
|
|
400
|
+
createView(): View<TInput, TMessage> {
|
|
401
|
+
if (this._state === ClientSessionState.CLOSED) {
|
|
402
|
+
throw new Ably.ErrorInfo('unable to create view; session is closed', ErrorCode.SessionClosed, 400);
|
|
403
|
+
}
|
|
404
|
+
this._logger.trace('DefaultClientSession.createView();');
|
|
405
|
+
const view = createView<TInput, TOutput, TProjection, TMessage>({
|
|
406
|
+
tree: this._tree,
|
|
407
|
+
channel: this._channel,
|
|
408
|
+
codec: this._codec,
|
|
409
|
+
sendDelegate: this._internalSend.bind(this),
|
|
410
|
+
logger: this._logger,
|
|
411
|
+
onClose: () => this._views.delete(view),
|
|
412
|
+
});
|
|
413
|
+
this._views.add(view);
|
|
414
|
+
return view;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Spec: AIT-CT3, AIT-CT4
|
|
418
|
+
private async _internalSend(
|
|
419
|
+
input: TInput[],
|
|
420
|
+
sendOptions: SendOptions | undefined,
|
|
421
|
+
parentCodecMessageId: string | undefined,
|
|
422
|
+
): Promise<ActiveRun> {
|
|
423
|
+
if (this._state === ClientSessionState.CLOSED) {
|
|
424
|
+
throw new Ably.ErrorInfo('unable to send; session is closed', ErrorCode.SessionClosed, 400);
|
|
425
|
+
}
|
|
426
|
+
await this._requireConnected('send');
|
|
427
|
+
// CAST: re-check after await — close() may have been called while waiting for connect.
|
|
428
|
+
// TypeScript's control flow narrows _state after the first check, but the
|
|
429
|
+
// await yields and close() can mutate _state concurrently.
|
|
430
|
+
if ((this._state as ClientSessionState) === ClientSessionState.CLOSED) {
|
|
431
|
+
throw new Ably.ErrorInfo('unable to send; session is closed', ErrorCode.SessionClosed, 400);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Spec: AIT-CT20
|
|
435
|
+
const state = this._channel.state;
|
|
436
|
+
if (state !== 'attached' && state !== 'attaching') {
|
|
437
|
+
throw new Ably.ErrorInfo(`unable to send; channel is ${state}`, ErrorCode.ChannelNotReady, 400);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
this._logger.trace('ClientSession._internalSend();');
|
|
441
|
+
|
|
442
|
+
const isContinuation = sendOptions?.runId !== undefined;
|
|
443
|
+
|
|
444
|
+
// The agent mints run-ids, not the client. A fresh send carries no run-id
|
|
445
|
+
// (the agent mints it and echoes it on run-start); only a continuation
|
|
446
|
+
// reuses the existing run-id the caller passed.
|
|
447
|
+
const runId = sendOptions?.runId;
|
|
448
|
+
|
|
449
|
+
// Spec: AIT-CT3d
|
|
450
|
+
// Auto-compute parent from the visible branch tail when not explicitly
|
|
451
|
+
// provided. The View pre-resolves the codec-message-id of the last visible message
|
|
452
|
+
// since the session is codec-agnostic and can't extract it from TMessage.
|
|
453
|
+
let autoParent: string | undefined;
|
|
454
|
+
if (sendOptions?.parent === undefined && !sendOptions?.forkOf) {
|
|
455
|
+
autoParent = parentCodecMessageId;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const codecMessageIds = new Set<string>();
|
|
459
|
+
interface ItemState {
|
|
460
|
+
input: TInput;
|
|
461
|
+
codecMessageId: string;
|
|
462
|
+
inputEventId: string;
|
|
463
|
+
headers: Record<string, string>;
|
|
464
|
+
/** Inputs that reference an existing codec-message without contributing fresh local content (regenerate, tool resolutions) are wire-only — no optimistic projection fold. Fresh user-messages always fold, even when they pin their own codecMessageId. */
|
|
465
|
+
isWireOnly: boolean;
|
|
466
|
+
}
|
|
467
|
+
const items: ItemState[] = [];
|
|
468
|
+
|
|
469
|
+
// Per-input wire prep: read routing fields off the input directly, then
|
|
470
|
+
// mint per-event ids and build transport headers. Regenerate inputs are
|
|
471
|
+
// wire-only (no optimistic fold); other inputs fold into the projection
|
|
472
|
+
// optimistically.
|
|
473
|
+
for (const entry of input) {
|
|
474
|
+
const inputEventId = crypto.randomUUID();
|
|
475
|
+
// Use the input's `codecMessageId` when set (e.g. tool resolution
|
|
476
|
+
// targeting the prior assistant); otherwise mint a fresh id.
|
|
477
|
+
const codecMessageId = entry.codecMessageId ?? crypto.randomUUID();
|
|
478
|
+
codecMessageIds.add(codecMessageId);
|
|
479
|
+
|
|
480
|
+
// Inputs that reference an existing message (regenerate, tool
|
|
481
|
+
// resolutions targeting an assistant) are wire-only — no optimistic
|
|
482
|
+
// fold needed because either the receiving content doesn't
|
|
483
|
+
// materialise on this side (regenerate) or the target already exists
|
|
484
|
+
// and will be amended when the wire echoes back.
|
|
485
|
+
//
|
|
486
|
+
// A fresh `user-message` is never wire-only, even on the rare path
|
|
487
|
+
// where it carries an explicit `codecMessageId`: it is new content that
|
|
488
|
+
// must fold into the local projection immediately. Excluding it here
|
|
489
|
+
// keeps the optimistic user bubble from depending on the channel
|
|
490
|
+
// round-trip. (The session mints the codec-message-id for fresh user
|
|
491
|
+
// messages; the caller's `message.id` is preserved but never used as
|
|
492
|
+
// the correlation key.)
|
|
493
|
+
const isWireOnly =
|
|
494
|
+
entry.kind !== 'user-message' && (entry.kind === 'regenerate' || entry.codecMessageId !== undefined);
|
|
495
|
+
|
|
496
|
+
// The input's own routing fields override the auto-parent /
|
|
497
|
+
// sendOptions defaults. For regenerate inputs, `target` becomes the
|
|
498
|
+
// `msg-regenerate` wire header. The fork anchor comes from
|
|
499
|
+
// `sendOptions.forkOf` (set by `View.edit`). The transport reads
|
|
500
|
+
// these directly without runtime classification.
|
|
501
|
+
const parent = entry.parent ?? (sendOptions?.parent === undefined ? autoParent : sendOptions.parent);
|
|
502
|
+
const forkOf = sendOptions?.forkOf;
|
|
503
|
+
const regenerates = entry.kind === 'regenerate' ? entry.target : undefined;
|
|
504
|
+
|
|
505
|
+
const headers = buildTransportHeaders({
|
|
506
|
+
role: 'user',
|
|
507
|
+
runId,
|
|
508
|
+
codecMessageId,
|
|
509
|
+
runClientId: this._clientId,
|
|
510
|
+
...(parent !== undefined && { parent }),
|
|
511
|
+
...(forkOf !== undefined && { forkOf }),
|
|
512
|
+
...(regenerates !== undefined && { regenerates }),
|
|
513
|
+
inputEventId,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// Spec: AIT-CT3c — optimistic fold for non-wire-only inputs.
|
|
517
|
+
if (!isWireOnly) {
|
|
518
|
+
this._tree.applyMessage({ inputs: [entry], outputs: [] }, headers);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
items.push({ input: entry, codecMessageId, inputEventId, headers, isWireOnly });
|
|
522
|
+
|
|
523
|
+
// Spec: AIT-CT3e — chain subsequent inputs off the previous one when
|
|
524
|
+
// auto-parenting is in effect.
|
|
525
|
+
if (!isWireOnly && sendOptions?.parent === undefined && !sendOptions?.forkOf && entry.parent === undefined) {
|
|
526
|
+
autoParent = codecMessageId;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// The trigger event is the last input — the one the agent looks up on the
|
|
531
|
+
// channel via `event-id`, surfaced on `ActiveRun` (and via `toInvocation()`)
|
|
532
|
+
// so the application can point an invocation at it. Its codec-message-id is
|
|
533
|
+
// the handle the client owns at send time; the agent echoes it back on
|
|
534
|
+
// run-start as `input-codec-message-id`, and it keys the run-start tracker.
|
|
535
|
+
const triggerItem = items.at(-1);
|
|
536
|
+
if (triggerItem === undefined) {
|
|
537
|
+
// Every send must carry at least one input — only new input starts or
|
|
538
|
+
// continues a run. The loop above produced no items, so nothing was
|
|
539
|
+
// published or folded optimistically.
|
|
540
|
+
throw new Ably.ErrorInfo(
|
|
541
|
+
'unable to send; inputs array is empty (include at least one input)',
|
|
542
|
+
ErrorCode.InvalidArgument,
|
|
543
|
+
400,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
const triggerInputEventId = triggerItem.inputEventId;
|
|
547
|
+
const startedKey = triggerItem.codecMessageId;
|
|
548
|
+
|
|
549
|
+
// Arm the run-start tracker backing the returned `ActiveRun.runId` promise.
|
|
550
|
+
// The run-start handler resolves it with the agent-minted run-id when this
|
|
551
|
+
// send's `ai-run-start` is observed; close() rejects it on teardown. No
|
|
552
|
+
// deadline — `send()` resolves on publish; callers bound the wait by racing
|
|
553
|
+
// `run.runId` against their own timeout.
|
|
554
|
+
//
|
|
555
|
+
// Key on the arming side mirrors the resolve side — see `_pendingRunStarts`
|
|
556
|
+
// for the full keying invariant. The executor runs synchronously, so the
|
|
557
|
+
// tracker entry is registered before `new Promise` returns.
|
|
558
|
+
const runIdPromise = new Promise<string>((resolve, reject) => {
|
|
559
|
+
this._pendingRunStarts.set(startedKey, { resolve, reject });
|
|
560
|
+
});
|
|
561
|
+
// Suppress unhandled-rejection warnings for callers that never await
|
|
562
|
+
// `run.runId`; the caller still observes the rejection if it does await.
|
|
563
|
+
runIdPromise.catch(() => {
|
|
564
|
+
/* observed via run.runId, if at all */
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// Publish each input in original order via the shared encoder. The
|
|
568
|
+
// codec routes user-message inputs into a per-part discrete batch and
|
|
569
|
+
// tool-resolution / regenerate inputs into a single discrete write —
|
|
570
|
+
// all on the `ai-input` wire.
|
|
571
|
+
const publishPromise = (async () => {
|
|
572
|
+
try {
|
|
573
|
+
for (const item of items) {
|
|
574
|
+
await this._encoder.publishInput(item.input, {
|
|
575
|
+
extras: { headers: item.headers },
|
|
576
|
+
messageId: item.codecMessageId,
|
|
577
|
+
...(this._clientId !== undefined && { clientId: this._clientId }),
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
} catch (error) {
|
|
581
|
+
const cause = error instanceof Ably.ErrorInfo ? error : undefined;
|
|
582
|
+
const isPermission = cause?.statusCode === 401 || cause?.statusCode === 403;
|
|
583
|
+
const err = new Ably.ErrorInfo(
|
|
584
|
+
isPermission
|
|
585
|
+
? `unable to publish events; missing publish capability on the channel`
|
|
586
|
+
: `unable to publish events; ${error instanceof Error ? error.message : String(error)}`,
|
|
587
|
+
isPermission ? ErrorCode.InsufficientCapability : ErrorCode.SessionSendFailed,
|
|
588
|
+
isPermission ? 401 : 500,
|
|
589
|
+
cause,
|
|
590
|
+
);
|
|
591
|
+
this._emitter.emit('error', err);
|
|
592
|
+
// The input never reached the channel — there is no run to wait on.
|
|
593
|
+
// Drop the run-start tracker so close() doesn't later reject an orphan.
|
|
594
|
+
this._pendingRunStarts.delete(startedKey);
|
|
595
|
+
// Continuations didn't insert optimistic nodes, so there is nothing to
|
|
596
|
+
// clear for them — only a fresh send's optimistic input nodes need
|
|
597
|
+
// removing, keyed by their codec-message-ids (the client mints no runId).
|
|
598
|
+
if (!isContinuation) this._cleanupFailedSend([...codecMessageIds]);
|
|
599
|
+
throw err;
|
|
600
|
+
}
|
|
601
|
+
})();
|
|
602
|
+
|
|
603
|
+
// `send()` resolves once the input is published. The core never sends
|
|
604
|
+
// HTTP — waking an agent is the application's concern. Callers POST
|
|
605
|
+
// `run.toInvocation().toJSON()` to their endpoint if they want one woken,
|
|
606
|
+
// and await `run.runId` if they need to know it was picked up.
|
|
607
|
+
await publishPromise;
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
inputCodecMessageId: startedKey,
|
|
611
|
+
runId: runIdPromise,
|
|
612
|
+
inputEventId: triggerInputEventId,
|
|
613
|
+
// The agent mints the run-id, so a fresh run has none until run-start.
|
|
614
|
+
// Cancel synchronously by the triggering input's codec-message-id (the
|
|
615
|
+
// handle the client owns at send time, = `inputCodecMessageId`): the
|
|
616
|
+
// agent resolves it to the run once its input-event lookup completes, and
|
|
617
|
+
// buffers a cancel that arrives before then so an early cancel is honoured
|
|
618
|
+
// rather than dropped. A continuation additionally carries its known
|
|
619
|
+
// run-id so the agent can match the run directly.
|
|
620
|
+
cancel: async () => {
|
|
621
|
+
await this._publishCancel({
|
|
622
|
+
inputCodecMessageId: startedKey,
|
|
623
|
+
...(runId !== undefined && { runId }),
|
|
624
|
+
});
|
|
625
|
+
},
|
|
626
|
+
optimisticCodecMessageIds: [...codecMessageIds],
|
|
627
|
+
toInvocation: () =>
|
|
628
|
+
// The invocation body carries no run-id: run identity lives on the
|
|
629
|
+
// channel (the agent mints a fresh run-id, or reads a continuation's
|
|
630
|
+
// from the triggering input event, which carries the reused run-id).
|
|
631
|
+
Invocation.fromJSON({
|
|
632
|
+
inputEventId: triggerInputEventId,
|
|
633
|
+
sessionName: this._channel.name,
|
|
634
|
+
}),
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Spec: AIT-CT7, AIT-CT7a
|
|
639
|
+
async cancel(runId: string): Promise<void> {
|
|
640
|
+
return this._publishCancel({ runId });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Publish an `ai-cancel` signal. The agent resolves the target run by
|
|
645
|
+
* whichever identifier is present:
|
|
646
|
+
*
|
|
647
|
+
* - `runId` — a continuation, whose run-id the caller already knows.
|
|
648
|
+
* - `inputCodecMessageId` — a fresh send, whose run-id the agent mints at
|
|
649
|
+
* run-start. The client can only key the cancel by the triggering input's
|
|
650
|
+
* codec-message-id (the `ActiveRun.inputCodecMessageId`) it owns at send
|
|
651
|
+
* time; the agent resolves it to the run once its input-event lookup
|
|
652
|
+
* completes, buffering a cancel that arrives before then.
|
|
653
|
+
*
|
|
654
|
+
* Both may be present (a continuation knows its run-id AND published an
|
|
655
|
+
* input). An `event-id` is always stamped so channel rewind redelivers the
|
|
656
|
+
* cancel to a per-request / serverless agent that attaches after it was
|
|
657
|
+
* published.
|
|
658
|
+
*
|
|
659
|
+
* Publishing the cancel signal is all the core does. The consumer-facing
|
|
660
|
+
* stream (if any) lives in the layer that built it — e.g. the Vercel
|
|
661
|
+
* ChatTransport closes its stream on cancel — and the Tree's RunNode is left
|
|
662
|
+
* intact so late agent events (a cancel append, a trailing
|
|
663
|
+
* `status: cancelled`) still fold into the Run's projection.
|
|
664
|
+
* @param target - The run identifier(s) to cancel. At least one of `runId` /
|
|
665
|
+
* `inputCodecMessageId` must be set.
|
|
666
|
+
* @param target.runId - The run-id to cancel (continuations).
|
|
667
|
+
* @param target.inputCodecMessageId - The triggering input's
|
|
668
|
+
* codec-message-id to cancel (fresh sends, before run-start).
|
|
669
|
+
*/
|
|
670
|
+
private async _publishCancel(target: { runId?: string; inputCodecMessageId?: string }): Promise<void> {
|
|
671
|
+
if (this._state === ClientSessionState.CLOSED) return;
|
|
672
|
+
await this._requireConnected('cancel');
|
|
673
|
+
// CAST: re-check after await — close() may have been called while waiting for connect.
|
|
674
|
+
if ((this._state as ClientSessionState) === ClientSessionState.CLOSED) return;
|
|
675
|
+
this._logger.debug('ClientSession._publishCancel();', {
|
|
676
|
+
runId: target.runId,
|
|
677
|
+
inputCodecMessageId: target.inputCodecMessageId,
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
const headers: Record<string, string> = {
|
|
681
|
+
// Stamp a per-cancel event-id so channel rewind redelivers this cancel
|
|
682
|
+
// to an agent that attaches after it was published.
|
|
683
|
+
[HEADER_EVENT_ID]: crypto.randomUUID(),
|
|
684
|
+
};
|
|
685
|
+
if (target.runId !== undefined) headers[HEADER_RUN_ID] = target.runId;
|
|
686
|
+
if (target.inputCodecMessageId !== undefined) headers[HEADER_INPUT_CODEC_MESSAGE_ID] = target.inputCodecMessageId;
|
|
687
|
+
|
|
688
|
+
await this._channel.publish({
|
|
689
|
+
name: EVENT_CANCEL,
|
|
690
|
+
extras: { ai: { transport: headers } },
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Spec: AIT-CT8, AIT-CT8c, AIT-CT8d
|
|
695
|
+
on(event: 'error', handler: (error: Ably.ErrorInfo) => void): () => void {
|
|
696
|
+
if (this._state === ClientSessionState.CLOSED) return noopUnsubscribe;
|
|
697
|
+
// CAST: the overload signature enforces the correct handler type.
|
|
698
|
+
const cb = handler;
|
|
699
|
+
this._emitter.on(event, cb);
|
|
700
|
+
return () => {
|
|
701
|
+
this._emitter.off(event, cb);
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Spec: AIT-CT12, AIT-CT12b, AIT-CT10c
|
|
706
|
+
async close(): Promise<void> {
|
|
707
|
+
if (this._state === ClientSessionState.CLOSED) return;
|
|
708
|
+
this._state = ClientSessionState.CLOSED;
|
|
709
|
+
this._logger.info('ClientSession.close();');
|
|
710
|
+
|
|
711
|
+
if (this._connectPromise) {
|
|
712
|
+
this._channel.unsubscribe(this._onMessage);
|
|
713
|
+
}
|
|
714
|
+
this._channel.off(this._onChannelStateChange);
|
|
715
|
+
|
|
716
|
+
this._emitter.off();
|
|
717
|
+
for (const v of this._views) v.close();
|
|
718
|
+
this._views.clear();
|
|
719
|
+
// Reject any in-flight `run.runId` promises so callers awaiting run-start
|
|
720
|
+
// settle rather than hang.
|
|
721
|
+
if (this._pendingRunStarts.size > 0) {
|
|
722
|
+
const closedErr = new Ably.ErrorInfo('unable to await run-start; session closed', ErrorCode.SessionClosed, 400);
|
|
723
|
+
for (const pending of this._pendingRunStarts.values()) {
|
|
724
|
+
pending.reject(closedErr);
|
|
725
|
+
}
|
|
726
|
+
this._pendingRunStarts.clear();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Best-effort encoder close — flushes any pending stream operations.
|
|
730
|
+
// The client only uses the discrete input path (publishInput), so this is
|
|
731
|
+
// typically a no-op, but it releases any internal resources cleanly.
|
|
732
|
+
try {
|
|
733
|
+
await this._encoder.close();
|
|
734
|
+
} catch {
|
|
735
|
+
// Swallow: encoder close is best-effort during teardown
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Detach the channel this session attached. connect() subscribes (which
|
|
739
|
+
// implicitly attaches), so we only detach when connect() ran. Best-effort:
|
|
740
|
+
// a detach failure (e.g. the channel is already FAILED) must not throw out
|
|
741
|
+
// of close().
|
|
742
|
+
if (this._connectPromise) {
|
|
743
|
+
try {
|
|
744
|
+
await this._channel.detach();
|
|
745
|
+
} catch (error) {
|
|
746
|
+
// Swallowed (see above): a detach failure must not throw out of
|
|
747
|
+
// close(). Logged at debug for observability.
|
|
748
|
+
this._logger.debug('ClientSession.close(); channel detach failed', { error });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ---------------------------------------------------------------------------
|
|
755
|
+
// Factory
|
|
756
|
+
// ---------------------------------------------------------------------------
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Create a client-side session that manages conversation state over an Ably channel.
|
|
760
|
+
*
|
|
761
|
+
* The caller owns the client's lifecycle; the session owns its channel.
|
|
762
|
+
* The session is created in a not-yet-connected state — callers must
|
|
763
|
+
* `await session.connect()` before `send`, `regenerate`, `edit`, `update`,
|
|
764
|
+
* or `cancel`.
|
|
765
|
+
* @param options - Configuration for the client session.
|
|
766
|
+
* @returns A new {@link ClientSession} instance.
|
|
767
|
+
*/
|
|
768
|
+
export const createClientSession = <
|
|
769
|
+
TInput extends CodecInputEvent,
|
|
770
|
+
TOutput extends CodecOutputEvent,
|
|
771
|
+
TProjection,
|
|
772
|
+
TMessage,
|
|
773
|
+
>(
|
|
774
|
+
options: ClientSessionOptions<TInput, TOutput, TProjection, TMessage>,
|
|
775
|
+
): ClientSession<TInput, TOutput, TProjection, TMessage> => new DefaultClientSession(options);
|