@ably/ai-transport 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +91 -100
  2. package/dist/ably-ai-transport.js +1553 -1238
  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 +29 -0
  8. package/dist/core/codec/decoder.d.ts +20 -23
  9. package/dist/core/codec/encoder.d.ts +11 -8
  10. package/dist/core/codec/index.d.ts +1 -2
  11. package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
  12. package/dist/core/codec/types.d.ts +407 -115
  13. package/dist/core/transport/agent-session.d.ts +10 -0
  14. package/dist/core/transport/branch-chain.d.ts +43 -0
  15. package/dist/core/transport/client-session.d.ts +13 -0
  16. package/dist/core/transport/decode-fold.d.ts +47 -0
  17. package/dist/core/transport/headers.d.ts +96 -18
  18. package/dist/core/transport/index.d.ts +5 -6
  19. package/dist/core/transport/internal/bounded-map.d.ts +20 -0
  20. package/dist/core/transport/invocation.d.ts +74 -0
  21. package/dist/core/transport/load-conversation.d.ts +128 -0
  22. package/dist/core/transport/load-history.d.ts +39 -0
  23. package/dist/core/transport/pipe-stream.d.ts +9 -9
  24. package/dist/core/transport/run-manager.d.ts +78 -0
  25. package/dist/core/transport/tree.d.ts +373 -109
  26. package/dist/core/transport/types/agent.d.ts +353 -0
  27. package/dist/core/transport/types/client.d.ts +168 -0
  28. package/dist/core/transport/types/shared.d.ts +24 -0
  29. package/dist/core/transport/types/tree.d.ts +315 -0
  30. package/dist/core/transport/types/view.d.ts +222 -0
  31. package/dist/core/transport/types.d.ts +13 -553
  32. package/dist/core/transport/view.d.ts +272 -84
  33. package/dist/errors.d.ts +21 -10
  34. package/dist/index.d.ts +6 -8
  35. package/dist/logger.d.ts +12 -0
  36. package/dist/react/ably-ai-transport-react.js +976 -990
  37. package/dist/react/ably-ai-transport-react.js.map +1 -1
  38. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  39. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  40. package/dist/react/contexts/client-session-context.d.ts +36 -0
  41. package/dist/react/contexts/client-session-provider.d.ts +53 -0
  42. package/dist/react/create-session-hooks.d.ts +116 -0
  43. package/dist/react/index.d.ts +12 -12
  44. package/dist/react/internal/use-resolved-session.d.ts +36 -0
  45. package/dist/react/use-ably-messages.d.ts +17 -14
  46. package/dist/react/use-client-session.d.ts +81 -0
  47. package/dist/react/use-create-view.d.ts +14 -13
  48. package/dist/react/use-tree.d.ts +30 -15
  49. package/dist/react/use-view.d.ts +82 -51
  50. package/dist/utils.d.ts +32 -23
  51. package/dist/vercel/ably-ai-transport-vercel.js +2573 -2086
  52. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  53. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  54. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  55. package/dist/vercel/codec/decoder.d.ts +5 -18
  56. package/dist/vercel/codec/encoder.d.ts +6 -36
  57. package/dist/vercel/codec/events.d.ts +51 -0
  58. package/dist/vercel/codec/index.d.ts +24 -12
  59. package/dist/vercel/codec/reducer.d.ts +144 -0
  60. package/dist/vercel/codec/tool-transitions.d.ts +2 -2
  61. package/dist/vercel/index.d.ts +4 -5
  62. package/dist/vercel/react/ably-ai-transport-vercel-react.js +3907 -3266
  63. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  64. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +33 -8
  65. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  66. package/dist/vercel/react/contexts/chat-transport-context.d.ts +7 -6
  67. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
  68. package/dist/vercel/react/index.d.ts +1 -2
  69. package/dist/vercel/react/use-chat-transport.d.ts +30 -26
  70. package/dist/vercel/react/use-message-sync.d.ts +17 -30
  71. package/dist/vercel/run-end-reason.d.ts +29 -0
  72. package/dist/vercel/transport/chat-transport.d.ts +43 -24
  73. package/dist/vercel/transport/index.d.ts +25 -21
  74. package/dist/vercel/transport/run-output-stream.d.ts +56 -0
  75. package/dist/version.d.ts +2 -0
  76. package/package.json +30 -23
  77. package/src/constants.ts +124 -51
  78. package/src/core/agent.ts +68 -0
  79. package/src/core/codec/decoder.ts +71 -98
  80. package/src/core/codec/encoder.ts +113 -65
  81. package/src/core/codec/index.ts +13 -6
  82. package/src/core/codec/lifecycle-tracker.ts +10 -9
  83. package/src/core/codec/types.ts +436 -120
  84. package/src/core/transport/agent-session.ts +1344 -0
  85. package/src/core/transport/branch-chain.ts +58 -0
  86. package/src/core/transport/client-session.ts +775 -0
  87. package/src/core/transport/decode-fold.ts +91 -0
  88. package/src/core/transport/headers.ts +181 -22
  89. package/src/core/transport/index.ts +25 -26
  90. package/src/core/transport/internal/bounded-map.ts +27 -0
  91. package/src/core/transport/invocation.ts +98 -0
  92. package/src/core/transport/load-conversation.ts +355 -0
  93. package/src/core/transport/load-history.ts +269 -0
  94. package/src/core/transport/pipe-stream.ts +54 -39
  95. package/src/core/transport/run-manager.ts +249 -0
  96. package/src/core/transport/tree.ts +926 -308
  97. package/src/core/transport/types/agent.ts +407 -0
  98. package/src/core/transport/types/client.ts +211 -0
  99. package/src/core/transport/types/shared.ts +27 -0
  100. package/src/core/transport/types/tree.ts +344 -0
  101. package/src/core/transport/types/view.ts +259 -0
  102. package/src/core/transport/types.ts +13 -706
  103. package/src/core/transport/view.ts +864 -433
  104. package/src/errors.ts +22 -9
  105. package/src/event-emitter.ts +3 -2
  106. package/src/index.ts +52 -41
  107. package/src/logger.ts +14 -1
  108. package/src/react/contexts/client-session-context.ts +41 -0
  109. package/src/react/contexts/client-session-provider.tsx +186 -0
  110. package/src/react/create-session-hooks.ts +141 -0
  111. package/src/react/index.ts +23 -13
  112. package/src/react/internal/use-resolved-session.ts +63 -0
  113. package/src/react/use-ably-messages.ts +32 -22
  114. package/src/react/use-client-session.ts +201 -0
  115. package/src/react/use-create-view.ts +33 -29
  116. package/src/react/use-tree.ts +61 -30
  117. package/src/react/use-view.ts +139 -97
  118. package/src/utils.ts +63 -45
  119. package/src/vercel/codec/decoder.ts +336 -258
  120. package/src/vercel/codec/encoder.ts +343 -205
  121. package/src/vercel/codec/events.ts +87 -0
  122. package/src/vercel/codec/index.ts +60 -13
  123. package/src/vercel/codec/reducer.ts +977 -0
  124. package/src/vercel/codec/tool-transitions.ts +2 -2
  125. package/src/vercel/index.ts +6 -19
  126. package/src/vercel/react/contexts/chat-transport-context.ts +7 -6
  127. package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
  128. package/src/vercel/react/index.ts +3 -5
  129. package/src/vercel/react/use-chat-transport.ts +47 -49
  130. package/src/vercel/react/use-message-sync.ts +80 -39
  131. package/src/vercel/run-end-reason.ts +78 -0
  132. package/src/vercel/transport/chat-transport.ts +392 -98
  133. package/src/vercel/transport/index.ts +39 -38
  134. package/src/vercel/transport/run-output-stream.ts +170 -0
  135. package/src/version.ts +2 -0
  136. package/dist/core/transport/client-transport.d.ts +0 -10
  137. package/dist/core/transport/decode-history.d.ts +0 -43
  138. package/dist/core/transport/server-transport.d.ts +0 -7
  139. package/dist/core/transport/stream-router.d.ts +0 -29
  140. package/dist/core/transport/turn-manager.d.ts +0 -37
  141. package/dist/react/contexts/transport-context.d.ts +0 -31
  142. package/dist/react/contexts/transport-provider.d.ts +0 -49
  143. package/dist/react/create-transport-hooks.d.ts +0 -124
  144. package/dist/react/use-active-turns.d.ts +0 -12
  145. package/dist/react/use-client-transport.d.ts +0 -80
  146. package/dist/vercel/codec/accumulator.d.ts +0 -21
  147. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
  148. package/dist/vercel/tool-approvals.d.ts +0 -124
  149. package/dist/vercel/tool-events.d.ts +0 -26
  150. package/src/core/transport/client-transport.ts +0 -977
  151. package/src/core/transport/decode-history.ts +0 -485
  152. package/src/core/transport/server-transport.ts +0 -612
  153. package/src/core/transport/stream-router.ts +0 -136
  154. package/src/core/transport/turn-manager.ts +0 -165
  155. package/src/react/contexts/transport-context.ts +0 -37
  156. package/src/react/contexts/transport-provider.tsx +0 -164
  157. package/src/react/create-transport-hooks.ts +0 -144
  158. package/src/react/use-active-turns.ts +0 -72
  159. package/src/react/use-client-transport.ts +0 -197
  160. package/src/vercel/codec/accumulator.ts +0 -588
  161. package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
  162. package/src/vercel/tool-approvals.ts +0 -380
  163. package/src/vercel/tool-events.ts +0 -53
