@ably/ai-transport 0.2.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 (166) hide show
  1. package/README.md +10 -19
  2. package/dist/ably-ai-transport.js +1790 -1091
  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 +2 -2
  7. package/dist/core/agent.d.ts +20 -5
  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 +4 -1
  11. package/dist/core/codec/define-codec.d.ts +100 -0
  12. package/dist/core/codec/encoder.d.ts +2 -7
  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 -1
  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/output-descriptor-decoder.d.ts +29 -0
  20. package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
  21. package/dist/core/codec/output-descriptors.d.ts +237 -0
  22. package/dist/core/codec/types.d.ts +95 -36
  23. package/dist/core/codec/well-known-inputs.d.ts +52 -0
  24. package/dist/core/transport/agent-view.d.ts +296 -0
  25. package/dist/core/transport/decode-fold.d.ts +40 -32
  26. package/dist/core/transport/headers.d.ts +30 -1
  27. package/dist/core/transport/index.d.ts +1 -1
  28. package/dist/core/transport/invocation.d.ts +1 -1
  29. package/dist/core/transport/load-history-pages.d.ts +71 -0
  30. package/dist/core/transport/load-history.d.ts +21 -16
  31. package/dist/core/transport/run-manager.d.ts +9 -11
  32. package/dist/core/transport/session-support.d.ts +55 -0
  33. package/dist/core/transport/tree.d.ts +165 -15
  34. package/dist/core/transport/types/agent.d.ts +120 -98
  35. package/dist/core/transport/types/client.d.ts +45 -12
  36. package/dist/core/transport/types/tree.d.ts +52 -10
  37. package/dist/core/transport/types/view.d.ts +55 -28
  38. package/dist/core/transport/view.d.ts +176 -58
  39. package/dist/core/transport/wire-log.d.ts +102 -0
  40. package/dist/errors.d.ts +10 -4
  41. package/dist/index.d.ts +6 -5
  42. package/dist/react/ably-ai-transport-react.js +784 -415
  43. package/dist/react/ably-ai-transport-react.js.map +1 -1
  44. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  45. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  46. package/dist/react/contexts/client-session-context.d.ts +2 -1
  47. package/dist/react/contexts/client-session-provider.d.ts +3 -0
  48. package/dist/react/index.d.ts +2 -1
  49. package/dist/react/internal/skipped-session.d.ts +8 -0
  50. package/dist/react/use-view.d.ts +3 -3
  51. package/dist/utils.d.ts +22 -54
  52. package/dist/vercel/ably-ai-transport-vercel.js +2297 -2026
  53. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  54. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  55. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  56. package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
  57. package/dist/vercel/codec/events.d.ts +1 -2
  58. package/dist/vercel/codec/fields.d.ts +44 -0
  59. package/dist/vercel/codec/fold-content.d.ts +16 -0
  60. package/dist/vercel/codec/fold-data.d.ts +16 -0
  61. package/dist/vercel/codec/fold-input.d.ts +67 -0
  62. package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
  63. package/dist/vercel/codec/fold-text.d.ts +16 -0
  64. package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
  65. package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
  66. package/dist/vercel/codec/index.d.ts +5 -30
  67. package/dist/vercel/codec/inputs.d.ts +11 -0
  68. package/dist/vercel/codec/outputs.d.ts +11 -0
  69. package/dist/vercel/codec/reducer-state.d.ts +121 -0
  70. package/dist/vercel/codec/reducer.d.ts +20 -102
  71. package/dist/vercel/codec/tool-transitions.d.ts +0 -6
  72. package/dist/vercel/codec/wire-data.d.ts +34 -0
  73. package/dist/vercel/index.d.ts +1 -0
  74. package/dist/vercel/react/ably-ai-transport-vercel-react.js +2013 -9500
  75. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  76. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -70
  77. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  78. package/dist/vercel/react/contexts/chat-transport-context.d.ts +2 -1
  79. package/dist/vercel/run-end-reason.d.ts +66 -11
  80. package/dist/vercel/tool-part.d.ts +21 -0
  81. package/dist/vercel/transport/chat-transport.d.ts +0 -2
  82. package/dist/vercel/transport/index.d.ts +1 -1
  83. package/dist/vercel/transport/run-output-stream.d.ts +6 -8
  84. package/dist/version.d.ts +1 -1
  85. package/package.json +2 -2
  86. package/src/constants.ts +2 -2
  87. package/src/core/agent.ts +43 -19
  88. package/src/core/channel-options.ts +89 -0
  89. package/src/core/codec/codec-event.ts +27 -0
  90. package/src/core/codec/decoder.ts +145 -21
  91. package/src/core/codec/define-codec.ts +432 -0
  92. package/src/core/codec/encoder.ts +13 -54
  93. package/src/core/codec/field-bag.ts +142 -0
  94. package/src/core/codec/fields.ts +193 -0
  95. package/src/core/codec/index.ts +43 -0
  96. package/src/core/codec/input-descriptor-decoder.ts +97 -0
  97. package/src/core/codec/input-descriptor-encoder.ts +150 -0
  98. package/src/core/codec/input-descriptors.ts +373 -0
  99. package/src/core/codec/output-descriptor-decoder.ts +139 -0
  100. package/src/core/codec/output-descriptor-encoder.ts +101 -0
  101. package/src/core/codec/output-descriptors.ts +307 -0
  102. package/src/core/codec/types.ts +99 -36
  103. package/src/core/codec/well-known-inputs.ts +96 -0
  104. package/src/core/transport/agent-session.ts +330 -589
  105. package/src/core/transport/agent-view.ts +738 -0
  106. package/src/core/transport/client-session.ts +74 -69
  107. package/src/core/transport/decode-fold.ts +57 -47
  108. package/src/core/transport/headers.ts +57 -4
  109. package/src/core/transport/index.ts +2 -1
  110. package/src/core/transport/invocation.ts +1 -1
  111. package/src/core/transport/load-history-pages.ts +220 -0
  112. package/src/core/transport/load-history.ts +63 -61
  113. package/src/core/transport/pipe-stream.ts +10 -1
  114. package/src/core/transport/run-manager.ts +25 -31
  115. package/src/core/transport/session-support.ts +96 -0
  116. package/src/core/transport/tree.ts +414 -47
  117. package/src/core/transport/types/agent.ts +129 -102
  118. package/src/core/transport/types/client.ts +49 -13
  119. package/src/core/transport/types/tree.ts +61 -12
  120. package/src/core/transport/types/view.ts +57 -28
  121. package/src/core/transport/view.ts +520 -172
  122. package/src/core/transport/wire-log.ts +189 -0
  123. package/src/errors.ts +10 -3
  124. package/src/index.ts +44 -11
  125. package/src/react/contexts/client-session-context.ts +1 -1
  126. package/src/react/contexts/client-session-provider.tsx +38 -2
  127. package/src/react/index.ts +2 -1
  128. package/src/react/internal/skipped-session.ts +62 -0
  129. package/src/react/use-client-session.ts +7 -30
  130. package/src/react/use-view.ts +3 -3
  131. package/src/utils.ts +31 -97
  132. package/src/vercel/codec/decode-lifecycle.ts +70 -0
  133. package/src/vercel/codec/events.ts +1 -3
  134. package/src/vercel/codec/fields.ts +58 -0
  135. package/src/vercel/codec/fold-content.ts +54 -0
  136. package/src/vercel/codec/fold-data.ts +46 -0
  137. package/src/vercel/codec/fold-input.ts +255 -0
  138. package/src/vercel/codec/fold-lifecycle.ts +85 -0
  139. package/src/vercel/codec/fold-text.ts +55 -0
  140. package/src/vercel/codec/fold-tool-input.ts +86 -0
  141. package/src/vercel/codec/fold-tool-output.ts +79 -0
  142. package/src/vercel/codec/index.ts +23 -63
  143. package/src/vercel/codec/inputs.ts +116 -0
  144. package/src/vercel/codec/outputs.ts +207 -0
  145. package/src/vercel/codec/reducer-state.ts +169 -0
  146. package/src/vercel/codec/reducer.ts +52 -838
  147. package/src/vercel/codec/tool-transitions.ts +1 -12
  148. package/src/vercel/codec/wire-data.ts +64 -0
  149. package/src/vercel/index.ts +1 -0
  150. package/src/vercel/react/contexts/chat-transport-context.ts +1 -1
  151. package/src/vercel/react/use-chat-transport.ts +8 -28
  152. package/src/vercel/react/use-message-sync.ts +5 -10
  153. package/src/vercel/run-end-reason.ts +95 -16
  154. package/src/vercel/tool-part.ts +25 -0
  155. package/src/vercel/transport/chat-transport.ts +10 -22
  156. package/src/vercel/transport/index.ts +1 -1
  157. package/src/vercel/transport/run-output-stream.ts +7 -8
  158. package/src/version.ts +1 -1
  159. package/dist/core/transport/branch-chain.d.ts +0 -43
  160. package/dist/core/transport/load-conversation.d.ts +0 -128
  161. package/dist/vercel/codec/decoder.d.ts +0 -9
  162. package/dist/vercel/codec/encoder.d.ts +0 -11
  163. package/src/core/transport/branch-chain.ts +0 -58
  164. package/src/core/transport/load-conversation.ts +0 -355
  165. package/src/vercel/codec/decoder.ts +0 -696
  166. package/src/vercel/codec/encoder.ts +0 -548
