@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.
Files changed (221) hide show
  1. package/README.md +93 -111
  2. package/dist/ably-ai-transport.js +2401 -1387
  3. package/dist/ably-ai-transport.js.map +1 -1
  4. package/dist/ably-ai-transport.umd.cjs +1 -1
  5. package/dist/ably-ai-transport.umd.cjs.map +1 -1
  6. package/dist/constants.d.ts +116 -42
  7. package/dist/core/agent.d.ts +44 -0
  8. package/dist/core/channel-options.d.ts +57 -0
  9. package/dist/core/codec/codec-event.d.ts +9 -0
  10. package/dist/core/codec/decoder.d.ts +24 -24
  11. package/dist/core/codec/define-codec.d.ts +100 -0
  12. package/dist/core/codec/encoder.d.ts +10 -12
  13. package/dist/core/codec/field-bag.d.ts +85 -0
  14. package/dist/core/codec/fields.d.ts +141 -0
  15. package/dist/core/codec/index.d.ts +8 -2
  16. package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
  17. package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
  18. package/dist/core/codec/input-descriptors.d.ts +281 -0
  19. package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
  20. package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
  21. package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
  22. package/dist/core/codec/output-descriptors.d.ts +237 -0
  23. package/dist/core/codec/types.d.ts +470 -119
  24. package/dist/core/codec/well-known-inputs.d.ts +52 -0
  25. package/dist/core/transport/agent-session.d.ts +10 -0
  26. package/dist/core/transport/agent-view.d.ts +296 -0
  27. package/dist/core/transport/client-session.d.ts +13 -0
  28. package/dist/core/transport/decode-fold.d.ts +55 -0
  29. package/dist/core/transport/headers.d.ts +121 -14
  30. package/dist/core/transport/index.d.ts +5 -6
  31. package/dist/core/transport/internal/bounded-map.d.ts +20 -0
  32. package/dist/core/transport/invocation.d.ts +74 -0
  33. package/dist/core/transport/load-history-pages.d.ts +71 -0
  34. package/dist/core/transport/load-history.d.ts +44 -0
  35. package/dist/core/transport/pipe-stream.d.ts +9 -9
  36. package/dist/core/transport/run-manager.d.ts +76 -0
  37. package/dist/core/transport/session-support.d.ts +55 -0
  38. package/dist/core/transport/tree.d.ts +523 -109
  39. package/dist/core/transport/types/agent.d.ts +375 -0
  40. package/dist/core/transport/types/client.d.ts +201 -0
  41. package/dist/core/transport/types/shared.d.ts +24 -0
  42. package/dist/core/transport/types/tree.d.ts +357 -0
  43. package/dist/core/transport/types/view.d.ts +249 -0
  44. package/dist/core/transport/types.d.ts +13 -553
  45. package/dist/core/transport/view.d.ts +390 -84
  46. package/dist/core/transport/wire-log.d.ts +102 -0
  47. package/dist/errors.d.ts +27 -10
  48. package/dist/index.d.ts +8 -9
  49. package/dist/logger.d.ts +12 -0
  50. package/dist/react/ably-ai-transport-react.js +1365 -1010
  51. package/dist/react/ably-ai-transport-react.js.map +1 -1
  52. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  53. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  54. package/dist/react/contexts/client-session-context.d.ts +37 -0
  55. package/dist/react/contexts/client-session-provider.d.ts +56 -0
  56. package/dist/react/create-session-hooks.d.ts +116 -0
  57. package/dist/react/index.d.ts +13 -12
  58. package/dist/react/internal/skipped-session.d.ts +8 -0
  59. package/dist/react/internal/use-resolved-session.d.ts +36 -0
  60. package/dist/react/use-ably-messages.d.ts +17 -14
  61. package/dist/react/use-client-session.d.ts +81 -0
  62. package/dist/react/use-create-view.d.ts +14 -13
  63. package/dist/react/use-tree.d.ts +30 -15
  64. package/dist/react/use-view.d.ts +81 -50
  65. package/dist/utils.d.ts +48 -71
  66. package/dist/vercel/ably-ai-transport-vercel.js +3257 -2499
  67. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  68. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  69. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  70. package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
  71. package/dist/vercel/codec/events.d.ts +50 -0
  72. package/dist/vercel/codec/fields.d.ts +44 -0
  73. package/dist/vercel/codec/fold-content.d.ts +16 -0
  74. package/dist/vercel/codec/fold-data.d.ts +16 -0
  75. package/dist/vercel/codec/fold-input.d.ts +67 -0
  76. package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
  77. package/dist/vercel/codec/fold-text.d.ts +16 -0
  78. package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
  79. package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
  80. package/dist/vercel/codec/index.d.ts +7 -20
  81. package/dist/vercel/codec/inputs.d.ts +11 -0
  82. package/dist/vercel/codec/outputs.d.ts +11 -0
  83. package/dist/vercel/codec/reducer-state.d.ts +121 -0
  84. package/dist/vercel/codec/reducer.d.ts +62 -0
  85. package/dist/vercel/codec/tool-transitions.d.ts +2 -8
  86. package/dist/vercel/codec/wire-data.d.ts +34 -0
  87. package/dist/vercel/index.d.ts +5 -5
  88. package/dist/vercel/react/ably-ai-transport-vercel-react.js +2859 -9705
  89. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  90. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -45
  91. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  92. package/dist/vercel/react/contexts/chat-transport-context.d.ts +9 -7
  93. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
  94. package/dist/vercel/react/index.d.ts +1 -2
  95. package/dist/vercel/react/use-chat-transport.d.ts +30 -26
  96. package/dist/vercel/react/use-message-sync.d.ts +17 -30
  97. package/dist/vercel/run-end-reason.d.ts +84 -0
  98. package/dist/vercel/tool-part.d.ts +21 -0
  99. package/dist/vercel/transport/chat-transport.d.ts +41 -24
  100. package/dist/vercel/transport/index.d.ts +24 -20
  101. package/dist/vercel/transport/run-output-stream.d.ts +54 -0
  102. package/dist/version.d.ts +2 -0
  103. package/package.json +31 -24
  104. package/src/constants.ts +124 -51
  105. package/src/core/agent.ts +92 -0
  106. package/src/core/channel-options.ts +89 -0
  107. package/src/core/codec/codec-event.ts +27 -0
  108. package/src/core/codec/decoder.ts +202 -105
  109. package/src/core/codec/define-codec.ts +432 -0
  110. package/src/core/codec/encoder.ts +114 -107
  111. package/src/core/codec/field-bag.ts +142 -0
  112. package/src/core/codec/fields.ts +193 -0
  113. package/src/core/codec/index.ts +56 -6
  114. package/src/core/codec/input-descriptor-decoder.ts +97 -0
  115. package/src/core/codec/input-descriptor-encoder.ts +150 -0
  116. package/src/core/codec/input-descriptors.ts +373 -0
  117. package/src/core/codec/lifecycle-tracker.ts +10 -9
  118. package/src/core/codec/output-descriptor-decoder.ts +139 -0
  119. package/src/core/codec/output-descriptor-encoder.ts +101 -0
  120. package/src/core/codec/output-descriptors.ts +307 -0
  121. package/src/core/codec/types.ts +505 -126
  122. package/src/core/codec/well-known-inputs.ts +96 -0
  123. package/src/core/transport/agent-session.ts +1085 -0
  124. package/src/core/transport/agent-view.ts +738 -0
  125. package/src/core/transport/client-session.ts +780 -0
  126. package/src/core/transport/decode-fold.ts +101 -0
  127. package/src/core/transport/headers.ts +234 -22
  128. package/src/core/transport/index.ts +27 -27
  129. package/src/core/transport/internal/bounded-map.ts +27 -0
  130. package/src/core/transport/invocation.ts +98 -0
  131. package/src/core/transport/load-history-pages.ts +220 -0
  132. package/src/core/transport/load-history.ts +271 -0
  133. package/src/core/transport/pipe-stream.ts +63 -39
  134. package/src/core/transport/run-manager.ts +243 -0
  135. package/src/core/transport/session-support.ts +96 -0
  136. package/src/core/transport/tree.ts +1293 -308
  137. package/src/core/transport/types/agent.ts +434 -0
  138. package/src/core/transport/types/client.ts +247 -0
  139. package/src/core/transport/types/shared.ts +27 -0
  140. package/src/core/transport/types/tree.ts +393 -0
  141. package/src/core/transport/types/view.ts +288 -0
  142. package/src/core/transport/types.ts +13 -706
  143. package/src/core/transport/view.ts +1229 -450
  144. package/src/core/transport/wire-log.ts +189 -0
  145. package/src/errors.ts +29 -9
  146. package/src/event-emitter.ts +3 -2
  147. package/src/index.ts +86 -42
  148. package/src/logger.ts +14 -1
  149. package/src/react/contexts/client-session-context.ts +41 -0
  150. package/src/react/contexts/client-session-provider.tsx +222 -0
  151. package/src/react/create-session-hooks.ts +141 -0
  152. package/src/react/index.ts +24 -13
  153. package/src/react/internal/skipped-session.ts +62 -0
  154. package/src/react/internal/use-resolved-session.ts +63 -0
  155. package/src/react/use-ably-messages.ts +32 -22
  156. package/src/react/use-client-session.ts +178 -0
  157. package/src/react/use-create-view.ts +33 -29
  158. package/src/react/use-tree.ts +61 -30
  159. package/src/react/use-view.ts +138 -96
  160. package/src/utils.ts +83 -131
  161. package/src/vercel/codec/decode-lifecycle.ts +70 -0
  162. package/src/vercel/codec/events.ts +85 -0
  163. package/src/vercel/codec/fields.ts +58 -0
  164. package/src/vercel/codec/fold-content.ts +54 -0
  165. package/src/vercel/codec/fold-data.ts +46 -0
  166. package/src/vercel/codec/fold-input.ts +255 -0
  167. package/src/vercel/codec/fold-lifecycle.ts +85 -0
  168. package/src/vercel/codec/fold-text.ts +55 -0
  169. package/src/vercel/codec/fold-tool-input.ts +86 -0
  170. package/src/vercel/codec/fold-tool-output.ts +79 -0
  171. package/src/vercel/codec/index.ts +28 -21
  172. package/src/vercel/codec/inputs.ts +116 -0
  173. package/src/vercel/codec/outputs.ts +207 -0
  174. package/src/vercel/codec/reducer-state.ts +169 -0
  175. package/src/vercel/codec/reducer.ts +191 -0
  176. package/src/vercel/codec/tool-transitions.ts +3 -14
  177. package/src/vercel/codec/wire-data.ts +64 -0
  178. package/src/vercel/index.ts +7 -19
  179. package/src/vercel/react/contexts/chat-transport-context.ts +8 -7
  180. package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
  181. package/src/vercel/react/index.ts +3 -5
  182. package/src/vercel/react/use-chat-transport.ts +44 -66
  183. package/src/vercel/react/use-message-sync.ts +75 -39
  184. package/src/vercel/run-end-reason.ts +157 -0
  185. package/src/vercel/tool-part.ts +25 -0
  186. package/src/vercel/transport/chat-transport.ts +380 -98
  187. package/src/vercel/transport/index.ts +38 -37
  188. package/src/vercel/transport/run-output-stream.ts +169 -0
  189. package/src/version.ts +2 -0
  190. package/dist/core/transport/client-transport.d.ts +0 -10
  191. package/dist/core/transport/decode-history.d.ts +0 -43
  192. package/dist/core/transport/server-transport.d.ts +0 -7
  193. package/dist/core/transport/stream-router.d.ts +0 -29
  194. package/dist/core/transport/turn-manager.d.ts +0 -37
  195. package/dist/react/contexts/transport-context.d.ts +0 -31
  196. package/dist/react/contexts/transport-provider.d.ts +0 -49
  197. package/dist/react/create-transport-hooks.d.ts +0 -124
  198. package/dist/react/use-active-turns.d.ts +0 -12
  199. package/dist/react/use-client-transport.d.ts +0 -80
  200. package/dist/vercel/codec/accumulator.d.ts +0 -21
  201. package/dist/vercel/codec/decoder.d.ts +0 -22
  202. package/dist/vercel/codec/encoder.d.ts +0 -41
  203. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
  204. package/dist/vercel/tool-approvals.d.ts +0 -124
  205. package/dist/vercel/tool-events.d.ts +0 -26
  206. package/src/core/transport/client-transport.ts +0 -977
  207. package/src/core/transport/decode-history.ts +0 -485
  208. package/src/core/transport/server-transport.ts +0 -612
  209. package/src/core/transport/stream-router.ts +0 -136
  210. package/src/core/transport/turn-manager.ts +0 -165
  211. package/src/react/contexts/transport-context.ts +0 -37
  212. package/src/react/contexts/transport-provider.tsx +0 -164
  213. package/src/react/create-transport-hooks.ts +0 -144
  214. package/src/react/use-active-turns.ts +0 -72
  215. package/src/react/use-client-transport.ts +0 -197
  216. package/src/vercel/codec/accumulator.ts +0 -588
  217. package/src/vercel/codec/decoder.ts +0 -618
  218. package/src/vercel/codec/encoder.ts +0 -410
  219. package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
  220. package/src/vercel/tool-approvals.ts +0 -380
  221. package/src/vercel/tool-events.ts +0 -53