@@ -1,53 +1,68 @@
1
1
  /**
2
2
  * Pure stream piping function.
3
3
  *
4
- * Reads events from a ReadableStream, writes them to a streaming encoder,
5
- * and handles abort/error. No dependencies on turn state or transport internals.
4
+ * Reads outputs from a ReadableStream, writes them to an encoder via
5
+ * `publishOutput`, and handles cancel/error. No dependencies on run
6
+ * state or session internals.
6
7
  */
7
8
 
8
9
  import type { Logger } from '../../logger.js';
9
- import type { StreamEncoder, WriteOptions } from '../codec/types.js';
10
+ import type { CodecInputEvent, CodecOutputEvent, Encoder, WriteOptions } from '../codec/types.js';
10
11
  import type { StreamResult } from './types.js';
11
12
 
12
13
  /**
13
- * Pipe an event stream through an encoder to the channel.
14
+ * Adapt an AbortSignal into a promise that resolves once the signal aborts,
15
+ * paired with a cleanup that detaches the listener. With no signal the promise
16
+ * never resolves (there is no cancellation path); an already-aborted signal
17
+ * resolves immediately. `cleanup` is a no-op unless a listener was attached.
18
+ * @param signal - The AbortSignal to watch, or undefined for no cancellation.
19
+ * @returns The abort promise and a cleanup to call when racing is done.
20
+ */
21
+ const abortSignalToPromise = (signal: AbortSignal | undefined): { promise: Promise<void>; cleanup: () => void } => {
22
+ let listener: (() => void) | undefined;
23
+ const promise =
24
+ signal === undefined
25
+ ? // eslint-disable-next-line @typescript-eslint/no-empty-function -- never-resolving promise: no signal means no cancellation path
26
+ new Promise<void>(() => {})
27
+ : signal.aborted
28
+ ? Promise.resolve()
29
+ : new Promise<void>((resolve) => {
30
+ listener = () => {
31
+ resolve();
32
+ };
33
+ signal.addEventListener('abort', listener, { once: true });
34
+ });
35
+ const cleanup = (): void => {
36
+ if (listener && signal) signal.removeEventListener('abort', listener);
37
+ };
38
+ return { promise, cleanup };
39
+ };
40
+
41
+ /**
42
+ * Pipe an output stream through an encoder to the channel.
14
43
  *
15
44
  * Returns when the stream completes, is cancelled (via signal), or errors.
16
45
  * The `reason` field of the result indicates which case occurred.
17
- * @param stream - The event stream to read from.
18
- * @param encoder - The streaming encoder to write events through.
19
- * @param signal - Abort signal to monitor for cancellation.
20
- * @param onAbort - Optional callback invoked when the stream is cancelled, before the stream ends.
21
- * @param resolveWriteOptions - Optional per-event hook returning {@link WriteOptions} overrides to pass to `encoder.appendEvent`.
46
+ * @param stream - The output stream to read from.
47
+ * @param encoder - The encoder to publish outputs through.
48
+ * @param signal - AbortSignal to monitor for cancellation.
49
+ * @param onCancelled - Optional callback invoked when the stream is cancelled, before the stream ends.
50
+ * @param resolveWriteOptions - Optional per-output hook returning {@link WriteOptions} overrides to pass to `encoder.publishOutput`.
22
51
  * @param logger - Optional logger for diagnostic output.
23
- * @returns The reason the pipe ended.
52
+ * @returns A {@link StreamResult}: `reason` is why the pipe ended, and `error` holds the caught error when `reason` is `'error'`.
24
53
  */