@@ -6,16 +6,17 @@
6
6
  * single channel subscription — no separate cancel manager needed.
7
7
  *
8
8
  * The session exposes a single factory method — `createRun()` — which returns
9
- * a Run object with explicit lifecycle methods: start(), pipe(), addEvents(),
10
- * suspend(), and end() (suspend() and end() are both terminal).
9
+ * a Run object with explicit lifecycle methods: start(), pipe(), suspend(),
10
+ * and end() (suspend() and end() are both terminal).
11
11
  */
12
12
 
13
13
  import * as Ably from 'ably';
14
+ // Also augments RealtimeChannel with `.object` (ably/liveobjects side-effect).
15
+ import type * as AblyObjects from 'ably/liveobjects';
14
16
 
15
17
  import {
16
18
  EVENT_CANCEL,
17
19
  HEADER_CODEC_MESSAGE_ID,
18
- HEADER_EVENT_ID,
19
20
  HEADER_FORK_OF,
20
21
  HEADER_INPUT_CODEC_MESSAGE_ID,
21
22
  HEADER_MSG_REGENERATE,
@@ -24,264 +25,42 @@ import {
24
25
  HEADER_RUN_ID,
25
26
  } from '../../constants.js';
26
27
  import { ErrorCode } from '../../errors.js';
27
- import type { Logger } from '../../logger.js';
28
- import { compareBySerial, getTransportHeaders } from '../../utils.js';
28
+ import { type Logger, LogLevel, makeLogger } from '../../logger.js';
29
+ import { errorCause, errorMessage, getTransportHeaders } from '../../utils.js';
29
30
  import { registerAgent } from '../agent.js';
31
+ import { resolveChannelModes } from '../channel-options.js';
30
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';
31
35
  import { buildTransportHeaders } from './headers.js';
32
36
  import { evictOldestIfFull } from './internal/bounded-map.js';
33
37
  import { Invocation } from './invocation.js';
34
- import { loadConversation, loadRunProjection } from './load-conversation.js';
35
38
  import { pipeStream } from './pipe-stream.js';
36
39
  import type { RunManager } from './run-manager.js';
37
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';
38
43
  import type {
39
44
  AgentSession,
40
45
  AgentSessionOptions,
41
46
  CancelRequest,
42
- EventsNode,
43
47
  LoadConversationOptions,
44
- MessageNode,
45
48
  PipeOptions,
46
49
  Run,
47
- RunEndReason,
50
+ RunEndParams,
48
51
  RunRuntime,
49
52
  RunView,
50
53
  StreamResult,
54
+ Tree,
51
55
  } from './types.js';
52
56
 
53
- // ---------------------------------------------------------------------------
54
- // Run-state lookup helpers
55
- // ---------------------------------------------------------------------------
56
-
57
57
  /**
58
- * Wait for every event-id in `expectedInputEventIds` to arrive as a channel
59
- * message before letting the run proceed to LLM work. Uses the session's
60
- * unfiltered channel dispatcher (registered in `connect()`) so that
61
- * messages replayed via channel rewind on attach reach the lookup — no
62
- * separate history fetch needed.
63
- *
64
- * Scope: this awaits the data-carrying input events a send publishes —
65
- * fresh prompts, edits, regenerates, tool results, and approvals. Control
66
- * events (cancel etc.) carry no `event-id`, are dispatched
67
- * separately, and never enter this lookup.
68
- *
69
- * Each client-published event in a send (user-message AND amend events
70
- * such as tool-approval responses and client tool outputs) is stamped
71
- * with its own `event-id`.
72
- * The lookup matches incoming messages against the expected set; ids
73
- * not in the set are ignored, duplicates (rewind redelivering a message
74
- * also seen live) are deduped by event-id. The wait completes when
75
- * every expected id has arrived, guaranteeing the channel state is
76
- * consistent with what the client promised before any downstream
77
- * processing (loadProjection, streamText) runs.
78
- *
79
- * User-message arrivals decode into MessageNodes that populate
80
- * `run.view.messages`; amend arrivals fold into a fresh projection that
81
- * has no target message, so they're orphaned and dropped — they only
82
- * count toward the wait. Collected nodes are returned sorted by Ably
83
- * `serial` ascending.
84
- *
85
- * Bounded by `timeoutMs` as a total budget across all N arrivals. The
86
- * caller's `signal` aborts the wait. On partial collection at timeout the
87
- * promise rejects with `InputEventNotFound` and an error message including
88
- * "received X of Y". If any decode throws mid-collection, the whole lookup
89
- * rejects with `InputEventNotFound` wrapping the decode error as cause —
90
- * already-collected messages are discarded.
91
- * @param opts - Lookup parameters.
92
- * @param opts.register - Session-provided registration that delivers the input events for the expected event-ids. Returns an unregister function.
93
- * @param opts.codec - Codec used to decode arriving messages.
94
- * @param opts.invocationId - Invocation identifier — used only for diagnostic logging and error messages.
95
- * @param opts.runId - Run identifier (used for logging and error messages).
96
- * @param opts.expectedInputEventIds - Input-event ids the lookup must observe before resolving.
97
- * @param opts.timeoutMs - Maximum total time to wait for all event-id arrivals.
98
- * @param opts.signal - AbortSignal that cancels the wait when the run is cancelled.
99
- * @param opts.logger - Optional logger for diagnostic output.
100
- * @returns The MessageNodes for arriving user-message events (sorted by Ably
101
- * serial — empty when every input event was a tool-resolution wire message that
102
- * decoded to a chunk and produced no node), and the transport headers of
103
- * the first matched wire message. `firstHeaders` is the canonical source for
104
- * run-level metadata (clientId, parent, forkOf, continuation flag) because
105
- * it lands whether or not the decode produced a MessageNode. `firstClientId`
106
- * carries the publisher's Ably-level `clientId` from that same message — the
107
- * source of `inputClientId` re-stamping on the agent's published events.
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.
108
62
  */
109
- interface InputEventLookupResult<TMessage> {
110
- nodes: MessageNode<TMessage>[];
111
- firstHeaders?: Record<string, string>;
112
- firstClientId?: string;
113
- /**
114
- * Raw Ably messages observed live for the matched input-event ids, in
115
- * arrival order. The agent forwards these to `loadRunProjection` so a
116
- * continuation invocation can fold the just-published client wires
117
- * (e.g. a tool-output-available) without waiting on Ably's channel
118
- * history indexing window.
119
- */
120
- rawMessages: Ably.InboundMessage[];
121
- }
122
-
123
- const lookupInputEvents = async <
124
- TInput extends CodecInputEvent,
125
- TOutput extends CodecOutputEvent,
126
- TProjection,
127
- TMessage,
128
- >(opts: {
129
- register: (callback: (msg: Ably.InboundMessage) => void) => () => void;
130
- codec: Codec<TInput, TOutput, TProjection, TMessage>;
131
- invocationId: string;
132
- runId: string;
133
- expectedInputEventIds: readonly string[];
134
- timeoutMs: number;
135
- signal: AbortSignal;
136
- logger: Logger | undefined;
137
- }): Promise<InputEventLookupResult<TMessage>> => {
138
- const { register, codec, invocationId, runId, expectedInputEventIds, timeoutMs, signal, logger } = opts;
139
- const expectedSet = new Set(expectedInputEventIds);
140
- const expectedCount = expectedSet.size;
141
-
142
- const collected: MessageNode<TMessage>[] = [];
143
- const rawMessages: Ably.InboundMessage[] = [];
144
- const matchedInputEventIds = new Set<string>();
145
- let firstHeaders: Record<string, string> | undefined;
146
- let firstClientId: string | undefined;
147
-
148
- /**
149
- * Decode an inbound Ably message into MessageNodes via the codec.
150
- * @param m - The inbound Ably message to decode.
151
- * @returns The decoded MessageNodes carrying transport headers and serial.
152
- */
153
- const decode = (m: Ably.InboundMessage): MessageNode<TMessage>[] => {
154
- const decoder = codec.createDecoder();
155
- const headers = getTransportHeaders(m);
156
- const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID] ?? '';
157
- const { inputs, outputs } = decoder.decode(m);
158
- const events: (TInput | TOutput)[] = [...inputs, ...outputs];
159
- let projection = codec.init();
160
- for (const event of events) {
161
- projection = codec.fold(projection, event, { serial: m.serial ?? '', messageId: codecMessageId });
162
- }
163
- return codec.getMessages(projection).map(({ message }) => ({
164
- kind: 'message' as const,
165
- message,
166
- codecMessageId,
167
- parentId: headers[HEADER_PARENT],
168
- forkOf: headers[HEADER_FORK_OF],
169
- headers,
170
- serial: m.serial,
171
- }));
172
- };
173
-
174
- return new Promise<InputEventLookupResult<TMessage>>((resolve, reject) => {
175
- let settled = false;
176
- // Dedupe across rewind-redelivery: rewind may surface a message the
177
- // listener also saw live. Scoped to the active lookup so it cannot
178
- // grow unbounded.
179
- const seenSerials = new Set<string>();
180
- // Forward-declared so that cleanup() and onCancelled() can reference them
181
- // before they are assigned. cleanup may run synchronously inside
182
- // `register(...)` (when buffered input events drain on registration) before
183
- // `unregister`/`timer` have been assigned — the no-op fallback for
184
- // unregister and undefined-guard for timer handle that window. The
185
- // settled-flag re-check after `register` returns reconciles the
186
- // listener-detach that cleanup couldn't perform inside that window.
187
- /* eslint-disable prefer-const, unicorn/consistent-function-scoping, @typescript-eslint/no-empty-function -- forward-declared state for the sync-drain reconciliation pattern; see comment above. */
188
- let unregister: () => void = () => {};
189
- let timer: ReturnType<typeof setTimeout> | undefined;
190
- /* eslint-enable */
191
- const cleanup = (): void => {
192
- unregister();
193
- if (timer !== undefined) clearTimeout(timer);
194
- signal.removeEventListener('abort', onCancelled);
195
- };
196
- const onCancelled = (): void => {
197
- if (settled) return;
198
- settled = true;
199
- cleanup();
200
- reject(
201
- new Ably.ErrorInfo(`unable to look up input event; run ${runId} was cancelled`, ErrorCode.InvalidArgument, 400),
202
- );
203
- };
204
- signal.addEventListener('abort', onCancelled, { once: true });
205
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- onCancelled may have settled the promise synchronously above when the signal was already aborted.
206
- if (settled) return;
207
- unregister = register((m) => {
208
- if (settled) return;
209
- if (m.serial !== undefined && seenSerials.has(m.serial)) return;
210
- if (m.serial !== undefined) seenSerials.add(m.serial);
211
-
212
- const wireHeaders = getTransportHeaders(m);
213
-
214
- // Only count messages whose event-id is in the expected set.
215
- const msgEventId = wireHeaders[HEADER_EVENT_ID];
216
- if (!msgEventId || !expectedSet.has(msgEventId) || matchedInputEventIds.has(msgEventId)) return;
217
- matchedInputEventIds.add(msgEventId);
218
-
219
- // Capture the trigger event's headers AND its Ably channel-level `clientId`
220
- // so run-level metadata (parent / forkOf / continuation flag from headers;
221
- // `inputClientId` from the wire publisher) is available even when the decode
222
- // produces zero MessageNodes — the case for continuation tool-resolution
223
- // trigger events whose chunks fold into a fresh empty projection without
224
- // an assistant to land on.
225
- if (firstHeaders === undefined) {
226
- firstHeaders = wireHeaders;
227
- firstClientId = m.clientId;
228
- }
229
-
230
- let decoded: MessageNode<TMessage>[];
231
- try {
232
- decoded = decode(m);
233
- } catch (error) {
234
- settled = true;
235
- cleanup();
236
- const cause = error instanceof Ably.ErrorInfo ? error : undefined;
237
- reject(
238
- new Ably.ErrorInfo(
239
- `unable to look up input event; decode failed for invocation ${invocationId}: ${error instanceof Error ? error.message : String(error)}`,
240
- ErrorCode.InputEventNotFound,
241
- 504,
242
- cause,
243
- ),
244
- );
245
- return;
246
- }
247
- for (const node of decoded) collected.push(node);
248
- rawMessages.push(m);
249
- if (matchedInputEventIds.size < expectedCount) return;
250
- settled = true;
251
- cleanup();
252
- // Sort by Ably serial ascending so callers see publish order regardless
253
- // of interleaved rewind+live delivery. Null serials sort last (defensive
254
- // — input events should always carry a serial).
255
- collected.sort(compareBySerial);
256
- logger?.debug('lookupInputEvents(); collected input events', {
257
- runId,
258
- invocationId,
259
- count: collected.length,
260
- });
261
- resolve({ nodes: collected, firstHeaders, firstClientId, rawMessages });
262
- });
263
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the register callback may have settled the promise synchronously during buffered input-event drain.
264
- if (settled) {
265
- // Sync drain inside register settled the promise; cleanup ran but
266
- // could not detach the listener because `unregister` was still the
267
- // no-op. Detach it now.
268
- unregister();
269
- return;
270
- }
271
- timer = setTimeout(() => {
272
- if (settled) return;
273
- settled = true;
274
- cleanup();
275
- reject(
276
- new Ably.ErrorInfo(
277
- `unable to look up input event; received ${String(collected.length)} of ${String(expectedCount)} input events for invocation ${invocationId} within ${String(timeoutMs)}ms`,
278
- ErrorCode.InputEventNotFound,
279
- 504,
280
- ),
281
- );
282
- }, timeoutMs);
283
- });
284
- };
63
+ const DEFERRED_CANCEL_LIMIT = 200;
285
64
 