@@ -0,0 +1,1085 @@
1
+ /**
2
+ * Core agent (server-side) session, parameterized by codec.
3
+ *
4
+ * Composes RunManager and pipeStream to handle the full server-side run
5
+ * lifecycle. Cancel message routing is handled directly by the session's
6
+ * single channel subscription — no separate cancel manager needed.
7
+ *
8
+ * The session exposes a single factory method — `createRun()` — which returns
9
+ * a Run object with explicit lifecycle methods: start(), pipe(), suspend(),
10
+ * and end() (suspend() and end() are both terminal).
11
+ */
12
+
13
+ import * as Ably from 'ably';
14
+ // Also augments RealtimeChannel with `.object` (ably/liveobjects side-effect).
15
+ import type * as AblyObjects from 'ably/liveobjects';
16
+
17
+ import {
18
+ EVENT_CANCEL,
19
+ HEADER_CODEC_MESSAGE_ID,
20
+ HEADER_FORK_OF,
21
+ HEADER_INPUT_CODEC_MESSAGE_ID,
22
+ HEADER_MSG_REGENERATE,
23
+ HEADER_PARENT,
24
+ HEADER_RUN_CLIENT_ID,
25
+ HEADER_RUN_ID,
26
+ } from '../../constants.js';
27
+ import { ErrorCode } from '../../errors.js';
28
+ import { type Logger, LogLevel, makeLogger } from '../../logger.js';
29
+ import { errorCause, errorMessage, getTransportHeaders } from '../../utils.js';
30
+ import { registerAgent } from '../agent.js';
31
+ import { resolveChannelModes } from '../channel-options.js';
32
+ import type { Codec, CodecInputEvent, CodecOutputEvent } from '../codec/types.js';
33
+ import { type AgentView, createAgentView } from './agent-view.js';
34
+ import { createWireApplier, type WireApplier } from './decode-fold.js';
35
+ import { buildTransportHeaders } from './headers.js';
36
+ import { evictOldestIfFull } from './internal/bounded-map.js';
37
+ import { Invocation } from './invocation.js';
38
+ import { pipeStream } from './pipe-stream.js';
39
+ import type { RunManager } from './run-manager.js';
40
+ import { createRunManager } from './run-manager.js';
41
+ import { bestEffortDetach, continuityLostError, isContinuityLost, requireConnected } from './session-support.js';
42
+ import { createTree, type DefaultTree } from './tree.js';
43
+ import type {
44
+ AgentSession,
45
+ AgentSessionOptions,
46
+ CancelRequest,
47
+ LoadConversationOptions,
48
+ PipeOptions,
49
+ Run,
50
+ RunEndParams,
51
+ RunRuntime,
52
+ RunView,
53
+ StreamResult,
54
+ Tree,
55
+ } from './types.js';
56
+
57
+ /**
58
+ * Upper bound on buffered deferred cancels. Deferred cancels are bounded so
59
+ * a pathological burst can't grow the map without bound. 200 outstanding
60
+ * fresh-send cancels in flight is ample — a typical agent process sees one
61
+ * per HTTP request.
62
+ */
63
+ const DEFERRED_CANCEL_LIMIT = 200;
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Internal run record for cancel routing
67
+ // ---------------------------------------------------------------------------
68
+
69
+ interface RegisteredRun {
70
+ runId: string;
71
+ /** Invocation-id this run is associated with, minted by the agent at `createRun` (or the `runtime.invocationId` override). */
72
+ invocationId: string;
73
+ controller: AbortController;
74
+ /** Composite signal that fires when either the internal controller or the external signal aborts. */
75
+ signal: AbortSignal;
76
+ onCancel?: (request: CancelRequest) => Promise<boolean>;
77
+ onError?: (error: Ably.ErrorInfo) => void;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Internal state machines
82
+ // ---------------------------------------------------------------------------
83
+
84
+ enum SessionState {
85
+ READY = 'ready',
86
+ CLOSED = 'closed',
87
+ }
88
+
89
+ enum RunState {
90
+ INITIALIZED = 'initialized',
91
+ STARTED = 'started',
92
+ ENDED = 'ended',
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Implementation
97
+ // ---------------------------------------------------------------------------
98
+
99
+ // Spec: AIT-ST1
100
+ class DefaultAgentSession<
101
+ TInput extends CodecInputEvent,
102
+ TOutput extends CodecOutputEvent,
103
+ TProjection,
104
+ TMessage,
105
+ > implements AgentSession<TOutput, TProjection, TMessage> {
106
+ private readonly _channel: Ably.RealtimeChannel;
107
+ private readonly _codec: Codec<TInput, TOutput, TProjection, TMessage>;
108
+ private readonly _logger: Logger | undefined;
109
+ private readonly _onError: ((error: Ably.ErrorInfo) => void) | undefined;
110
+ private readonly _runManager: RunManager;
111
+ private readonly _registeredRuns = new Map<string, RegisteredRun>();
112
+ /**
113
+ * Reverse index from a run's triggering input codec-message-id to its
114
+ * run-id, populated once `Run.start()`'s input-event lookup resolves the
115
+ * triggering input. Lets `_handleCancelMessage` route a cancel keyed by the
116
+ * input codec-message-id (a fresh send whose run-id the client doesn't know)
117
+ * to the registered run. Entries are removed when the run ends / suspends /
118
+ * the session closes, alongside `_registeredRuns`.
119
+ */
120
+ private readonly _runIdByInputCodecMessageId = new Map<string, string>();
121
+ /**
122
+ * Cancels buffered by triggering input codec-message-id when they arrived
123
+ * before the run was known — i.e. before `Run.start()`'s input-event lookup
124
+ * resolved that input to a run. A fresh run has no run-id at the client's
125
+ * send time (the agent mints it at run-start), so an early cancel can only be
126
+ * keyed by the input codec-message-id, and the `inputCodecMessageId → run`
127
+ * linkage doesn't exist until the lookup completes. `Run.start()` consults
128
+ * this buffer as a PULL once it resolves its `resolvedInputCodecMessageId`,
129
+ * honouring any cancel that arrived first. Cleared on `close()`.
130
+ */
131
+ private readonly _deferredCancels = new Map<string, Ably.InboundMessage>();
132
+ /**
133
+ * Session-owned materialisation tree. Every message (live + history) folds
134
+ * through `this._applier.apply(msg)`; conversation state is read by
135
+ * walking parent pointers from the input node.
136
+ *
137
+ * Replaced (not cleared in place) on channel continuity loss so that the
138
+ * fresh tree starts empty. The old tree is abandoned to GC once in-flight
139
+ * lookups have aborted.
140
+ */
141
+ private _tree: DefaultTree<TInput, TOutput, TProjection>;
142
+ /**
143
+ * The Tree's single decode-and-apply engine, binding one inbound decoder
144
+ * instance shared by every fold route (live + history). Streaming across
145
+ * pages folds correctly because the decoder keeps stream-tracker state
146
+ * across messages. Replaced alongside the Tree on continuity loss so the
147
+ * fresh Tree gets a fresh decoder. Outbound encoders (used by `Run.pipe`)
148
+ * manage their own decoders.
149
+ */
150
+ private _applier: WireApplier;
151
+ /**
152
+ * Internal server-side view: input-event lookup + conversation loading over
153
+ * the session Tree. Holds the Tree/applier directly (like the client's
154
+ * DefaultView), so it is RECREATED — not mutated — when the Tree is swapped
155
+ * on continuity loss.
156
+ */
157
+ private _agentView: AgentView<TInput, TOutput, TProjection, TMessage>;
158
+ private readonly _channelListener: (msg: Ably.InboundMessage) => void;
159
+ private readonly _inputEventLookupTimeoutMs: number;
160
+ /** Lookback bound passed to the AgentView's input-event scan (see {@link _createAgentView}). */
161
+ private readonly _inputEventLookbackMs: number;
162
+
163
+ private _state = SessionState.READY;
164
+ private _connectPromise: Promise<void> | undefined;
165
+ private _hasAttachedOnce: boolean;
166
+ private readonly _onChannelStateChange: Ably.channelEventCallback;
167
+
168
+ constructor(options: AgentSessionOptions<TInput, TOutput, TProjection, TMessage>) {
169
+ this._codec = options.codec;
170
+ // Spec: AIT-ST1a, AIT-ST1a2 — register this SDK on both the connection
171
+ // (options.agents) and channel-attach (params.agent) paths. Idempotent
172
+ // across sessions sharing one client.
173
+ const registerOptions = registerAgent(options.client, options.codec);
174
+ const channelOptions: Ably.ChannelOptions = { ...registerOptions };
175
+ // Spec: AIT-ST16 — request object modes etc. when channelModes opts in.
176
+ const modes = resolveChannelModes(options.channelModes);
177
+ if (modes) channelOptions.modes = modes;
178
+ this._channel = options.client.channels.get(options.channelName, channelOptions);
179
+ this._logger = options.logger?.withContext({ component: 'AgentSession' });
180
+ this._onError = options.onError;
181
+ this._runManager = createRunManager(this._channel, this._logger);
182
+ this._inputEventLookupTimeoutMs = options.inputEventLookupTimeoutMs ?? 30000;
183
+ this._inputEventLookbackMs = options.inputEventLookbackMs ?? 120_000;
184
+ this._tree = createTree<TInput, TOutput, TProjection>(
185
+ this._codec,
186
+ this._logger ?? makeLogger({ logLevel: LogLevel.Silent }),
187
+ );
188
+ this._applier = createWireApplier(this._tree, this._codec.createDecoder());
189
+ this._agentView = this._createAgentView();
190
+
191
+ this._channelListener = (msg: Ably.InboundMessage) => {
192
+ this._handleChannelMessage(msg);
193
+ };
194
+
195
+ // Spec: AIT-ST12, AIT-ST12a
196
+ // Listen for channel state changes that break message continuity. The
197
+ // session only consumes cancel messages from the channel, so losing one
198
+ // is survivable — but the developer needs to know so they can decide
199
+ // whether to cancel in-flight work. _hasAttachedOnce is seeded from the
200
+ // channel's current state so pre-attached channels are handled correctly;
201
+ // it distinguishes the initial attach from a genuine discontinuity.
202
+ this._hasAttachedOnce = this._channel.state === 'attached';
203
+ this._onChannelStateChange = (stateChange: Ably.ChannelStateChange) => {
204
+ this._handleChannelStateChange(stateChange);
205
+ };
206
+ this._channel.on(this._onChannelStateChange);
207
+
208
+ this._logger?.debug('DefaultAgentSession(); session created');
209
+ }
210
+
211
+ /**
212
+ * Build an AgentView bound to the session's CURRENT Tree + applier. Called at
213
+ * construction and again after a continuity-loss swap — the AgentView holds
214
+ * the Tree/applier directly (like DefaultView), so a swap recreates it rather
215
+ * than mutating it in place.
216
+ * @returns A fresh AgentView over the current Tree/applier.
217
+ */
218
+ private _createAgentView(): AgentView<TInput, TOutput, TProjection, TMessage> {
219
+ return createAgentView<TInput, TOutput, TProjection, TMessage>({
220
+ tree: this._tree,
221
+ channel: this._channel,
222
+ codec: this._codec,
223
+ applier: this._applier,
224
+ logger: this._logger,
225
+ inputEventLookbackMs: this._inputEventLookbackMs,
226
+ });
227
+ }
228
+
229
+ // -------------------------------------------------------------------------
230
+ // Public accessors
231
+ // -------------------------------------------------------------------------
232
+
233
+ // Spec: AIT-ST14
234
+ get presence(): Ably.RealtimePresence {
235
+ return this._channel.presence;
236
+ }
237
+
238
+ // Spec: AIT-ST15
239
+ get object(): AblyObjects.RealtimeObject {
240
+ return this._channel.object;
241
+ }
242
+
243
+ // -------------------------------------------------------------------------
244
+ // Public API
245
+ // -------------------------------------------------------------------------
246
+
247
+ // Spec: AIT-ST2
248
+ // eslint-disable-next-line @typescript-eslint/promise-function-async -- preserve reference equality across calls
249
+ connect(): Promise<void> {
250
+ if (this._state === SessionState.CLOSED) {
251
+ return Promise.reject(new Ably.ErrorInfo('unable to connect; session is closed', ErrorCode.SessionClosed, 400));
252
+ }
253
+ if (this._connectPromise) return this._connectPromise;
254
+
255
+ this._logger?.trace('DefaultAgentSession.connect();');
256
+ // Subscribe unfiltered (before attach, per RTL7g — subscribe implicitly
257
+ // attaches the channel). Unfiltered so the Tree folds every post-attach
258
+ // message regardless of name (cancel control messages are dispatched
259
+ // separately by the channel listener after the Tree fold).
260
+ this._connectPromise = this._channel.subscribe(this._channelListener).then(
261
+ () => {
262
+ this._logger?.debug('DefaultAgentSession.connect(); subscribed and attached');
263
+ },
264
+ (error: unknown) => {
265
+ const errInfo = new Ably.ErrorInfo(
266
+ `unable to subscribe to channel; ${errorMessage(error)}`,
267
+ ErrorCode.SessionSubscriptionError,
268
+ 500,
269
+ errorCause(error),
270
+ );
271
+ this._logger?.error('DefaultAgentSession.connect(); subscribe failed');
272
+ this._onError?.(errInfo);
273
+ throw errInfo;
274
+ },
275
+ );
276
+ return this._connectPromise;
277
+ }
278
+
279
+ /**
280
+ * The session-owned materialisation tree. Mirrors `ClientSession.tree`
281
+ * for observability and parity.
282
+ * @returns The session's Tree.
283
+ */
284
+ get tree(): Tree<TOutput, TProjection> {
285
+ return this._tree;
286
+ }
287
+
288
+ // Spec: AIT-ST3
289
+ createRun(invocation: Invocation, runtime?: RunRuntime<TOutput>): Run<TOutput, TProjection, TMessage> {
290
+ this._logger?.trace('DefaultAgentSession.createRun();', { inputEventId: invocation.inputEventId });
291
+ return this._createRun(invocation, runtime ?? {});
292
+ }
293
+
294
+ // Spec: AIT-ST11
295
+ async close(): Promise<void> {
296
+ if (this._state === SessionState.CLOSED) return;
297
+ this._state = SessionState.CLOSED;
298
+ this._logger?.trace('DefaultAgentSession.close();');
299
+ if (this._connectPromise) {
300
+ this._channel.unsubscribe(this._channelListener);
301
+ }
302
+ this._channel.off(this._onChannelStateChange);
303
+ for (const reg of this._registeredRuns.values()) {
304
+ reg.controller.abort();
305
+ }
306
+ this._registeredRuns.clear();
307
+ this._runIdByInputCodecMessageId.clear();
308
+ this._deferredCancels.clear();
309
+ this._runManager.close();
310
+
311
+ await bestEffortDetach(this._channel, this._connectPromise, this._logger, 'DefaultAgentSession');
312
+
313
+ this._logger?.debug('DefaultAgentSession.close(); session closed');
314
+ }
315
+
316
+ // -------------------------------------------------------------------------
317
+ // Cancel message routing
318
+ // -------------------------------------------------------------------------
319
+
320
+ private async _handleCancelMessage(msg: Ably.InboundMessage): Promise<void> {
321
+ const headers = getTransportHeaders(msg);
322
+ const runId = headers[HEADER_RUN_ID];
323
+ const inputCodecMessageId = headers[HEADER_INPUT_CODEC_MESSAGE_ID];
324
+
325
+ // Malformed cancel: drop with warn. A cancel must identify its target by
326
+ // `run-id` (a continuation, whose run-id the client knows) and/or by
327
+ // `input-codec-message-id` (a fresh send, before the agent minted the
328
+ // run-id). Neither present means there is nothing to route to.
329
+ if (!runId && !inputCodecMessageId) {
330
+ this._logger?.warn('DefaultAgentSession._handleCancelMessage(); missing run-id and input-codec-message-id', {
331
+ serial: msg.serial,
332
+ });
333
+ return;
334
+ }
335
+
336
+ // Primary path — match by run-id (continuations, whose run-id the client
337
+ // already knows). Resolve the input-codec-message-id to a run-id when the
338
+ // run-id wasn't supplied (a fresh-send cancel that arrived after the run's
339
+ // input-event lookup resolved, so the linkage already exists).
340
+ const resolvedRunId =
341
+ runId ?? (inputCodecMessageId ? this._runIdByInputCodecMessageId.get(inputCodecMessageId) : undefined);
342
+ const reg = resolvedRunId ? this._registeredRuns.get(resolvedRunId) : undefined;
343
+
344
+ if (!reg) {
345
+ // The run isn't known yet. A fresh-send cancel can race ahead of the
346
+ // run's input-event lookup (which is what establishes the
347
+ // input-codec-message-id → run linkage). Buffer it by
348
+ // input-codec-message-id so `Run.start()` can pull and honour it once it
349
+ // resolves the triggering input. A bare run-id cancel for an unknown run
350
+ // is a no-op (the run never existed here, or already ended).
351
+ if (inputCodecMessageId !== undefined) {
352
+ this._bufferDeferredCancel(inputCodecMessageId, msg);
353
+ }
354
+ return;
355
+ }
356
+
357
+ await this._cancelRegistration(reg, msg);
358
+ }
359
+
360
+ /**
361
+ * Buffer a cancel that arrived before its target run was known, keyed by the
362
+ * triggering input's codec-message-id. FIFO-evicts the oldest entry at
363
+ * {@link DEFERRED_CANCEL_LIMIT}. A later cancel for the same input replaces the earlier
364
+ * one — the intent is identical.
365
+ * @param inputCodecMessageId - The triggering input's codec-message-id.
366
+ * @param msg - The raw cancel message (passed to `onCancel`).
367
+ */
368
+ private _bufferDeferredCancel(inputCodecMessageId: string, msg: Ably.InboundMessage): void {
369
+ const evicted = evictOldestIfFull(this._deferredCancels, inputCodecMessageId, DEFERRED_CANCEL_LIMIT);
370
+ if (evicted !== undefined) {
371
+ this._logger?.warn('DefaultAgentSession._bufferDeferredCancel(); deferred-cancel buffer full, dropping oldest', {
372
+ evictedInputCodecMessageId: evicted,
373
+ limit: DEFERRED_CANCEL_LIMIT,
374
+ });
375
+ }
376
+ this._deferredCancels.set(inputCodecMessageId, msg);
377
+ this._logger?.debug('DefaultAgentSession._bufferDeferredCancel(); buffered early cancel', {
378
+ inputCodecMessageId,
379
+ serial: msg.serial,
380
+ });
381
+ }
382
+
383
+ /**
384
+ * Pull and honour a cancel buffered before this run was known. Called from
385
+ * `Run.start()` once the input-event lookup resolves the run's triggering
386
+ * input codec-message-id — the point at which the
387
+ * `input-codec-message-id → run` linkage first exists. No-op when no cancel
388
+ * was buffered for that input.
389
+ * @param reg - The now-known run registration.
390
+ * @param inputCodecMessageId - The run's resolved triggering input codec-message-id.
391
+ */
392
+ private async _pullDeferredCancel(reg: RegisteredRun, inputCodecMessageId: string): Promise<void> {
393
+ const buffered = this._deferredCancels.get(inputCodecMessageId);
394
+ if (buffered === undefined) return;
395
+ this._deferredCancels.delete(inputCodecMessageId);
396
+ this._logger?.debug('DefaultAgentSession._pullDeferredCancel(); honouring buffered cancel', {
397
+ runId: reg.runId,
398
+ inputCodecMessageId,
399
+ });
400
+ await this._cancelRegistration(reg, buffered);
401
+ }
402
+
403
+ /**
404
+ * Fire a cancel against a known run: consult its `onCancel` authorization
405
+ * hook (if any), then abort the run's controller. Shared by the run-id match,
406
+ * the input-codec-message-id match, and the buffered-cancel pull so all three
407
+ * honour `onCancel` and surface handler errors identically.
408
+ * @param reg - The target run registration.
409
+ * @param msg - The raw cancel message (passed to `onCancel`).
410
+ */
411
+ private async _cancelRegistration(reg: RegisteredRun, msg: Ably.InboundMessage): Promise<void> {
412
+ const { runId } = reg;
413
+ this._logger?.debug('DefaultAgentSession._cancelRegistration(); matched run', { runId });
414
+
415
+ const request: CancelRequest = { message: msg, runId };
416
+
417
+ try {
418
+ if (reg.onCancel) {
419
+ const allowed = await reg.onCancel(request);
420
+ if (!allowed) {
421
+ this._logger?.debug('DefaultAgentSession._cancelRegistration(); cancel rejected by onCancel', {
422
+ runId,
423
+ });
424
+ return;
425
+ }
426
+ }
427
+ reg.controller.abort();
428
+ this._logger?.debug('DefaultAgentSession._cancelRegistration(); run cancelled', { runId });
429
+ } catch (error) {
430
+ const errInfo = new Ably.ErrorInfo(
431
+ `unable to process cancel for run ${runId}; onCancel handler threw: ${errorMessage(error)}`,
432
+ ErrorCode.CancelListenerError,
433
+ 500,
434
+ errorCause(error),
435
+ );
436
+ this._logger?.error('DefaultAgentSession._cancelRegistration(); onCancel threw', { runId });
437
+ (reg.onError ?? this._onError)?.(errInfo);
438
+ }
439
+ }
440
+
441
+ // -------------------------------------------------------------------------
442
+ // Channel state change handler
443
+ // -------------------------------------------------------------------------
444
+
445
+ // Spec: AIT-ST12, AIT-ST12a
446
+ private _handleChannelStateChange(stateChange: Ably.ChannelStateChange): void {
447
+ if (this._state === SessionState.CLOSED) return;
448
+
449
+ const { current, resumed } = stateChange;
450
+
451
+ // Track the initial attach so we don't treat it as a discontinuity.
452
+ if (current === 'attached' && !this._hasAttachedOnce) {
453
+ this._hasAttachedOnce = true;
454
+ return;
455
+ }
456
+
457
+ if (!isContinuityLost(stateChange)) return;
458
+
459
+ this._logger?.error('DefaultAgentSession._handleChannelStateChange(); channel continuity lost', {
460
+ current,
461
+ resumed,
462
+ previous: stateChange.previous,
463
+ });
464
+
465
+ const continuityErr = continuityLostError(stateChange, 'continue');
466
+
467
+ // Abort every active run's controller FIRST so in-flight
468
+ // `loadConversation` / `findInputEvent` calls observe the abort before
469
+ // the Tree changes underneath them and reject (InvalidArgument from their
470
+ // signal checks; the session-level onError carries ChannelContinuityLost).
471
+ for (const reg of this._registeredRuns.values()) {
472
+ reg.controller.abort();
473
+ }
474
+
475
+ // Then swap the Tree for a fresh empty instance — abandons the old
476
+ // Tree's projections, indices, and ably-message listeners to GC. New
477
+ // runs use the fresh Tree; lingering closures on the old Tree from
478
+ // in-flight (now-aborted) lookups are bounded by the abort propagation.
479
+ this._tree = createTree<TInput, TOutput, TProjection>(
480
+ this._codec,
481
+ this._logger ?? makeLogger({ logLevel: LogLevel.Silent }),
482
+ );
483
+ this._applier = createWireApplier(this._tree, this._codec.createDecoder());
484
+ // The AgentView holds the Tree/applier directly, so rebuild it against the
485
+ // fresh pair — this also resets its cursor and exhaustion state.
486
+ this._agentView = this._createAgentView();
487
+
488
+ // Session-level notification: continuity loss is not scoped to any one
489
+ // run. Per-run onError handlers are reserved for errors from that run's
490
+ // own operations (publish failures, encoder errors).
491
+ this._onError?.(continuityErr);
492
+ }
493
+
494
+ // -------------------------------------------------------------------------
495
+ // Wire fold
496
+ // -------------------------------------------------------------------------
497
+
498
+ /**
499
+ * Fold a single wire message into the session-owned Tree. Mirrors the
500
+ * ClientSession's live decode loop — same engine, same fold path. The
501
+ * applier decodes the message and applies the result to the Tree (or
502
+ * routes lifecycle messages through `applyRunLifecycle`);
503
+ * `emitAblyMessage` notifies Tree subscribers AND populates the event-id
504
+ * index used by the AgentView's input-event lookup.
505
+ *
506
+ * A message that surfaces via more than one path (the live listener and
507
+ * the AgentView's history walk) does not
508
+ * double-fold: the shared decoder's version-guarded trackers drop
509
+ * re-delivered stream content, and the Tree's per-entry `decodedThrough`
510
+ * high-water-mark drops whole-wire replays (including stateless discrete
511
+ * re-decodes) at the correct per-delivery granularity — same-serial live
512
+ * appends each carry their own version and fold exactly once.
513
+ * @param wire - The inbound Ably message to fold.
514
+ */
515
+ private _foldWire(wire: Ably.InboundMessage): void {
516
+ this._applier.apply(wire);
517
+ this._tree.emitAblyMessage(wire);
518
+ }
519
+
520
+ // -------------------------------------------------------------------------
521
+ // Channel subscription handler
522
+ // -------------------------------------------------------------------------
523
+
524
+ private _handleChannelMessage(msg: Ably.InboundMessage): void {
525
+ try {
526
+ // Fold first (re-delivered content is dropped by the shared decoder's
527
+ // version guard and the Tree's replay guard), then dispatch cancel
528
+ // control messages.
529
+ this._foldWire(msg);
530
+
531
+ if (msg.name === EVENT_CANCEL) {
532
+ // Fire-and-forget async handler — errors are caught internally.
533
+ this._handleCancelMessage(msg).catch((error: unknown) => {
534
+ const errInfo = new Ably.ErrorInfo(
535
+ `unable to route cancel message; ${errorMessage(error)}`,
536
+ ErrorCode.CancelListenerError,
537
+ 500,
538
+ errorCause(error),
539
+ );
540
+ this._logger?.error('DefaultAgentSession._handleChannelMessage(); cancel routing error');
541
+ this._onError?.(errInfo);
542
+ });
543
+ return;
544
+ }
545
+ } catch (error) {
546
+ const errInfo = new Ably.ErrorInfo(
547
+ `unable to process channel message; ${errorMessage(error)}`,
548
+ ErrorCode.SessionSubscriptionError,
549
+ 500,
550
+ errorCause(error),
551
+ );
552
+ this._logger?.error('DefaultAgentSession._handleChannelMessage(); subscription error');
553
+ this._onError?.(errInfo);
554
+ }
555
+ }
556
+
557
+ // -------------------------------------------------------------------------
558
+ // Connection guard
559
+ // -------------------------------------------------------------------------
560
+
561
+ private async _requireConnected(method: string): Promise<void> {
562
+ return requireConnected(this._connectPromise, method);
563
+ }
564
+
565
+ // -------------------------------------------------------------------------
566
+ // Run creation
567
+ // -------------------------------------------------------------------------
568
+
569
+ private _createRun(invocation: Invocation, runtime: RunRuntime<TOutput>): Run<TOutput, TProjection, TMessage> {
570
+ // The run-id is not carried in the invocation body — the agent mints it.
571
+ // Mint a provisional id now (or take the `runtime.runId` override for
572
+ // tests / in-process drivers) — this IS the id for a fresh run. A
573
+ // continuation overrides it in `Run.start()` with the existing run-id read
574
+ // off the triggering input event's message headers (the run it re-enters).
575
+ // Mirrors the invocationId mint below.
576
+ let runId = runtime.runId ?? crypto.randomUUID();
577
+ // Whether the run-id was supplied via the runtime override. Together with
578
+ // `resolvedContinuation` (set in start() when the triggering input carries
579
+ // a wire run-id) this decides whether the id is "adopted" — an adopted id
580
+ // can name a run that already exists in channel history; a freshly-minted
581
+ // UUID cannot, so hydration must not demand its node from history.
582
+ const runIdOverridden = runtime.runId !== undefined;
583
+ // The agent mints the invocation id — one per HTTP request that invokes
584
+ // it. A per-run override (runtime.invocationId) supports deterministic ids
585
+ // in tests and in-process drivers.
586
+ const invocationId = runtime.invocationId ?? crypto.randomUUID();
587
+ const inputEventLookupTimeoutMs = this._inputEventLookupTimeoutMs;
588
+ const { onMessage, onCancelled, onCancel, onError: runOnError, signal: externalSignal } = runtime;
589
+
590
+ const controller = new AbortController();
591
+ let state = RunState.INITIALIZED;
592
+
593
+ // Compose the internal controller signal with the external signal (e.g.
594
+ // req.signal) so platform-level cancellation (request cancellation, function
595
+ // timeout) cancels the run through the same path as Ably cancel messages.
596
+ const signal = externalSignal ? AbortSignal.any([controller.signal, externalSignal]) : controller.signal;
597
+
598
+ // Spec: AIT-ST3a — register immediately so `close()` aborts an in-flight
599
+ // start() and a post-lookup cancel can fire the AbortSignal. Keyed by the
600
+ // provisional run-id; a continuation re-keys to the real id in start()
601
+ // once the triggering input reveals it.
602
+ const registration: RegisteredRun = {
603
+ runId,
604
+ invocationId,
605
+ controller,
606
+ signal,
607
+ onCancel,
608
+ onError: runOnError,
609
+ };
610
+ this._registeredRuns.set(runId, registration);
611
+
612
+ // Capture instance members as locals so arrow functions close over them
613
+ // without needing `this` (avoids unicorn/no-this-assignment).
614
+ const logger = this._logger;
615
+ const runManager = this._runManager;
616
+ const codec = this._codec;
617
+ const channel = this._channel;
618
+ const registeredRuns = this._registeredRuns;
619
+ const runIdByInputCodecMessageId = this._runIdByInputCodecMessageId;
620
+ const deferredCancels = this._deferredCancels;
621
+ const requireConnected = this._requireConnected.bind(this);
622
+ // Live accessor (not a captured ref): a continuity-loss swap recreates the
623
+ // AgentView, and reads after the swap must observe the fresh instance.
624
+ const getAgentView = (): AgentView<TInput, TOutput, TProjection, TMessage> => this._agentView;
625
+ const pullDeferredCancel = this._pullDeferredCancel.bind(this);
626
+ const inputEventId = invocation.inputEventId;
627
+
628
+ // Per-run metadata resolved from the input-event lookup result. The first
629
+ // matched message message's headers carry the run's `clientId`, `parent`, and
630
+ // `forkOf`, and — for a continuation — the `run-id` it re-enters (a fresh
631
+ // input carries none; the client stamps a run-id only when re-entering a
632
+ // run it already knows). Its Ably-level publisher `clientId` becomes the
633
+ // `inputClientId` re-stamped on the agent's own publishes.
634
+ let resolvedClientId: string | undefined;
635
+ let resolvedInputClientId: string | undefined;
636
+ let resolvedParent: string | undefined;
637
+ let resolvedForkOf: string | undefined;
638
+ let resolvedRegenerates: string | undefined;
639
+ let resolvedInputCodecMessageId: string | undefined;
640
+ let resolvedContinuation = false;
641
+ let firstLookupHeaders: Record<string, string> | undefined;
642
+
643
+ // `Run.view.messages` is a LIVE read against the session's Tree:
644
+ // returns the trigger node's currently-folded messages, reflecting any
645
+ // amendments (tool resolutions etc.) that have arrived since
646
+ // `Run.start()`. No internal `viewMessages` array — the Tree is the
647
+ // single source of truth. The trigger node may be an input node (fresh
648
+ // send) or a reply run (continuation re-entry with run-id on the
649
+ // triggering message); both expose a projection the codec can read.
650
+ //
651
+ // Resolved via an arrow accessor so the closure picks up `this._tree`
652
+ // after a continuity-loss swap; capturing `this._tree` into a local at
653
+ // run-creation time would silently keep returning data from the
654
+ // abandoned Tree.
655
+ const getTree = (): DefaultTree<TInput, TOutput, TProjection> => this._tree;
656
+ const view: RunView<TMessage> = {
657
+ get messages() {
658
+ if (resolvedInputCodecMessageId === undefined) return [];
659
+ const node = getTree().getNodeByCodecMessageId(resolvedInputCodecMessageId);
660
+ if (!node) return [];
661
+ const sourceSerial = node.kind === 'input' ? node.serial : node.startSerial;
662
+ const sourceForkOf = node.kind === 'input' ? node.forkOf : undefined;
663
+ return codec.getMessages(node.projection).map((m) => ({
664
+ kind: 'message' as const,
665
+ message: m.message,
666
+ codecMessageId: m.codecMessageId,
667
+ parentId: node.parentCodecMessageId,
668
+ forkOf: sourceForkOf,
669
+ headers: {},
670
+ serial: sourceSerial,
671
+ }));
672
+ },
673
+ };
674
+ /**
675
+ * The reply run's structural-parent fallback, computed once in
676
+ * `Run.start()` once the input-event lookup resolves the triggering
677
+ * input's codec-message-id, and consumed by every `Run.pipe()` publish.
678
+ * A per-stream `streamOpts.parent` still overrides it. Storing it here
679
+ * keeps it stable across pipes and decouples the assistant's structural
680
+ * parent from the run-start message's own `parent`.
681
+ */
682
+ let assistantParentFallback: string | undefined;
683
+ /**
684
+ * Remove this run from the session's routing maps. Drops the
685
+ * `_registeredRuns` entry plus the `input-codec-message-id → run-id`
686
+ * reverse index (and any stale deferred cancel still buffered for that
687
+ * input), keeping the cancel-routing state consistent when the run ends,
688
+ * suspends, or its start fails.
689
+ */
690
+ const deregisterRun = (): void => {
691
+ registeredRuns.delete(runId);
692
+ if (resolvedInputCodecMessageId !== undefined) {
693
+ runIdByInputCodecMessageId.delete(resolvedInputCodecMessageId);
694
+ deferredCancels.delete(resolvedInputCodecMessageId);
695
+ }
696
+ };
697
+
698
+ /**
699
+ * Run a run-lifecycle publish (run-start / run-suspend / run-end) and wrap
700
+ * any failure as a `RunLifecycleError`, logging at error and rethrowing.
701
+ * Shared by start(), suspend(), and end() so the three publishes can't
702
+ * drift on the error code, message shape, or cause preservation.
703
+ * @param phase - The lifecycle wire phase, used in the error message.
704
+ * @param method - The Run method name, used in the log prefix.
705
+ * @param publish - The RunManager publish to run.
706
+ */
707
+ const publishLifecycle = async (
708
+ phase: 'run-start' | 'run-suspend' | 'run-end',
709
+ method: 'start' | 'suspend' | 'end',
710
+ publish: () => Promise<void>,
711
+ ): Promise<void> => {
712
+ try {
713
+ await publish();
714
+ } catch (error) {
715
+ const errInfo = new Ably.ErrorInfo(
716
+ `unable to publish ${phase} for run ${runId}; ${errorMessage(error)}`,
717
+ ErrorCode.RunLifecycleError,
718
+ 500,
719
+ errorCause(error),
720
+ );
721
+ logger?.error(`Run.${method}(); failed to publish ${phase}`, { runId });
722
+ throw errInfo;
723
+ }
724
+ };
725
+
726
+ const run: Run<TOutput, TProjection, TMessage> = {
727
+ get runId() {
728
+ return runId;
729
+ },
730
+ get invocationId() {
731
+ return invocationId;
732
+ },
733
+ get abortSignal() {
734
+ return signal;
735
+ },
736
+ get view() {
737
+ return view;
738
+ },
739
+ get messages() {
740
+ // Always derive live from the Tree via the AgentView. Walks the parent
741
+ // chain from the run's structural-parent anchor and concatenates each
742
+ // ancestor's projection, then appends the current reply run's messages
743
+ // at the tail. Uses `assistantParentFallback` (which falls back to the
744
+ // input message's `parent` for regenerate carriers whose own
745
+ // codec-message-id has no Tree node) — same anchor `loadConversation`
746
+ // uses, and passes `resolvedRegenerates` so a regenerate's history
747
+ // stops before the message being replaced. No cache: every read
748
+ // reflects the latest folded state. `getAgentView()` dereferences the
749
+ // live AgentView so a continuity-loss swap is observed instead of
750
+ // returning stale data from the abandoned tree.
751
+ return getAgentView().messages(runId, assistantParentFallback, resolvedRegenerates);
752
+ },
753
+
754
+ // Spec: AIT-ST4, AIT-ST4a, AIT-ST4b
755
+ start: async (): Promise<void> => {
756
+ logger?.trace('Run.start();', { runId, inputEventId });
757
+
758
+ await requireConnected('start');
759
+
760
+ // Spec: AIT-ST4a
761
+ if (signal.aborted) {
762
+ throw new Ably.ErrorInfo(
763
+ `unable to start run; run ${runId} was cancelled before start()`,
764
+ ErrorCode.InvalidArgument,
765
+ 400,
766
+ );
767
+ }
768
+ if (state !== RunState.INITIALIZED) return;
769
+ state = RunState.STARTED;
770
+
771
+ // Look up the triggering input event on the channel so the agent
772
+ // can read the user's message and per-run metadata (parent, forkOf,
773
+ // continuation flag) before publishing run-start. Skip when
774
+ // inputEventLookupTimeoutMs === 0 (tests and in-process drivers) or
775
+ // when no inputEventId is set (invocation requires no channel lookup).
776
+ if (inputEventId && inputEventLookupTimeoutMs > 0) {
777
+ try {
778
+ const found = await getAgentView().findInputEvent({
779
+ invocationId,
780
+ runId,
781
+ expectedEventIds: [inputEventId],
782
+ timeoutMs: inputEventLookupTimeoutMs,
783
+ signal,
784
+ });
785
+ if (found.firstHeaders !== undefined) firstLookupHeaders = found.firstHeaders;
786
+ if (found.firstClientId !== undefined) resolvedInputClientId = found.firstClientId;
787
+ } catch (error) {
788
+ const errInfo =
789
+ error instanceof Ably.ErrorInfo
790
+ ? error
791
+ : new Ably.ErrorInfo(
792
+ `unable to look up input event; ${errorMessage(error)}`,
793
+ ErrorCode.InputEventNotFound,
794
+ 504,
795
+ );
796
+ // The rejection bubbles up to the developer's HTTP handler,
797
+ // which surfaces the failure as a non-2xx response — that is
798
+ // the signal the client sees. No channel publish: an
799
+ // `ai-run-end` without a preceding `ai-run-start` would break
800
+ // the lifecycle invariant for other channel observers.
801
+ deregisterRun();
802
+ logger?.error('Run.start(); input-event lookup failed', { runId, invocationId });
803
+ throw errInfo;
804
+ }
805
+ }
806
+
807
+ // Resolve per-run metadata from the first matched message message's
808
+ // headers — they carry `clientId`, `parent`, and `forkOf`.
809
+ // Continuations of a suspended run pick up the suspended assistant's
810
+ // parent in the same headers (the continuation message parents off
811
+ // the assistant). A `run-id` on the triggering input marks a
812
+ // continuation (re-entry via `ai-run-resume`); a fresh input carries
813
+ // none and opens the run with `ai-run-start`.
814
+ const sourceHeaders = firstLookupHeaders;
815
+ if (sourceHeaders) {
816
+ resolvedClientId = sourceHeaders[HEADER_RUN_CLIENT_ID];
817
+ resolvedParent = sourceHeaders[HEADER_PARENT];
818
+ resolvedForkOf = sourceHeaders[HEADER_FORK_OF];
819
+ resolvedRegenerates = sourceHeaders[HEADER_MSG_REGENERATE];
820
+ resolvedInputCodecMessageId = sourceHeaders[HEADER_CODEC_MESSAGE_ID];
821
+
822
+ // The triggering input's run-id (if any) IS this run's identity.
823
+ // Present → a continuation re-entering that run: adopt the id,
824
+ // overriding the provisional one minted at construction, and re-key
825
+ // the registration so cancel routing / deregistration resolve to the
826
+ // real run. Absent → a fresh run: the provisional id stands and the
827
+ // run opens with run-start.
828
+ const wireRunId = sourceHeaders[HEADER_RUN_ID];
829
+ resolvedContinuation = wireRunId !== undefined;
830
+ if (wireRunId !== undefined && wireRunId !== runId) {
831
+ registeredRuns.delete(runId);
832
+ runId = wireRunId;
833
+ registration.runId = runId;
834
+ registeredRuns.set(runId, registration);
835
+ }
836
+ }
837
+
838
+ // Compute the reply run's structural-parent fallback: the triggering
839
+ // user message's codec-message-id ONLY if that codec-message-id is
840
+ // backed by a real node in the Tree (i.e. the message decoded into at
841
+ // least one input event); otherwise — for regenerate carriers that
842
+ // are wire-only signals with no input events — fall back to the
843
+ // input message's own `parent` header.
844
+ assistantParentFallback =
845
+ resolvedInputCodecMessageId !== undefined &&
846
+ this._tree.getNodeByCodecMessageId(resolvedInputCodecMessageId) !== undefined
847
+ ? resolvedInputCodecMessageId
848
+ : resolvedParent;
849
+
850
+ // The triggering input's codec-message-id is now resolved, so the
851
+ // `input-codec-message-id → run` linkage exists: index it for live
852
+ // cancels and pull any cancel that arrived before the run was known
853
+ // (a fresh-send cancel published before the agent minted this run-id).
854
+ // Honouring it here may abort the controller before run-start; that is
855
+ // fine — the abort propagates through the same signal a normal cancel
856
+ // would use.
857
+ if (resolvedInputCodecMessageId !== undefined) {
858
+ runIdByInputCodecMessageId.set(resolvedInputCodecMessageId, runId);
859
+ await pullDeferredCancel(registration, resolvedInputCodecMessageId);
860
+ }
861
+
862
+ await publishLifecycle('run-start', 'start', async () =>
863
+ runManager.startRun(runId, resolvedClientId, controller, {
864
+ // Stamp the reply run's STRUCTURAL parent (its input node, M_user) —
865
+ // the same value the output path stamps — not the input message's own
866
+ // parent. Makes `parent` structural on every message so the Tree's two
867
+ // creation paths agree regardless of arrival order. Valid only now
868
+ // that M_user is a separate input node (the two-node flip).
869
+ parent: assistantParentFallback,
870
+ forkOf: resolvedForkOf,
871
+ regenerates: resolvedRegenerates,
872
+ invocationId,
873
+ inputClientId: resolvedInputClientId,
874
+ inputCodecMessageId: resolvedInputCodecMessageId,
875
+ continuation: resolvedContinuation,
876
+ }),
877
+ );
878
+
879
+ // Optimistically insert the fresh run's node into the session Tree so
880
+ // reads that follow start() (loadConversation, Run.messages) see the
881
+ // run immediately rather than depending on the channel echo of the
882
+ // run-start just published. The echo (or a history fold) reconciles
883
+ // through the Tree's run-start handling, promoting startSerial onto
884
+ // this serial-less node. Continuations re-enter an existing run via
885
+ // run-resume, which creates no structure — their node comes from
886
+ // history hydration instead.
887
+ if (!resolvedContinuation) {
888
+ getTree().applyRunLifecycle({
889
+ type: 'start',
890
+ runId,
891
+ clientId: resolvedClientId ?? '',
892
+ serial: undefined,
893
+ invocationId,
894
+ ...(assistantParentFallback !== undefined && { parent: assistantParentFallback }),
895
+ ...(resolvedForkOf !== undefined && { forkOf: resolvedForkOf }),
896
+ ...(resolvedRegenerates !== undefined && { regenerates: resolvedRegenerates }),
897
+ });
898
+ }
899
+
900
+ logger?.debug('Run.start(); run started', { runId, inputEventId });
901
+ },
902
+
903
+ loadConversation: async (options?: LoadConversationOptions): Promise<TMessage[]> => {
904
+ logger?.trace('Run.loadConversation();', { runId });
905
+ await requireConnected('loadConversation');
906
+ // No cache. Drives Tree hydration via the AgentView's conversation walk
907
+ // and computes a fresh snapshot of the parent-chain messages at
908
+ // return time. After this call, `Run.messages` continues to work
909
+ // as a live Tree read.
910
+ const { messages } = await getAgentView().loadConversation(
911
+ runId,
912
+ assistantParentFallback,
913
+ signal,
914
+ options?.maxRuns,
915
+ runIdOverridden || resolvedContinuation,
916
+ resolvedRegenerates,
917
+ );
918
+ return messages;
919
+ },
920
+
921
+ // Spec: AIT-ST6, AIT-ST6a, AIT-ST6b, AIT-ST6b1, AIT-ST6b2, AIT-ST6b3, AIT-ST6c
922
+ pipe: async (stream: ReadableStream<TOutput>, streamOpts?: PipeOptions<TOutput>): Promise<StreamResult> => {
923
+ logger?.trace('Run.pipe();', { runId });
924
+
925
+ await requireConnected('pipe');
926
+
927
+ if (state === RunState.INITIALIZED) {
928
+ throw new Ably.ErrorInfo(
929
+ `unable to pipe stream; start() must be called before pipe() (run ${runId})`,
930
+ ErrorCode.InvalidArgument,
931
+ 400,
932
+ );
933
+ }
934
+
935
+ const runOwnerClientId = runManager.getClientId(runId);
936
+
937
+ // The assistant message's parent: an explicit per-stream
938
+ // `streamOpts.parent` from the caller, else the reply run's
939
+ // structural-parent fallback computed once at run-start
940
+ // (`assistantParentFallback` — the triggering user message, or the
941
+ // input message's own parent for regenerate messages that produced no
942
+ // MessageNodes). Owning the default here means agent routes don't have
943
+ // to pass `{ parent: lastUserCodecMessageId }` to keep tree threading
944
+ // correct; edit-then-regenerate sibling resolution relies on the
945
+ // user→assistant chain being explicit.
946
+ const assistantParent = streamOpts?.parent ?? assistantParentFallback;
947
+ const assistantForkOf = streamOpts?.forkOf ?? resolvedForkOf;
948
+ // Echo `msg-regenerate` on the assistant message so that a
949
+ // client receiving the assistant chunk before `ai-run-start`
950
+ // (e.g. via history pagination across a page boundary, or a lost
951
+ // lifecycle publish) can still populate `RunNode.regeneratesCodecMessageId`
952
+ // when creating the Run from headers. Mirrors the symmetric
953
+ // behaviour for `assistantForkOf` on edit runs.
954
+ const assistantRegenerates = resolvedRegenerates;
955
+
956
+ const codecMessageId = crypto.randomUUID();
957
+ const defaultHeaders = buildTransportHeaders({
958
+ role: 'assistant',
959
+ runId,
960
+ codecMessageId,
961
+ runClientId: runOwnerClientId,
962
+ parent: assistantParent,
963
+ forkOf: assistantForkOf,
964
+ invocationId,
965
+ inputClientId: resolvedInputClientId,
966
+ inputCodecMessageId: resolvedInputCodecMessageId,
967
+ regenerates: assistantRegenerates,
968
+ });
969
+ const encoder = codec.createEncoder(channel, {
970
+ extras: { headers: defaultHeaders },
971
+ onMessage,
972
+ messageId: codecMessageId,
973
+ });
974
+
975
+ const result = await pipeStream(stream, encoder, signal, onCancelled, streamOpts?.resolveWriteOptions, logger);
976
+
977
+ if (result.error) {
978
+ const errInfo = new Ably.ErrorInfo(
979
+ `unable to pipe response for run ${runId}; ${result.error.message}`,
980
+ ErrorCode.StreamError,
981
+ 500,
982
+ errorCause(result.error),
983
+ );
984
+ logger?.error('Run.pipe(); stream error', { runId });
985
+ runOnError?.(errInfo);
986
+ }
987
+
988
+ // Run cancellation is transport-tier: guarantee the run-end terminal so
989
+ // every observer's stream closes even if the caller's handler omits
990
+ // run.end(). Best-effort — pipe must still return the StreamResult; a
991
+ // later run.end() is a no-op via the ENDED guard. The run is past
992
+ // INITIALIZED here (pipe requires start()), so end()'s guards pass.
993
+ if (result.reason === 'cancelled') {
994
+ try {
995
+ await run.end({ reason: 'cancelled' });
996
+ } catch {
997
+ logger?.error('Run.pipe(); run-end on cancel failed', { runId });
998
+ }
999
+ }
1000
+
1001
+ logger?.debug('Run.pipe(); stream finished', { runId, reason: result.reason });
1002
+ return result;
1003
+ },
1004
+
1005
+ suspend: async (): Promise<void> => {
1006
+ logger?.trace('Run.suspend();', { runId });
1007
+
1008
+ await requireConnected('suspend');
1009
+
1010
+ if (state === RunState.INITIALIZED) {
1011
+ throw new Ably.ErrorInfo(
1012
+ `unable to suspend run; start() must be called before suspend() (run ${runId})`,
1013
+ ErrorCode.InvalidArgument,
1014
+ 400,
1015
+ );
1016
+ }
1017
+ // ENDED is the terminal state for either an end or a suspend on this
1018
+ // Run instance; a second terminal call is a no-op.
1019
+ if (state === RunState.ENDED) return;
1020
+ state = RunState.ENDED;
1021
+
1022
+ try {
1023
+ await publishLifecycle('run-suspend', 'suspend', async () =>
1024
+ runManager.suspendRun(runId, invocationId, resolvedInputClientId, resolvedInputCodecMessageId),
1025
+ );
1026
+ } finally {
1027
+ deregisterRun();
1028
+ }
1029
+
1030
+ logger?.debug('Run.suspend(); run suspended', { runId });
1031
+ },
1032
+
1033
+ // Spec: AIT-ST7, AIT-ST7a, AIT-ST7b
1034
+ end: async (params: RunEndParams): Promise<void> => {
1035
+ const { reason } = params;
1036
+ const error = params.reason === 'error' ? params.error : undefined;
1037
+ logger?.trace('Run.end();', { runId, reason });
1038
+
1039
+ await requireConnected('end');
1040
+
1041
+ if (state === RunState.INITIALIZED) {
1042
+ throw new Ably.ErrorInfo(
1043
+ `unable to end run; start() must be called before end() (run ${runId})`,
1044
+ ErrorCode.InvalidArgument,
1045
+ 400,
1046
+ );
1047
+ }
1048
+ if (state === RunState.ENDED) return;
1049
+ state = RunState.ENDED;
1050
+
1051
+ try {
1052
+ await publishLifecycle('run-end', 'end', async () =>
1053
+ runManager.endRun(runId, reason, invocationId, resolvedInputClientId, resolvedInputCodecMessageId, error),
1054
+ );
1055
+ } finally {
1056
+ deregisterRun();
1057
+ }
1058
+
1059
+ logger?.debug('Run.end(); run ended', { runId, reason });
1060
+ },
1061
+ };
1062
+
1063
+ return run;
1064
+ }
1065
+ }
1066
+
1067
+ // ---------------------------------------------------------------------------
1068
+ // Factory
1069
+ // ---------------------------------------------------------------------------
1070
+
1071
+ /**
1072
+ * Create an agent (server-side) session bound to the given Realtime client
1073
+ * and channel name. The caller owns the client's lifecycle; the session
1074
+ * owns its channel.
1075
+ * @param options - Session configuration.
1076
+ * @returns A new {@link AgentSession} instance.
1077
+ */
1078
+ export const createAgentSession = <
1079
+ TInput extends CodecInputEvent,
1080
+ TOutput extends CodecOutputEvent,
1081
+ TProjection,
1082
+ TMessage,
1083
+ >(
1084
+ options: AgentSessionOptions<TInput, TOutput, TProjection, TMessage>,
1085
+ ): AgentSession<TOutput, TProjection, TMessage> => new DefaultAgentSession(options);