25
- export const pipeStream = async <TEvent, TMessage>(
26
- stream: ReadableStream<TEvent>,
27
- encoder: StreamEncoder<TEvent, TMessage>,
54
+ export const pipeStream = async <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent>(
55
+ stream: ReadableStream<TOutput>,
56
+ encoder: Encoder<TInput, TOutput>,
28
57
  signal: AbortSignal | undefined,
29
- onAbort?: (write: (event: TEvent) => Promise<void>) => void | Promise<void>,
30
- resolveWriteOptions?: (event: TEvent) => WriteOptions | undefined,
58
+ onCancelled?: (write: (output: TOutput) => Promise<void>) => void | Promise<void>,
59
+ resolveWriteOptions?: (output: TOutput) => WriteOptions | undefined,
31
60
  logger?: Logger,
32
61
  ): Promise<StreamResult> => {
33
62
  logger?.trace('pipeStream();');
34
63
 
35
64
  const reader = stream.getReader();
36
-
37
- let abortListener: (() => void) | undefined;
38
- const abortPromise = signal
39
- ? new Promise<void>((resolve) => {
40
- if (signal.aborted) {
41
- resolve();
42
- return;
43
- }
44
- abortListener = () => {
45
- resolve();
46
- };
47
- signal.addEventListener('abort', abortListener, { once: true });
48
- })
49
- : // eslint-disable-next-line @typescript-eslint/no-empty-function -- never-resolving promise: no signal means no cancellation path
50
- new Promise<void>(() => {});
65
+ const abort = abortSignalToPromise(signal);
51
66
 
52
67
  let reason: StreamResult['reason'] = 'complete';
53
68
  let caughtError: Error | undefined;
@@ -55,17 +70,17 @@ export const pipeStream = async <TEvent, TMessage>(
55
70
  try {
56
71
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop broken by return/break
57
72
  while (true) {
58
- // .then() is intentional: transforms the abort signal into a discriminant
73
+ // .then() is intentional: transforms the AbortSignal into a discriminant
59
74
  // for Promise.race — no async/await equivalent for this pattern.
60
- const result = await Promise.race([reader.read(), abortPromise.then(() => 'aborted' as const)]);
75
+ const result = await Promise.race([reader.read(), abort.promise.then(() => 'cancelled' as const)]);
61
76
 
62
- if (result === 'aborted') {
77
+ if (result === 'cancelled') {
63
78
  reason = 'cancelled';
64
- logger?.debug('pipeStream(); stream cancelled by abort signal');
65
- if (onAbort) {
66
- await onAbort(async (event: TEvent) => encoder.appendEvent(event));
79
+ logger?.debug('pipeStream(); stream cancelled by AbortSignal');
80
+ if (onCancelled) {
81
+ await onCancelled(async (output: TOutput) => encoder.publishOutput(output));
67
82
  }
68
- await encoder.abort('cancelled');
83
+ await encoder.cancel('cancelled');
69
84
  break;
70
85
  }
71
86
 
@@ -76,7 +91,7 @@ export const pipeStream = async <TEvent, TMessage>(
76
91
  break;
77
92
  }
78
93
 
79
- await encoder.appendEvent(value, resolveWriteOptions?.(value));
94
+ await encoder.publishOutput(value, resolveWriteOptions?.(value));
80
95
  }
81
96
  } catch (error) {
82
97
  reason = 'error';
@@ -90,7 +105,7 @@ export const pipeStream = async <TEvent, TMessage>(
90
105
  // the StreamResult reason ("error").
91
106
  }
92
107
  } finally {
93
- if (abortListener) signal?.removeEventListener('abort', abortListener);
108
+ abort.cleanup();
94
109
  reader.releaseLock();
95
110
  }
96
111
 
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Server-side run state management and lifecycle event publishing.
3
+ *
4
+ * Owns the authoritative run lifecycle. Tracks active runs with their
5
+ * AbortControllers and clientIds. Publishes run-start, run-resume, run-suspend, and
6
+ * run-end events on the Ably channel so all clients can react to run
7
+ * state changes.
8
+ */
9
+
10
+ import type * as Ably from 'ably';
11
+
12
+ import { EVENT_RUN_END, EVENT_RUN_RESUME, EVENT_RUN_START, EVENT_RUN_SUSPEND } from '../../constants.js';
13
+ import type { Logger } from '../../logger.js';
14
+ import { buildLifecycleHeaders } from './headers.js';
15
+ import type { RunEndReason } from './types.js';
16
+
17
+ /**
18
+ * Per-invocation metadata carried on a run's opening lifecycle event. A
19
+ * continuation (re-entering an existing run) sets `continuation` and omits the
20
+ * structural `parent` / `forkOf` / `regenerates` fields.
21
+ */
22
+ export interface StartRunMetadata {
23
+ /** Structural parent codec-message-id (fresh run-start only). */
24
+ parent?: string;
25
+ /** Forked user-prompt codec-message-id for an edit (fresh run-start only). */
26
+ forkOf?: string;
27
+ /** Regenerated assistant codec-message-id (fresh run-start only). */
28
+ regenerates?: string;
29
+ /** Agent-minted invocation id, carried on the lifecycle event. */
30
+ invocationId?: string;
31
+ /** ClientId of the triggering input event. */
32
+ inputClientId?: string;
33
+ /** Codec-message-id of the triggering input event. */
34
+ inputCodecMessageId?: string;
35
+ /** When true, publish `ai-run-resume` (re-entry) instead of `ai-run-start`. */
36
+ continuation?: boolean;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Interface
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /** Manages active runs and publishes run lifecycle events on the channel. */
44
+ export interface RunManager {
45
+ /**
46
+ * Register a run and publish its opening lifecycle event. Publishes
47
+ * `ai-run-start` for a fresh run, or `ai-run-resume` when `metadata.continuation`
48
+ * is set (a subsequent invocation re-entering an existing run). A resume omits
49
+ * the structural `parent` / `forkOf` / `regenerates` headers — the original
50
+ * run-start owns the run's structure. Returns the run's AbortSignal.
51
+ */
52
+ startRun(
53
+ runId: string,
54
+ clientId?: string,
55
+ controller?: AbortController,
56
+ metadata?: StartRunMetadata,
57
+ ): Promise<AbortSignal>;
58
+ /**
59
+ * Suspend a run. Publishes run-suspend on the channel and drops the run's
60
+ * active-run entry — the agent process terminates on suspend, so there is no
61
+ * live AbortController to retain. A cancel arriving during suspension is a
62
+ * no-op; the resuming invocation re-registers the run via {@link startRun}.
63
+ * Carries the same per-invocation attribution as {@link endRun}
64
+ * (`inputClientId`, `inputCodecMessageId`), since a suspend is the terminal
65
+ * event of the suspending invocation just as run-end is of an ending one.
66
+ */
67
+ suspendRun(runId: string, invocationId?: string, inputClientId?: string, inputCodecMessageId?: string): Promise<void>;
68
+ /**
69
+ * End a run. Publishes run-end on the channel (stamping `reason` as the
70
+ * run-reason header) and drops the run's active-run entry. Carries the same
71
+ * per-invocation attribution as {@link suspendRun} (`invocationId`,
72
+ * `inputClientId`, `inputCodecMessageId`), since run-end is the terminal event
73
+ * of the ending invocation.
74
+ */
75
+ endRun(
76
+ runId: string,
77
+ reason: RunEndReason,
78
+ invocationId?: string,
79
+ inputClientId?: string,
80
+ inputCodecMessageId?: string,
81
+ ): Promise<void>;
82
+ /** Get the AbortSignal for a run. */
83
+ getSignal(runId: string): AbortSignal | undefined;
84
+ /** Get the clientId that owns a run. */
85
+ getClientId(runId: string): string | undefined;
86
+ /** Fire the AbortSignal for a run to cancel any in-flight work. */
87
+ cancel(runId: string): void;
88
+ /** Get all active run IDs. */
89
+ getActiveRunIds(): string[];
90
+ /** Cancel all active runs and clear state. */
91
+ close(): void;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Internal state
96
+ // ---------------------------------------------------------------------------
97
+
98
+ interface ActiveRunEntry {
99
+ controller: AbortController;
100
+ clientId: string;
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Implementation
105
+ // ---------------------------------------------------------------------------
106
+
107
+ class DefaultRunManager implements RunManager {
108
+ private readonly _channel: Ably.RealtimeChannel;
109
+ private readonly _logger: Logger | undefined;
110
+ private readonly _activeRuns = new Map<string, ActiveRunEntry>();
111
+
112
+ constructor(channel: Ably.RealtimeChannel, logger?: Logger) {
113
+ this._channel = channel;
114
+ this._logger = logger?.withContext({ component: 'RunManager' });
115
+ }
116
+
117
+ async startRun(
118
+ runId: string,
119
+ clientId?: string,
120
+ externalController?: AbortController,
121
+ metadata?: StartRunMetadata,
122
+ ): Promise<AbortSignal> {
123
+ this._logger?.trace('DefaultRunManager.startRun();', { runId, clientId });
124
+
125
+ const controller = externalController ?? new AbortController();
126
+ const resolvedClientId = clientId ?? '';
127
+ this._activeRuns.set(runId, { controller, clientId: resolvedClientId });
128
+
129
+ // A continuation re-enters an already-started run: publish `ai-run-resume`
130
+ // rather than `ai-run-start`. Resume is a pure re-entry signal — the
131
+ // original run-start already established the run's structure, so the
132
+ // parent / forkOf / regenerates metadata is NOT re-stamped here (doing so
133
+ // would point the run at content within itself). The agent learned this is
134
+ // a continuation from the run-id on the triggering input; the re-entry is
135
+ // conveyed to clients by the event name, not a header echo. The
136
+ // invocation-id / input attribution headers are carried on both.
137
+ const continuation = metadata?.continuation === true;
138
+
139
+ const headers = buildLifecycleHeaders({
140
+ runId,
141
+ runClientId: resolvedClientId,
142
+ parent: continuation ? undefined : metadata?.parent,
143
+ forkOf: continuation ? undefined : metadata?.forkOf,
144
+ regenerates: continuation ? undefined : metadata?.regenerates,
145
+ invocationId: metadata?.invocationId,
146
+ inputClientId: metadata?.inputClientId,
147
+ inputCodecMessageId: metadata?.inputCodecMessageId,
148
+ });
149
+
150
+ await this._channel.publish({
151
+ name: continuation ? EVENT_RUN_RESUME : EVENT_RUN_START,
152
+ extras: { ai: { transport: headers } },
153
+ });
154
+
155
+ this._logger?.debug('DefaultRunManager.startRun(); run started', { runId });
156
+ return controller.signal;
157
+ }
158
+
159
+ async suspendRun(
160
+ runId: string,
161
+ invocationId?: string,
162
+ inputClientId?: string,
163
+ inputCodecMessageId?: string,
164
+ ): Promise<void> {
165
+ this._logger?.trace('DefaultRunManager.suspendRun();', { runId });
166
+ await this._publishTerminal(EVENT_RUN_SUSPEND, runId, { invocationId, inputClientId, inputCodecMessageId });
167
+ this._logger?.debug('DefaultRunManager.suspendRun(); run suspended', { runId });
168
+ }
169
+
170
+ async endRun(
171
+ runId: string,
172
+ reason: RunEndReason,
173
+ invocationId?: string,
174
+ inputClientId?: string,
175
+ inputCodecMessageId?: string,
176
+ ): Promise<void> {
177
+ this._logger?.trace('DefaultRunManager.endRun();', { runId, reason });
178
+ await this._publishTerminal(EVENT_RUN_END, runId, { reason, invocationId, inputClientId, inputCodecMessageId });
179
+ this._logger?.debug('DefaultRunManager.endRun(); run ended', { runId, reason });
180
+ }
181
+
182
+ /**
183
+ * Publish a run's terminal lifecycle event (run-suspend or run-end) and drop
184
+ * its active-run entry. Both events are the suspending/ending invocation's
185
+ * terminal signal, carrying the same per-invocation correlation; they differ
186
+ * only by event name and the run-reason header (run-end). Publishes BEFORE
187
+ * dropping local state so a publish failure leaves the run in the active set.
188
+ * @param eventName - The lifecycle event to publish (run-suspend or run-end).
189
+ * @param runId - The run being suspended or ended.
190
+ * @param attribution - Per-invocation correlation and the terminal reason.
191
+ * @param attribution.reason - Terminal reason; set for run-end, omitted for run-suspend.
192
+ * @param attribution.invocationId - The invocation's id.
193
+ * @param attribution.inputClientId - ClientId of the triggering input event.
194
+ * @param attribution.inputCodecMessageId - Codec-message-id of the triggering input event.
195
+ */
196
+ private async _publishTerminal(
197
+ eventName: string,
198
+ runId: string,
199
+ attribution: {
200
+ reason?: RunEndReason;
201
+ invocationId?: string;
202
+ inputClientId?: string;
203
+ inputCodecMessageId?: string;
204
+ },
205
+ ): Promise<void> {
206
+ const resolvedClientId = this._activeRuns.get(runId)?.clientId ?? '';
207
+ const headers = buildLifecycleHeaders({ runId, runClientId: resolvedClientId, ...attribution });
208
+ await this._channel.publish({ name: eventName, extras: { ai: { transport: headers } } });
209
+ this._activeRuns.delete(runId);
210
+ }
211
+
212
+ getSignal(runId: string): AbortSignal | undefined {
213
+ return this._activeRuns.get(runId)?.controller.signal;
214
+ }
215
+
216
+ getClientId(runId: string): string | undefined {
217
+ return this._activeRuns.get(runId)?.clientId;
218
+ }
219
+
220
+ cancel(runId: string): void {
221
+ this._logger?.debug('DefaultRunManager.cancel();', { runId });
222
+ this._activeRuns.get(runId)?.controller.abort();
223
+ }
224
+
225
+ getActiveRunIds(): string[] {
226
+ return [...this._activeRuns.keys()];
227
+ }
228
+
229
+ close(): void {
230
+ this._logger?.trace('DefaultRunManager.close();', { activeRuns: this._activeRuns.size });
231
+ for (const state of this._activeRuns.values()) {
232
+ state.controller.abort();
233
+ }
234
+ this._activeRuns.clear();
235
+ }
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Factory
240
+ // ---------------------------------------------------------------------------
241
+
242
+ /**
243
+ * Create a run manager bound to the given channel.
244
+ * @param channel - The Ably channel to publish lifecycle events on.
245
+ * @param logger - Optional logger for diagnostic output.
246
+ * @returns A new {@link RunManager} instance.
247
+ */
248
+ export const createRunManager = (channel: Ably.RealtimeChannel, logger?: Logger): RunManager =>
249
+ new DefaultRunManager(channel, logger);