286
65
  // ---------------------------------------------------------------------------
287
66
  // Internal run record for cancel routing
@@ -325,7 +104,7 @@ class DefaultAgentSession<
325
104
  TMessage,
326
105
  > implements AgentSession<TOutput, TProjection, TMessage> {
327
106
  private readonly _channel: Ably.RealtimeChannel;
328
- private readonly _codec: AgentSessionOptions<TInput, TOutput, TProjection, TMessage>['codec'];
107
+ private readonly _codec: Codec<TInput, TOutput, TProjection, TMessage>;
329
108
  private readonly _logger: Logger | undefined;
330
109
  private readonly _onError: ((error: Ably.ErrorInfo) => void) | undefined;
331
110
  private readonly _runManager: RunManager;
@@ -347,33 +126,39 @@ class DefaultAgentSession<
347
126
  * keyed by the input codec-message-id, and the `inputCodecMessageId → run`
348
127
  * linkage doesn't exist until the lookup completes. `Run.start()` consults
349
128
  * this buffer as a PULL once it resolves its `resolvedInputCodecMessageId`,
350
- * honouring any cancel that arrived first. Mirrors `_inputEventBuffer`: FIFO
351
- * eviction at `_inputEventBufferLimit` entries, cleared on `close()`.
129
+ * honouring any cancel that arrived first. Cleared on `close()`.
352
130
  */
353
131
  private readonly _deferredCancels = new Map<string, Ably.InboundMessage>();
354
132
  /**
355
- * Active input-event lookups keyed by `event-id`. The channel listener
356
- * dispatches each input event to the lookup that registered for its
357
- * `event-id`, so that messages replayed via channel rewind (and live
358
- * messages alike) reach the right lookup without each lookup having to
359
- * subscribe separately, and without depending on a client-minted
360
- * `invocation-id`.
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.
361
149
  */
362
- private readonly _pendingInputEventLookups = new Map<string, (msg: Ably.InboundMessage) => void>();
150
+ private _applier: WireApplier;
363
151
  /**
364
- * Input events buffered by `event-id` when no lookup callback was
365
- * registered at delivery time. Rewind replays user messages on attach
366
- * before `run.start()` runsso without buffering they would be dropped.
367
- * Each `event-id` maps to an ordered array so rewind redelivery of the
368
- * same event before registration is preserved (the lookup later dedupes by
369
- * serial). `_registerInputEventListener` drains the buffer on registration.
370
- * FIFO eviction at `_inputEventBufferLimit` event entries (each entry counts
371
- * once regardless of array length).
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.
372
156
  */
373
- private readonly _inputEventBuffer = new Map<string, Ably.InboundMessage[]>();
374
- private readonly _inputEventBufferLimit: number;
157
+ private _agentView: AgentView<TInput, TOutput, TProjection, TMessage>;
375
158
  private readonly _channelListener: (msg: Ably.InboundMessage) => void;
376
159
  private readonly _inputEventLookupTimeoutMs: number;
160
+ /** Lookback bound passed to the AgentView's input-event scan (see {@link _createAgentView}). */
161
+ private readonly _inputEventLookbackMs: number;
377
162
 
378
163
  private _state = SessionState.READY;
379
164
  private _connectPromise: Promise<void> | undefined;
@@ -386,20 +171,22 @@ class DefaultAgentSession<
386
171
  // (options.agents) and channel-attach (params.agent) paths. Idempotent
387
172
  // across sessions sharing one client.
388
173
  const registerOptions = registerAgent(options.client, options.codec);
389
- // Attach with a rewind window (default 2m) so a freshly-constructed
390
- // agent session can locate an input event that was published before it
391
- // attached (closes the lookup race when a per-request agent is spun
392
- // up after the client has already POSTed). Tunable via
393
- // `AgentSessionOptions.rewindWindow`.
394
- const channelOptions: Ably.ChannelOptions = {
395
- params: { ...registerOptions.params, rewind: options.rewindWindow ?? '2m' },
396
- };
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;
397
178
  this._channel = options.client.channels.get(options.channelName, channelOptions);
398
179
  this._logger = options.logger?.withContext({ component: 'AgentSession' });
399
180
  this._onError = options.onError;
400
181
  this._runManager = createRunManager(this._channel, this._logger);
401
182
  this._inputEventLookupTimeoutMs = options.inputEventLookupTimeoutMs ?? 30000;
402
- this._inputEventBufferLimit = options.inputEventBufferLimit ?? 200;
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();
403
190
 
404
191
  this._channelListener = (msg: Ably.InboundMessage) => {
405
192
  this._handleChannelMessage(msg);
@@ -421,6 +208,38 @@ class DefaultAgentSession<
421
208
  this._logger?.debug('DefaultAgentSession(); session created');
422
209
  }
423
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
+
424
243
  // -------------------------------------------------------------------------
425
244
  // Public API
426
245
  // -------------------------------------------------------------------------
@@ -435,22 +254,19 @@ class DefaultAgentSession<
435
254
 
436
255
  this._logger?.trace('DefaultAgentSession.connect();');
437
256
  // Subscribe unfiltered (before attach, per RTL7g — subscribe implicitly
438
- // attaches the channel). An unfiltered subscribe ensures that messages
439
- // replayed via channel rewind reach the dispatcher so input-event
440
- // lookups can match against them; the dispatcher then routes by name
441
- // (cancel vs. input event). A name-filtered subscribe would silently
442
- // drop replayed user messages because rewind delivers them to listeners
443
- // registered at attach time only.
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).
444
260
  this._connectPromise = this._channel.subscribe(this._channelListener).then(
445
261
  () => {
446
262
  this._logger?.debug('DefaultAgentSession.connect(); subscribed and attached');
447
263
  },
448
264
  (error: unknown) => {
449
265
  const errInfo = new Ably.ErrorInfo(
450
- `unable to subscribe to channel; ${error instanceof Error ? error.message : String(error)}`,
266
+ `unable to subscribe to channel; ${errorMessage(error)}`,
451
267
  ErrorCode.SessionSubscriptionError,
452
268
  500,
453
- error instanceof Ably.ErrorInfo ? error : undefined,
269
+ errorCause(error),
454
270
  );
455
271
  this._logger?.error('DefaultAgentSession.connect(); subscribe failed');
456
272
  this._onError?.(errInfo);
@@ -461,47 +277,12 @@ class DefaultAgentSession<
461
277
  }
462
278
 
463
279
  /**
464
- * Register a callback to receive the input events carrying any of the
465
- * given `eventIds`. Lookups must share the session's unfiltered
466
- * subscription rather than registering their own subscribe — Ably's
467
- * rewind only delivers to listeners present at attach time.
468
- *
469
- * The listener remains registered after the initial buffer drain so a
470
- * matching event that arrives live (rather than from the buffer) still
471
- * reaches the lookup until it unregisters itself. Today the only caller
472
- * registers a single trigger event-id; the array form keeps the
473
- * registration capable of awaiting several ids without changing callers.
474
- * @param eventIds - The `event-id`s this listener cares about.
475
- * @param callback - Invoked once per matching Ably message, in buffer-insertion order for drained entries.
476
- * @returns Unregister function. Safe to call multiple times.
280
+ * The session-owned materialisation tree. Mirrors `ClientSession.tree`
281
+ * for observability and parity.
282
+ * @returns The session's Tree.
477
283
  */
478
- private _registerInputEventListener(
479
- eventIds: readonly string[],
480
- callback: (msg: Ably.InboundMessage) => void,
481
- ): () => void {
482
- for (const eventId of eventIds) {
483
- this._pendingInputEventLookups.set(eventId, callback);
484
- }
485
- // Drain any buffered input events for these event-ids — rewind replays
486
- // user messages on attach before run.start() can register the callback.
487
- // Without this drain, the lookup waits the full
488
- // `inputEventLookupTimeoutMs` for a live arrival that never comes. Set
489
- // all listeners before draining so a drain that completes the lookup
490
- // synchronously cannot leave a later event-id unmapped.
491
- for (const eventId of eventIds) {
492
- const buffered = this._inputEventBuffer.get(eventId);
493
- if (buffered) {
494
- this._inputEventBuffer.delete(eventId);
495
- for (const m of buffered) callback(m);
496
- }
497
- }
498
- return () => {
499
- for (const eventId of eventIds) {
500
- if (this._pendingInputEventLookups.get(eventId) === callback) {
501
- this._pendingInputEventLookups.delete(eventId);
502
- }
503
- }
504
- };
284
+ get tree(): Tree<TOutput, TProjection> {
285
+ return this._tree;
505
286
  }
506
287
 
507
288
  // Spec: AIT-ST3
@@ -525,23 +306,9 @@ class DefaultAgentSession<
525
306
  this._registeredRuns.clear();
526
307
  this._runIdByInputCodecMessageId.clear();
527
308
  this._deferredCancels.clear();
528
- this._pendingInputEventLookups.clear();
529
- this._inputEventBuffer.clear();
530
309
  this._runManager.close();
531
310
 
532
- // Detach the channel this session attached. connect() subscribes (which
533
- // implicitly attaches), so we only detach when connect() ran. Best-effort:
534
- // a detach failure (e.g. the channel is already FAILED) must not throw out
535
- // of close().
536
- if (this._connectPromise) {
537
- try {
538
- await this._channel.detach();
539
- } catch (error) {
540
- // Swallowed (see above): a detach failure must not throw out of
541
- // close(). Logged at debug for observability.
542
- this._logger?.debug('DefaultAgentSession.close(); channel detach failed', { error });
543
- }
544
- }
311
+ await bestEffortDetach(this._channel, this._connectPromise, this._logger, 'DefaultAgentSession');
545
312
 
546
313
  this._logger?.debug('DefaultAgentSession.close(); session closed');
547
314
  }
@@ -593,17 +360,17 @@ class DefaultAgentSession<
593
360
  /**
594
361
  * Buffer a cancel that arrived before its target run was known, keyed by the
595
362
  * triggering input's codec-message-id. FIFO-evicts the oldest entry at
596
- * `_inputEventBufferLimit` (mirroring `_inputEventBuffer`). A later cancel
597
- * for the same input replaces the earlier one — the intent is identical.
363
+ * {@link DEFERRED_CANCEL_LIMIT}. A later cancel for the same input replaces the earlier
364
+ * one — the intent is identical.
598
365
  * @param inputCodecMessageId - The triggering input's codec-message-id.
599
366
  * @param msg - The raw cancel message (passed to `onCancel`).
600
367
  */
601
368
  private _bufferDeferredCancel(inputCodecMessageId: string, msg: Ably.InboundMessage): void {
602
- const evicted = evictOldestIfFull(this._deferredCancels, inputCodecMessageId, this._inputEventBufferLimit);
369
+ const evicted = evictOldestIfFull(this._deferredCancels, inputCodecMessageId, DEFERRED_CANCEL_LIMIT);
603
370
  if (evicted !== undefined) {
604
371
  this._logger?.warn('DefaultAgentSession._bufferDeferredCancel(); deferred-cancel buffer full, dropping oldest', {
605
372
  evictedInputCodecMessageId: evicted,
606
- limit: this._inputEventBufferLimit,
373
+ limit: DEFERRED_CANCEL_LIMIT,
607
374
  });
608
375
  }
609
376
  this._deferredCancels.set(inputCodecMessageId, msg);
@@ -661,10 +428,10 @@ class DefaultAgentSession<
661
428
  this._logger?.debug('DefaultAgentSession._cancelRegistration(); run cancelled', { runId });
662
429
  } catch (error) {
663
430
  const errInfo = new Ably.ErrorInfo(
664
- `unable to process cancel for run ${runId}; onCancel handler threw: ${error instanceof Error ? error.message : String(error)}`,
431
+ `unable to process cancel for run ${runId}; onCancel handler threw: ${errorMessage(error)}`,
665
432
  ErrorCode.CancelListenerError,
666
433
  500,
667
- error instanceof Ably.ErrorInfo ? error : undefined,
434
+ errorCause(error),
668
435
  );
669
436
  this._logger?.error('DefaultAgentSession._cancelRegistration(); onCancel threw', { runId });
670
437
  (reg.onError ?? this._onError)?.(errInfo);
@@ -681,19 +448,13 @@ class DefaultAgentSession<
681
448
 
682
449
  const { current, resumed } = stateChange;
683
450
 
684
- // Track the initial attach so we don't treat it as a discontinuity
451
+ // Track the initial attach so we don't treat it as a discontinuity.
685
452
  if (current === 'attached' && !this._hasAttachedOnce) {
686
453
  this._hasAttachedOnce = true;
687
454
  return;
688
455
  }
689
456
 
690
- // Continuity-breaking states:
691
- // - FAILED, SUSPENDED, DETACHED: no more messages expected (or gap)
692
- // - ATTACHED with resumed: false (UPDATE): messages were lost
693
- const continuityLost =
694
- current === 'failed' || current === 'suspended' || current === 'detached' || (current === 'attached' && !resumed);
695
-
696
- if (!continuityLost) return;
457
+ if (!isContinuityLost(stateChange)) return;
697
458
 
698
459
  this._logger?.error('DefaultAgentSession._handleChannelStateChange(); channel continuity lost', {
699
460
  current,
@@ -701,18 +462,59 @@ class DefaultAgentSession<
701
462
  previous: stateChange.previous,
702
463
  });
703
464
 
704
- const err = new Ably.ErrorInfo(
705
- `unable to deliver cancel messages; channel continuity lost (${current}${current === 'attached' ? ', resumed: false' : ''})`,
706
- ErrorCode.ChannelContinuityLost,
707
- 500,
708
- stateChange.reason,
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 }),
709
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();
710
487
 
711
- // Session-level notification only: continuity loss is not scoped to any
488
+ // Session-level notification: continuity loss is not scoped to any one
712
489
  // run. Per-run onError handlers are reserved for errors from that run's
713
- // own operations (publish failures, encoder errors). Developers that need
714
- // per-run reaction can iterate active runs from the session handler.
715
- this._onError?.(err);
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);
716
518
  }
717
519
 
718
520
  // -------------------------------------------------------------------------
@@ -721,67 +523,31 @@ class DefaultAgentSession<
721
523
 
722
524
  private _handleChannelMessage(msg: Ably.InboundMessage): void {
723
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
+
724
531
  if (msg.name === EVENT_CANCEL) {
725
532
  // Fire-and-forget async handler — errors are caught internally.
726
533
  this._handleCancelMessage(msg).catch((error: unknown) => {
727
534
  const errInfo = new Ably.ErrorInfo(
728
- `unable to route cancel message; ${error instanceof Error ? error.message : String(error)}`,
535
+ `unable to route cancel message; ${errorMessage(error)}`,
729
536
  ErrorCode.CancelListenerError,
730
537
  500,
731
- error instanceof Ably.ErrorInfo ? error : undefined,
538
+ errorCause(error),
732
539
  );
733
540
  this._logger?.error('DefaultAgentSession._handleChannelMessage(); cancel routing error');
734
541
  this._onError?.(errInfo);
735
542
  });
736
543
  return;
737
544
  }
738
-
739
- // Dispatch client-published input events to the lookup registered
740
- // for their `event-id`. Every client-originated event in an
741
- // invocation (user-message AND amend events such as tool-approval
742
- // responses and client tool outputs) carries `event-id`; the lookup
743
- // waits for every promised id to arrive before letting the run start
744
- // LLM work. Routing by `event-id` rather than `invocation-id` keeps
745
- // the dispatcher independent of any client-minted invocation
746
- // identity. Server-side lifecycle messages (run-start, run-end,
747
- // cancel, error) never stamp `event-id`, so they're naturally
748
- // excluded.
749
- const headers = getTransportHeaders(msg);
750
- const eventId = headers[HEADER_EVENT_ID];
751
- if (eventId !== undefined) {
752
- const listener = this._pendingInputEventLookups.get(eventId);
753
- if (listener) {
754
- listener(msg);
755
- } else {
756
- // Buffer for a future `_registerInputEventListener` call. This is
757
- // load-bearing for the "agent attaches after publish" scenario
758
- // where channel rewind delivers user messages before
759
- // `run.start()` runs.
760
- const existing = this._inputEventBuffer.get(eventId);
761
- if (existing) {
762
- existing.push(msg);
763
- } else {
764
- // FIFO eviction: drop the oldest event entry (and all its buffered
765
- // redeliveries). Clients whose input event was evicted will fail
766
- // their lookup with `InputEventNotFound` — this warn is the only
767
- // operator-visible signal that capacity caused the failure.
768
- const evicted = evictOldestIfFull(this._inputEventBuffer, eventId, this._inputEventBufferLimit);
769
- if (evicted !== undefined) {
770
- this._logger?.warn(
771
- 'DefaultAgentSession._handleChannelMessage(); input-event buffer full, dropping oldest entry',
772
- { evictedEventId: evicted, limit: this._inputEventBufferLimit },
773
- );
774
- }
775
- this._inputEventBuffer.set(eventId, [msg]);
776
- }
777
- }
778
- }
779
545
  } catch (error) {
780
546
  const errInfo = new Ably.ErrorInfo(
781
- `unable to process channel message; ${error instanceof Error ? error.message : String(error)}`,
547
+ `unable to process channel message; ${errorMessage(error)}`,
782
548
  ErrorCode.SessionSubscriptionError,
783
549
  500,
784
- error instanceof Ably.ErrorInfo ? error : undefined,
550
+ errorCause(error),
785
551
  );
786
552
  this._logger?.error('DefaultAgentSession._handleChannelMessage(); subscription error');
787
553
  this._onError?.(errInfo);
@@ -793,14 +559,7 @@ class DefaultAgentSession<
793
559
  // -------------------------------------------------------------------------
794
560
 
795
561
  private async _requireConnected(method: string): Promise<void> {
796
- if (!this._connectPromise) {
797
- throw new Ably.ErrorInfo(
798
- `unable to ${method}; connect() must be called before ${method}()`,
799
- ErrorCode.InvalidArgument,
800
- 400,
801
- );
802
- }
803
- return this._connectPromise;
562
+ return requireConnected(this._connectPromise, method);
804
563
  }
805
564
 
806
565
  // -------------------------------------------------------------------------
@@ -812,9 +571,15 @@ class DefaultAgentSession<
812
571
  // Mint a provisional id now (or take the `runtime.runId` override for
813
572
  // tests / in-process drivers) — this IS the id for a fresh run. A
814
573
  // continuation overrides it in `Run.start()` with the existing run-id read
815
- // off the triggering input event's wire headers (the run it re-enters).
574
+ // off the triggering input event's message headers (the run it re-enters).
816
575
  // Mirrors the invocationId mint below.
817
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;
818
583
  // The agent mints the invocation id — one per HTTP request that invokes
819
584
  // it. A per-run override (runtime.invocationId) supports deterministic ids
820
585
  // in tests and in-process drivers.
@@ -854,29 +619,18 @@ class DefaultAgentSession<
854
619
  const runIdByInputCodecMessageId = this._runIdByInputCodecMessageId;
855
620
  const deferredCancels = this._deferredCancels;
856
621
  const requireConnected = this._requireConnected.bind(this);
857
- const registerInputEventListener = this._registerInputEventListener.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;
858
625
  const pullDeferredCancel = this._pullDeferredCancel.bind(this);
859
626
  const inputEventId = invocation.inputEventId;
860
627
 
861
- // `viewMessages` starts empty. `Run.start()` populates it via the
862
- // channel-rewind input-event lookup, pulling in user-message MessageNodes
863
- // as they arrive on the channel.
864
- const viewMessages: MessageNode<TMessage>[] = [];
865
- const view: RunView<TMessage> = {
866
- get messages() {
867
- return viewMessages;
868
- },
869
- };
870
-
871
628
  // Per-run metadata resolved from the input-event lookup result. The first
872
- // matched wire message's headers carry the run's `clientId`, `parent`, and
629
+ // matched message message's headers carry the run's `clientId`, `parent`, and
873
630
  // `forkOf`, and — for a continuation — the `run-id` it re-enters (a fresh
874
631
  // input carries none; the client stamps a run-id only when re-entering a
875
632
  // run it already knows). Its Ably-level publisher `clientId` becomes the
876
- // `inputClientId` re-stamped on the agent's own publishes. Captured
877
- // separately from `viewMessages` because tool-resolution wire messages
878
- // (`tool-output-available` etc.) decode to chunks and produce zero
879
- // MessageNodes — the metadata still needs to surface.
633
+ // `inputClientId` re-stamped on the agent's own publishes.
880
634
  let resolvedClientId: string | undefined;
881
635
  let resolvedInputClientId: string | undefined;
882
636
  let resolvedParent: string | undefined;
@@ -885,23 +639,47 @@ class DefaultAgentSession<
885
639
  let resolvedInputCodecMessageId: string | undefined;
886
640
  let resolvedContinuation = false;
887
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
+ };
888
674
  /**
889
675
  * The reply run's structural-parent fallback, computed once in
890
- * `Run.start()` (after the input-event lookup has populated `viewMessages`)
891
- * and consumed by every `Run.pipe()` publish. A per-stream
892
- * `streamOpts.parent` still overrides it. Storing it here keeps it stable
893
- * across pipes and decouples the assistant's structural parent from the
894
- * run-start wire's own `parent`.
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`.
895
681
  */
896
682
  let assistantParentFallback: string | undefined;
897
- /**
898
- * Raw Ably messages observed live by the input-event lookup. Passed to
899
- * `loadRunProjection` so the just-published client wires don't need
900
- * to wait on Ably's channel history indexing window. Empty when no
901
- * lookup ran or no messages matched.
902
- */
903
- let liveLookupMessages: readonly Ably.InboundMessage[] | undefined;
904
-
905
683
  /**
906
684
  * Remove this run from the session's routing maps. Drops the
907
685
  * `_registeredRuns` entry plus the `input-codec-message-id → run-id`
@@ -917,17 +695,33 @@ class DefaultAgentSession<
917
695
  }
918
696
  };
919
697
 
920
- // Most recently loaded projection for this run only, cached by
921
- // `Run.loadProjection()` and `Run.loadConversation()` so the `messages`
922
- // getter can return the run's folded messages. `undefined` before any
923
- // load call; the getter then falls back to the live `viewMessages`.
924
- let cachedProjection: TProjection | undefined;
925
-
926
- // Full multi-turn conversation, set by `Run.loadConversation()`. When set,
927
- // it takes priority over `cachedProjection` in the `messages` getter —
928
- // the getter then returns the complete ancestor-chain + current-run
929
- // messages instead of the current run alone.
930
- let cachedConversation: TMessage[] | undefined;
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
+ };
931
725
 
932
726
  const run: Run<TOutput, TProjection, TMessage> = {
933
727
  get runId() {
@@ -943,13 +737,18 @@ class DefaultAgentSession<
943
737
  return view;
944
738
  },
945
739
  get messages() {
946
- if (cachedConversation !== undefined) {
947
- return [...cachedConversation];
948
- }
949
- if (cachedProjection !== undefined) {
950
- return codec.getMessages(cachedProjection).map((m) => m.message);
951
- }
952
- return viewMessages.map((n) => n.message);
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);
953
752
  },
954
753
 
955
754
  // Spec: AIT-ST4, AIT-ST4a, AIT-ST4b
@@ -976,26 +775,21 @@ class DefaultAgentSession<
976
775
  // when no inputEventId is set (invocation requires no channel lookup).
977
776
  if (inputEventId && inputEventLookupTimeoutMs > 0) {
978
777
  try {
979
- const found = await lookupInputEvents<TInput, TOutput, TProjection, TMessage>({
980
- register: (callback) => registerInputEventListener([inputEventId], callback),
981
- codec,
778
+ const found = await getAgentView().findInputEvent({
982
779
  invocationId,
983
780
  runId,
984
- expectedInputEventIds: [inputEventId],
781
+ expectedEventIds: [inputEventId],
985
782
  timeoutMs: inputEventLookupTimeoutMs,
986
783
  signal,
987
- logger,
988
784
  });
989
- for (const m of found.nodes) viewMessages.push(m);
990
785
  if (found.firstHeaders !== undefined) firstLookupHeaders = found.firstHeaders;
991
786
  if (found.firstClientId !== undefined) resolvedInputClientId = found.firstClientId;
992
- liveLookupMessages = found.rawMessages;
993
787
  } catch (error) {
994
788
  const errInfo =
995
789
  error instanceof Ably.ErrorInfo
996
790
  ? error
997
791
  : new Ably.ErrorInfo(
998
- `unable to look up input event; ${error instanceof Error ? error.message : String(error)}`,
792
+ `unable to look up input event; ${errorMessage(error)}`,
999
793
  ErrorCode.InputEventNotFound,
1000
794
  504,
1001
795
  );
@@ -1010,16 +804,14 @@ class DefaultAgentSession<
1010
804
  }
1011
805
  }
1012
806
 
1013
- // Resolve per-run metadata from the first matched wire message's
807
+ // Resolve per-run metadata from the first matched message message's
1014
808
  // headers — they carry `clientId`, `parent`, and `forkOf`.
1015
809
  // Continuations of a suspended run pick up the suspended assistant's
1016
- // parent in the same headers (the continuation wire message parents off
810
+ // parent in the same headers (the continuation message parents off
1017
811
  // the assistant). A `run-id` on the triggering input marks a
1018
812
  // continuation (re-entry via `ai-run-resume`); a fresh input carries
1019
- // none and opens the run with `ai-run-start`. Fall back to the first
1020
- // MessageNode's headers for the path where the lookup ran with
1021
- // `viewMessages` already populated and no `firstHeaders` was captured.
1022
- const sourceHeaders = firstLookupHeaders ?? viewMessages[0]?.headers;
813
+ // none and opens the run with `ai-run-start`.
814
+ const sourceHeaders = firstLookupHeaders;
1023
815
  if (sourceHeaders) {
1024
816
  resolvedClientId = sourceHeaders[HEADER_RUN_CLIENT_ID];
1025
817
  resolvedParent = sourceHeaders[HEADER_PARENT];
@@ -1043,12 +835,17 @@ class DefaultAgentSession<
1043
835
  }
1044
836
  }
1045
837
 
1046
- // Compute the reply run's structural-parent fallback now that the
1047
- // lookup has populated `viewMessages`: the triggering user message,
1048
- // or for regenerate wires that match by inputEventId but produce no
1049
- // MessageNodes the input wire's own `parent`. `Run.pipe()` consumes
1050
- // this for every assistant publish.
1051
- assistantParentFallback = viewMessages.at(-1)?.codecMessageId ?? resolvedParent;
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;
1052
849
 
1053
850
  // The triggering input's codec-message-id is now resolved, so the
1054
851
  // `input-codec-message-id → run` linkage exists: index it for live
@@ -1062,11 +859,11 @@ class DefaultAgentSession<
1062
859
  await pullDeferredCancel(registration, resolvedInputCodecMessageId);
1063
860
  }
1064
861
 
1065
- try {
1066
- await runManager.startRun(runId, resolvedClientId, controller, {
862
+ await publishLifecycle('run-start', 'start', async () =>
863
+ runManager.startRun(runId, resolvedClientId, controller, {
1067
864
  // Stamp the reply run's STRUCTURAL parent (its input node, M_user) —
1068
- // the same value the output path stamps — not the input wire's own
1069
- // parent. Makes `parent` structural on every wire so the Tree's two
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
1070
867
  // creation paths agree regardless of arrival order. Valid only now
1071
868
  // that M_user is a separate input node (the two-node flip).
1072
869
  parent: assistantParentFallback,
@@ -1076,105 +873,48 @@ class DefaultAgentSession<
1076
873
  inputClientId: resolvedInputClientId,
1077
874
  inputCodecMessageId: resolvedInputCodecMessageId,
1078
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 }),
1079
897
  });
1080
- } catch (error) {
1081
- const errInfo = new Ably.ErrorInfo(
1082
- `unable to publish run-start for run ${runId}; ${error instanceof Error ? error.message : String(error)}`,
1083
- ErrorCode.RunLifecycleError,
1084
- 500,
1085
- error instanceof Ably.ErrorInfo ? error : undefined,
1086
- );
1087
- logger?.error('Run.start(); failed to publish run-start', { runId });
1088
- throw errInfo;
1089
898
  }
1090
899
 
1091
900
  logger?.debug('Run.start(); run started', { runId, inputEventId });
1092
901
  },
1093
902
 
1094
- // Spec: AIT-ST5c
1095
- addEvents: async (nodes: EventsNode<TOutput>[]): Promise<void> => {
1096
- logger?.trace('Run.addEvents();', { runId, count: nodes.length });
1097
-
1098
- await requireConnected('addEvents');
1099
-
1100
- if (state === RunState.INITIALIZED) {
1101
- throw new Ably.ErrorInfo(
1102
- `unable to add events; start() must be called before addEvents() (run ${runId})`,
1103
- ErrorCode.InvalidArgument,
1104
- 400,
1105
- );
1106
- }
1107
-
1108
- const runOwnerClientId = runManager.getClientId(runId);
1109
-
1110
- try {
1111
- for (const node of nodes) {
1112
- const headers = buildTransportHeaders({
1113
- role: 'assistant',
1114
- runId,
1115
- codecMessageId: node.codecMessageId,
1116
- runClientId: runOwnerClientId,
1117
- invocationId,
1118
- inputClientId: resolvedInputClientId,
1119
- inputCodecMessageId: resolvedInputCodecMessageId,
1120
- });
1121
-
1122
- const encoder = codec.createEncoder(channel, {
1123
- extras: { headers },
1124
- onMessage,
1125
- });
1126
-
1127
- for (const event of node.events) {
1128
- await encoder.publishOutput(event);
1129
- }
1130
-
1131
- await encoder.close();
1132
- }
1133
- } catch (error) {
1134
- const errInfo = new Ably.ErrorInfo(
1135
- `unable to publish events for run ${runId}; ${error instanceof Error ? error.message : String(error)}`,
1136
- ErrorCode.RunLifecycleError,
1137
- 500,
1138
- error instanceof Ably.ErrorInfo ? error : undefined,
1139
- );
1140
- logger?.error('Run.addEvents(); publish failed', { runId });
1141
- throw errInfo;
1142
- }
1143
-
1144
- logger?.debug('Run.addEvents(); events published', { runId, count: nodes.length });
1145
- },
1146
-
1147
- loadProjection: async (): Promise<TProjection> => {
1148
- logger?.trace('Run.loadProjection();', { runId });
1149
- await requireConnected('loadProjection');
1150
- const projection = await loadRunProjection<TInput, TOutput, TProjection, TMessage>({
1151
- channel,
1152
- codec,
1153
- runId,
1154
- signal,
1155
- logger,
1156
- liveMessages: liveLookupMessages,
1157
- });
1158
- cachedProjection = projection;
1159
- return projection;
1160
- },
1161
-
1162
903
  loadConversation: async (options?: LoadConversationOptions): Promise<TMessage[]> => {
1163
904
  logger?.trace('Run.loadConversation();', { runId });
1164
905
  await requireConnected('loadConversation');
1165
- const { messages, projection } = await loadConversation<TInput, TOutput, TProjection, TMessage>({
1166
- channel,
1167
- codec,
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(
1168
911
  runId,
1169
- signal,
1170
- logger,
1171
- liveMessages: liveLookupMessages,
1172
912
  assistantParentFallback,
1173
- pageLimit: options?.pageLimit ?? 200,
1174
- maxMessages: options?.maxMessages ?? 2000,
1175
- });
1176
- cachedProjection = projection;
1177
- cachedConversation = messages;
913
+ signal,
914
+ options?.maxRuns,
915
+ runIdOverridden || resolvedContinuation,
916
+ resolvedRegenerates,
917
+ );
1178
918
  return messages;
1179
919
  },
1180
920
 
@@ -1198,14 +938,14 @@ class DefaultAgentSession<
1198
938
  // `streamOpts.parent` from the caller, else the reply run's
1199
939
  // structural-parent fallback computed once at run-start
1200
940
  // (`assistantParentFallback` — the triggering user message, or the
1201
- // input wire's own parent for regenerate wires that produced no
941
+ // input message's own parent for regenerate messages that produced no
1202
942
  // MessageNodes). Owning the default here means agent routes don't have
1203
943
  // to pass `{ parent: lastUserCodecMessageId }` to keep tree threading
1204
944
  // correct; edit-then-regenerate sibling resolution relies on the
1205
945
  // user→assistant chain being explicit.
1206
946
  const assistantParent = streamOpts?.parent ?? assistantParentFallback;
1207
947
  const assistantForkOf = streamOpts?.forkOf ?? resolvedForkOf;
1208
- // Echo `msg-regenerate` on the assistant wire so that a
948
+ // Echo `msg-regenerate` on the assistant message so that a
1209
949
  // client receiving the assistant chunk before `ai-run-start`
1210
950
  // (e.g. via history pagination across a page boundary, or a lost
1211
951
  // lifecycle publish) can still populate `RunNode.regeneratesCodecMessageId`
@@ -1239,12 +979,25 @@ class DefaultAgentSession<
1239
979
  `unable to pipe response for run ${runId}; ${result.error.message}`,
1240
980
  ErrorCode.StreamError,
1241
981
  500,
1242
- result.error instanceof Ably.ErrorInfo ? result.error : undefined,
982
+ errorCause(result.error),
1243
983
  );
1244
984
  logger?.error('Run.pipe(); stream error', { runId });
1245
985
  runOnError?.(errInfo);
1246
986
  }
1247
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
+
1248
1001
  logger?.debug('Run.pipe(); stream finished', { runId, reason: result.reason });
1249
1002
  return result;
1250
1003
  },
@@ -1267,16 +1020,9 @@ class DefaultAgentSession<
1267
1020
  state = RunState.ENDED;
1268
1021
 
1269
1022
  try {
1270
- await runManager.suspendRun(runId, invocationId, resolvedInputClientId, resolvedInputCodecMessageId);
1271
- } catch (error) {
1272
- const errInfo = new Ably.ErrorInfo(
1273
- `unable to publish run-suspend for run ${runId}; ${error instanceof Error ? error.message : String(error)}`,
1274
- ErrorCode.RunLifecycleError,
1275
- 500,
1276
- error instanceof Ably.ErrorInfo ? error : undefined,
1023
+ await publishLifecycle('run-suspend', 'suspend', async () =>
1024
+ runManager.suspendRun(runId, invocationId, resolvedInputClientId, resolvedInputCodecMessageId),
1277
1025
  );
1278
- logger?.error('Run.suspend(); failed to publish run-suspend', { runId });
1279
- throw errInfo;
1280
1026
  } finally {
1281
1027
  deregisterRun();
1282
1028
  }
@@ -1285,7 +1031,9 @@ class DefaultAgentSession<
1285
1031
  },
1286
1032
 
1287
1033
  // Spec: AIT-ST7, AIT-ST7a, AIT-ST7b
1288
- end: async (reason: RunEndReason): Promise<void> => {
1034
+ end: async (params: RunEndParams): Promise<void> => {
1035
+ const { reason } = params;
1036
+ const error = params.reason === 'error' ? params.error : undefined;
1289
1037
  logger?.trace('Run.end();', { runId, reason });
1290
1038
 
1291
1039
  await requireConnected('end');
@@ -1301,16 +1049,9 @@ class DefaultAgentSession<
1301
1049
  state = RunState.ENDED;
1302
1050
 
1303
1051
  try {
1304
- await runManager.endRun(runId, reason, invocationId, resolvedInputClientId, resolvedInputCodecMessageId);
1305
- } catch (error) {
1306
- const errInfo = new Ably.ErrorInfo(
1307
- `unable to publish run-end for run ${runId}; ${error instanceof Error ? error.message : String(error)}`,
1308
- ErrorCode.RunLifecycleError,
1309
- 500,
1310
- error instanceof Ably.ErrorInfo ? error : undefined,
1052
+ await publishLifecycle('run-end', 'end', async () =>
1053
+ runManager.endRun(runId, reason, invocationId, resolvedInputClientId, resolvedInputCodecMessageId, error),
1311
1054
  );
1312
- logger?.error('Run.end(); failed to publish run-end', { runId });
1313
- throw errInfo;
1314
1055
  } finally {
1315
1056
  deregisterRun();
1316
1057
  }