@ably/ai-transport 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -111
- package/dist/ably-ai-transport.js +2401 -1387
- package/dist/ably-ai-transport.js.map +1 -1
- package/dist/ably-ai-transport.umd.cjs +1 -1
- package/dist/ably-ai-transport.umd.cjs.map +1 -1
- package/dist/constants.d.ts +116 -42
- package/dist/core/agent.d.ts +44 -0
- package/dist/core/channel-options.d.ts +57 -0
- package/dist/core/codec/codec-event.d.ts +9 -0
- package/dist/core/codec/decoder.d.ts +24 -24
- package/dist/core/codec/define-codec.d.ts +100 -0
- package/dist/core/codec/encoder.d.ts +10 -12
- package/dist/core/codec/field-bag.d.ts +85 -0
- package/dist/core/codec/fields.d.ts +141 -0
- package/dist/core/codec/index.d.ts +8 -2
- package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
- package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
- package/dist/core/codec/input-descriptors.d.ts +281 -0
- package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
- package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
- package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
- package/dist/core/codec/output-descriptors.d.ts +237 -0
- package/dist/core/codec/types.d.ts +470 -119
- package/dist/core/codec/well-known-inputs.d.ts +52 -0
- package/dist/core/transport/agent-session.d.ts +10 -0
- package/dist/core/transport/agent-view.d.ts +296 -0
- package/dist/core/transport/client-session.d.ts +13 -0
- package/dist/core/transport/decode-fold.d.ts +55 -0
- package/dist/core/transport/headers.d.ts +121 -14
- package/dist/core/transport/index.d.ts +5 -6
- package/dist/core/transport/internal/bounded-map.d.ts +20 -0
- package/dist/core/transport/invocation.d.ts +74 -0
- package/dist/core/transport/load-history-pages.d.ts +71 -0
- package/dist/core/transport/load-history.d.ts +44 -0
- package/dist/core/transport/pipe-stream.d.ts +9 -9
- package/dist/core/transport/run-manager.d.ts +76 -0
- package/dist/core/transport/session-support.d.ts +55 -0
- package/dist/core/transport/tree.d.ts +523 -109
- package/dist/core/transport/types/agent.d.ts +375 -0
- package/dist/core/transport/types/client.d.ts +201 -0
- package/dist/core/transport/types/shared.d.ts +24 -0
- package/dist/core/transport/types/tree.d.ts +357 -0
- package/dist/core/transport/types/view.d.ts +249 -0
- package/dist/core/transport/types.d.ts +13 -553
- package/dist/core/transport/view.d.ts +390 -84
- package/dist/core/transport/wire-log.d.ts +102 -0
- package/dist/errors.d.ts +27 -10
- package/dist/index.d.ts +8 -9
- package/dist/logger.d.ts +12 -0
- package/dist/react/ably-ai-transport-react.js +1365 -1010
- package/dist/react/ably-ai-transport-react.js.map +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
- package/dist/react/contexts/client-session-context.d.ts +37 -0
- package/dist/react/contexts/client-session-provider.d.ts +56 -0
- package/dist/react/create-session-hooks.d.ts +116 -0
- package/dist/react/index.d.ts +13 -12
- package/dist/react/internal/skipped-session.d.ts +8 -0
- package/dist/react/internal/use-resolved-session.d.ts +36 -0
- package/dist/react/use-ably-messages.d.ts +17 -14
- package/dist/react/use-client-session.d.ts +81 -0
- package/dist/react/use-create-view.d.ts +14 -13
- package/dist/react/use-tree.d.ts +30 -15
- package/dist/react/use-view.d.ts +81 -50
- package/dist/utils.d.ts +48 -71
- package/dist/vercel/ably-ai-transport-vercel.js +3257 -2499
- package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
- package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
- package/dist/vercel/codec/events.d.ts +50 -0
- package/dist/vercel/codec/fields.d.ts +44 -0
- package/dist/vercel/codec/fold-content.d.ts +16 -0
- package/dist/vercel/codec/fold-data.d.ts +16 -0
- package/dist/vercel/codec/fold-input.d.ts +67 -0
- package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
- package/dist/vercel/codec/fold-text.d.ts +16 -0
- package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
- package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
- package/dist/vercel/codec/index.d.ts +7 -20
- package/dist/vercel/codec/inputs.d.ts +11 -0
- package/dist/vercel/codec/outputs.d.ts +11 -0
- package/dist/vercel/codec/reducer-state.d.ts +121 -0
- package/dist/vercel/codec/reducer.d.ts +62 -0
- package/dist/vercel/codec/tool-transitions.d.ts +2 -8
- package/dist/vercel/codec/wire-data.d.ts +34 -0
- package/dist/vercel/index.d.ts +5 -5
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +2859 -9705
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -45
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +9 -7
- package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
- package/dist/vercel/react/index.d.ts +1 -2
- package/dist/vercel/react/use-chat-transport.d.ts +30 -26
- package/dist/vercel/react/use-message-sync.d.ts +17 -30
- package/dist/vercel/run-end-reason.d.ts +84 -0
- package/dist/vercel/tool-part.d.ts +21 -0
- package/dist/vercel/transport/chat-transport.d.ts +41 -24
- package/dist/vercel/transport/index.d.ts +24 -20
- package/dist/vercel/transport/run-output-stream.d.ts +54 -0
- package/dist/version.d.ts +2 -0
- package/package.json +31 -24
- package/src/constants.ts +124 -51
- package/src/core/agent.ts +92 -0
- package/src/core/channel-options.ts +89 -0
- package/src/core/codec/codec-event.ts +27 -0
- package/src/core/codec/decoder.ts +202 -105
- package/src/core/codec/define-codec.ts +432 -0
- package/src/core/codec/encoder.ts +114 -107
- package/src/core/codec/field-bag.ts +142 -0
- package/src/core/codec/fields.ts +193 -0
- package/src/core/codec/index.ts +56 -6
- package/src/core/codec/input-descriptor-decoder.ts +97 -0
- package/src/core/codec/input-descriptor-encoder.ts +150 -0
- package/src/core/codec/input-descriptors.ts +373 -0
- package/src/core/codec/lifecycle-tracker.ts +10 -9
- package/src/core/codec/output-descriptor-decoder.ts +139 -0
- package/src/core/codec/output-descriptor-encoder.ts +101 -0
- package/src/core/codec/output-descriptors.ts +307 -0
- package/src/core/codec/types.ts +505 -126
- package/src/core/codec/well-known-inputs.ts +96 -0
- package/src/core/transport/agent-session.ts +1085 -0
- package/src/core/transport/agent-view.ts +738 -0
- package/src/core/transport/client-session.ts +780 -0
- package/src/core/transport/decode-fold.ts +101 -0
- package/src/core/transport/headers.ts +234 -22
- package/src/core/transport/index.ts +27 -27
- package/src/core/transport/internal/bounded-map.ts +27 -0
- package/src/core/transport/invocation.ts +98 -0
- package/src/core/transport/load-history-pages.ts +220 -0
- package/src/core/transport/load-history.ts +271 -0
- package/src/core/transport/pipe-stream.ts +63 -39
- package/src/core/transport/run-manager.ts +243 -0
- package/src/core/transport/session-support.ts +96 -0
- package/src/core/transport/tree.ts +1293 -308
- package/src/core/transport/types/agent.ts +434 -0
- package/src/core/transport/types/client.ts +247 -0
- package/src/core/transport/types/shared.ts +27 -0
- package/src/core/transport/types/tree.ts +393 -0
- package/src/core/transport/types/view.ts +288 -0
- package/src/core/transport/types.ts +13 -706
- package/src/core/transport/view.ts +1229 -450
- package/src/core/transport/wire-log.ts +189 -0
- package/src/errors.ts +29 -9
- package/src/event-emitter.ts +3 -2
- package/src/index.ts +86 -42
- package/src/logger.ts +14 -1
- package/src/react/contexts/client-session-context.ts +41 -0
- package/src/react/contexts/client-session-provider.tsx +222 -0
- package/src/react/create-session-hooks.ts +141 -0
- package/src/react/index.ts +24 -13
- package/src/react/internal/skipped-session.ts +62 -0
- package/src/react/internal/use-resolved-session.ts +63 -0
- package/src/react/use-ably-messages.ts +32 -22
- package/src/react/use-client-session.ts +178 -0
- package/src/react/use-create-view.ts +33 -29
- package/src/react/use-tree.ts +61 -30
- package/src/react/use-view.ts +138 -96
- package/src/utils.ts +83 -131
- package/src/vercel/codec/decode-lifecycle.ts +70 -0
- package/src/vercel/codec/events.ts +85 -0
- package/src/vercel/codec/fields.ts +58 -0
- package/src/vercel/codec/fold-content.ts +54 -0
- package/src/vercel/codec/fold-data.ts +46 -0
- package/src/vercel/codec/fold-input.ts +255 -0
- package/src/vercel/codec/fold-lifecycle.ts +85 -0
- package/src/vercel/codec/fold-text.ts +55 -0
- package/src/vercel/codec/fold-tool-input.ts +86 -0
- package/src/vercel/codec/fold-tool-output.ts +79 -0
- package/src/vercel/codec/index.ts +28 -21
- package/src/vercel/codec/inputs.ts +116 -0
- package/src/vercel/codec/outputs.ts +207 -0
- package/src/vercel/codec/reducer-state.ts +169 -0
- package/src/vercel/codec/reducer.ts +191 -0
- package/src/vercel/codec/tool-transitions.ts +3 -14
- package/src/vercel/codec/wire-data.ts +64 -0
- package/src/vercel/index.ts +7 -19
- package/src/vercel/react/contexts/chat-transport-context.ts +8 -7
- package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
- package/src/vercel/react/index.ts +3 -5
- package/src/vercel/react/use-chat-transport.ts +44 -66
- package/src/vercel/react/use-message-sync.ts +75 -39
- package/src/vercel/run-end-reason.ts +157 -0
- package/src/vercel/tool-part.ts +25 -0
- package/src/vercel/transport/chat-transport.ts +380 -98
- package/src/vercel/transport/index.ts +38 -37
- package/src/vercel/transport/run-output-stream.ts +169 -0
- package/src/version.ts +2 -0
- package/dist/core/transport/client-transport.d.ts +0 -10
- package/dist/core/transport/decode-history.d.ts +0 -43
- package/dist/core/transport/server-transport.d.ts +0 -7
- package/dist/core/transport/stream-router.d.ts +0 -29
- package/dist/core/transport/turn-manager.d.ts +0 -37
- package/dist/react/contexts/transport-context.d.ts +0 -31
- package/dist/react/contexts/transport-provider.d.ts +0 -49
- package/dist/react/create-transport-hooks.d.ts +0 -124
- package/dist/react/use-active-turns.d.ts +0 -12
- package/dist/react/use-client-transport.d.ts +0 -80
- package/dist/vercel/codec/accumulator.d.ts +0 -21
- package/dist/vercel/codec/decoder.d.ts +0 -22
- package/dist/vercel/codec/encoder.d.ts +0 -41
- package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
- package/dist/vercel/tool-approvals.d.ts +0 -124
- package/dist/vercel/tool-events.d.ts +0 -26
- package/src/core/transport/client-transport.ts +0 -977
- package/src/core/transport/decode-history.ts +0 -485
- package/src/core/transport/server-transport.ts +0 -612
- package/src/core/transport/stream-router.ts +0 -136
- package/src/core/transport/turn-manager.ts +0 -165
- package/src/react/contexts/transport-context.ts +0 -37
- package/src/react/contexts/transport-provider.tsx +0 -164
- package/src/react/create-transport-hooks.ts +0 -144
- package/src/react/use-active-turns.ts +0 -72
- package/src/react/use-client-transport.ts +0 -197
- package/src/vercel/codec/accumulator.ts +0 -588
- package/src/vercel/codec/decoder.ts +0 -618
- package/src/vercel/codec/encoder.ts +0 -410
- package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
- package/src/vercel/tool-approvals.ts +0 -380
- package/src/vercel/tool-events.ts +0 -53
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loadHistoryPages — shared low-level history pagination primitive.
|
|
3
|
+
*
|
|
4
|
+
* Consumed by both client (via `load-history.ts`, which layers a complete-
|
|
5
|
+
* domain-message counter on top) and agent (directly, for input-event lookup
|
|
6
|
+
* and conversation hydration). Returns raw Ably messages; does NOT decode.
|
|
7
|
+
*
|
|
8
|
+
* Behaviour:
|
|
9
|
+
* - Attaches the channel (idempotent) then pages via `channel.history()`,
|
|
10
|
+
* using `untilAttach: true` for gapless continuity with any live subscription.
|
|
11
|
+
* - Exposes the underlying pagination as a cursor with `hasNext()` (cheap,
|
|
12
|
+
* no network) and `next()` (one Ably page per call, newest-first within
|
|
13
|
+
* the page).
|
|
14
|
+
* - Per-page failures are retried with bounded exponential backoff; on
|
|
15
|
+
* exhaustion throws `Ably.ErrorInfo` with code `HistoryFetchFailed`.
|
|
16
|
+
* - `signal.aborted` is checked between pages; rejects with
|
|
17
|
+
* `Ably.ErrorInfo` (InvalidArgument) when aborted.
|
|
18
|
+
*
|
|
19
|
+
* Spec: AIT-CT11 / AIT-ST hydration.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as Ably from 'ably';
|
|
23
|
+
|
|
24
|
+
import { ErrorCode } from '../../errors.js';
|
|
25
|
+
import type { Logger } from '../../logger.js';
|
|
26
|
+
import { errorCause, errorMessage } from '../../utils.js';
|
|
27
|
+
|
|
28
|
+
/** Options for {@link loadHistoryPages}. */
|
|
29
|
+
export interface LoadHistoryPagesOptions {
|
|
30
|
+
/** Wire-message limit per Ably page. */
|
|
31
|
+
pageLimit: number;
|
|
32
|
+
/** Set `untilAttach: true` on the underlying history query for gapless continuity with live subscriptions. Default: true. */
|
|
33
|
+
untilAttach?: boolean;
|
|
34
|
+
/** AbortSignal checked between pages. Rejects with InvalidArgument when aborted. */
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
/** Max retries per `page.next()` / initial `history()` failure. Default: 3. */
|
|
37
|
+
maxRetries?: number;
|
|
38
|
+
/** Initial retry backoff in ms (doubled per attempt). Default: 100. */
|
|
39
|
+
retryBackoffMs?: number;
|
|
40
|
+
/** Logger for diagnostic output. */
|
|
41
|
+
logger?: Logger;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Cursor over the channel's history pages.
|
|
46
|
+
*
|
|
47
|
+
* `hasNext()` is cheap (cursor-only, no network); `next()` issues one Ably
|
|
48
|
+
* page fetch (with retry/backoff) and returns its messages. Once `next()`
|
|
49
|
+
* returns `undefined` the cursor is exhausted.
|
|
50
|
+
*/
|
|
51
|
+
export interface HistoryPagesCursor {
|
|
52
|
+
/** True when another Ably page is available (cheap to check; no network). */
|
|
53
|
+
hasNext(): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Fetch the next Ably page's messages (newest-first within the page).
|
|
56
|
+
* Returns `undefined` when no more pages are available or the abort
|
|
57
|
+
* signal has fired.
|
|
58
|
+
*/
|
|
59
|
+
next(): Promise<readonly Ably.InboundMessage[] | undefined>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sleep for `ms` milliseconds, honouring an AbortSignal.
|
|
64
|
+
* @param ms - Milliseconds to wait.
|
|
65
|
+
* @param signal - Optional abort signal; rejects when fired.
|
|
66
|
+
*/
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/promise-function-async -- the function body is the Promise constructor; async would wrap it in an extra Promise
|
|
68
|
+
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
|
69
|
+
new Promise<void>((resolve, reject) => {
|
|
70
|
+
if (signal?.aborted) {
|
|
71
|
+
reject(new Ably.ErrorInfo('unable to wait; signal aborted', ErrorCode.InvalidArgument, 400));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const timer: ReturnType<typeof setTimeout> | number = setTimeout(() => {
|
|
75
|
+
signal?.removeEventListener('abort', onAbort);
|
|
76
|
+
resolve();
|
|
77
|
+
}, ms);
|
|
78
|
+
// Node returns an unref-able Timeout; browsers return a number. Unref so
|
|
79
|
+
// a retry backoff cannot keep a Node process alive by itself.
|
|
80
|
+
if (typeof timer === 'object') timer.unref();
|
|
81
|
+
const onAbort = (): void => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
reject(new Ably.ErrorInfo('unable to wait; signal aborted', ErrorCode.InvalidArgument, 400));
|
|
84
|
+
};
|
|
85
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Invoke `fetchPage`, retrying on failure with exponential backoff. Throws
|
|
90
|
+
* the last failure wrapped as `HistoryFetchFailed` once retries are
|
|
91
|
+
* exhausted.
|
|
92
|
+
* @param fetchPage - The page fetch to retry (initial `channel.history()` call or a `page.next()`).
|
|
93
|
+
* @param maxRetries - Maximum number of attempts after the initial call.
|
|
94
|
+
* @param initialBackoffMs - Starting backoff delay (doubled per attempt).
|
|
95
|
+
* @param signal - Optional abort signal; cancels remaining retries.
|
|
96
|
+
* @param logger - Optional logger.
|
|
97
|
+
* @returns The fetched page, or `undefined` when pagination is exhausted.
|
|
98
|
+
*/
|
|
99
|
+
const fetchPageWithRetry = async (
|
|
100
|
+
fetchPage: () => Promise<Ably.PaginatedResult<Ably.InboundMessage> | undefined>,
|
|
101
|
+
maxRetries: number,
|
|
102
|
+
initialBackoffMs: number,
|
|
103
|
+
signal: AbortSignal | undefined,
|
|
104
|
+
logger: Logger | undefined,
|
|
105
|
+
): Promise<Ably.PaginatedResult<Ably.InboundMessage> | undefined> => {
|
|
106
|
+
let lastError: unknown;
|
|
107
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
108
|
+
if (signal?.aborted) {
|
|
109
|
+
throw new Ably.ErrorInfo(
|
|
110
|
+
'unable to fetch history page; signal aborted',
|
|
111
|
+
ErrorCode.InvalidArgument,
|
|
112
|
+
400,
|
|
113
|
+
errorCause(lastError),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return await fetchPage();
|
|
118
|
+
} catch (error) {
|
|
119
|
+
lastError = error;
|
|
120
|
+
if (attempt === maxRetries) break;
|
|
121
|
+
const backoff = initialBackoffMs * 2 ** attempt;
|
|
122
|
+
logger?.debug('loadHistoryPages.fetchPageWithRetry(); page fetch failed, retrying', {
|
|
123
|
+
attempt: attempt + 1,
|
|
124
|
+
maxRetries,
|
|
125
|
+
backoff,
|
|
126
|
+
});
|
|
127
|
+
await sleep(backoff, signal);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
throw new Ably.ErrorInfo(
|
|
131
|
+
`unable to fetch history page; ${errorMessage(lastError)}`,
|
|
132
|
+
ErrorCode.HistoryFetchFailed,
|
|
133
|
+
500,
|
|
134
|
+
errorCause(lastError),
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Page through channel history, returning a cursor over Ably pages.
|
|
140
|
+
*
|
|
141
|
+
* Newest-first within each yielded page (matching Ably's native ordering).
|
|
142
|
+
* Caller drives the cursor — calling `next()` until it returns `undefined`
|
|
143
|
+
* or stopping early when a domain-specific stop condition is met
|
|
144
|
+
* (e.g. complete-message counter satisfied, target codec-message-id found,
|
|
145
|
+
* parent chain walk reaches root).
|
|
146
|
+
*
|
|
147
|
+
* The initial Ably history call is awaited eagerly so the returned cursor
|
|
148
|
+
* already knows whether there are pages available (via `hasNext()`).
|
|
149
|
+
* @param channel - The Ably channel to read history from.
|
|
150
|
+
* @param options - Pagination options.
|
|
151
|
+
* @returns A cursor with `hasNext()` (cheap, cursor-only) and `next()` (fetches one page with retry).
|
|
152
|
+
* @throws {Ably.ErrorInfo} `HistoryFetchFailed` on exhausted retry of the initial fetch, or `InvalidArgument` on signal abort.
|
|
153
|
+
*/
|
|
154
|
+
export const loadHistoryPages = async (
|
|
155
|
+
channel: Ably.RealtimeChannel,
|
|
156
|
+
options: LoadHistoryPagesOptions,
|
|
157
|
+
): Promise<HistoryPagesCursor> => {
|
|
158
|
+
const { pageLimit, untilAttach = true, signal, maxRetries = 3, retryBackoffMs = 100, logger } = options;
|
|
159
|
+
|
|
160
|
+
if (signal?.aborted) {
|
|
161
|
+
throw new Ably.ErrorInfo('unable to load history; signal aborted', ErrorCode.InvalidArgument, 400);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await channel.attach();
|
|
165
|
+
|
|
166
|
+
const historyParams: Ably.RealtimeHistoryParams = { limit: pageLimit, untilAttach };
|
|
167
|
+
|
|
168
|
+
let currentPage: Ably.PaginatedResult<Ably.InboundMessage> | undefined = await fetchPageWithRetry(
|
|
169
|
+
// eslint-disable-next-line @typescript-eslint/promise-function-async -- channel.history returns a real Promise
|
|
170
|
+
() => channel.history(historyParams),
|
|
171
|
+
maxRetries,
|
|
172
|
+
retryBackoffMs,
|
|
173
|
+
signal,
|
|
174
|
+
logger,
|
|
175
|
+
);
|
|
176
|
+
let firstYielded = false;
|
|
177
|
+
|
|
178
|
+
// Compute whether the cursor has another page available. Cheap — no
|
|
179
|
+
// network. Reflects the latest fetched page's `hasNext()` plus the signal
|
|
180
|
+
// check.
|
|
181
|
+
const hasNext = (): boolean => {
|
|
182
|
+
if (currentPage === undefined) return false;
|
|
183
|
+
if (signal?.aborted) return false;
|
|
184
|
+
if (!firstYielded) return true;
|
|
185
|
+
return currentPage.hasNext();
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const next = async (): Promise<readonly Ably.InboundMessage[] | undefined> => {
|
|
189
|
+
if (currentPage === undefined) return undefined;
|
|
190
|
+
if (signal?.aborted) {
|
|
191
|
+
throw new Ably.ErrorInfo('unable to load history; signal aborted', ErrorCode.InvalidArgument, 400);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!firstYielded) {
|
|
195
|
+
firstYielded = true;
|
|
196
|
+
return currentPage.items;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!currentPage.hasNext()) {
|
|
200
|
+
currentPage = undefined;
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const nextPage: Ably.PaginatedResult<Ably.InboundMessage> | undefined = await fetchPageWithRetry(
|
|
205
|
+
async () => (await currentPage?.next()) ?? undefined,
|
|
206
|
+
maxRetries,
|
|
207
|
+
retryBackoffMs,
|
|
208
|
+
signal,
|
|
209
|
+
logger,
|
|
210
|
+
);
|
|
211
|
+
if (!nextPage) {
|
|
212
|
+
currentPage = undefined;
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
currentPage = nextPage;
|
|
216
|
+
return nextPage.items;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return { hasNext, next };
|
|
220
|
+
};
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loadHistory — load conversation history from an Ably channel and return
|
|
3
|
+
* the raw Ably messages as a paginated HistoryPage result.
|
|
4
|
+
*
|
|
5
|
+
* This does NOT decode: it pages back through Ably history via the shared
|
|
6
|
+
* {@link loadHistoryPages} primitive until `limit` complete messages are
|
|
7
|
+
* present, then hands the raw Ably messages (oldest-first) to the caller.
|
|
8
|
+
* The View re-decodes them into the Tree itself, so load-history only needs
|
|
9
|
+
* a cheap, header-based completion counter to decide when to stop paging.
|
|
10
|
+
*
|
|
11
|
+
* The `limit` option controls the number of complete **messages** per page,
|
|
12
|
+
* not the number of Ably messages fetched. A message is complete when
|
|
13
|
+
* its terminal wire signal — `status: "complete"` / `"cancelled"`, or a
|
|
14
|
+
* `discrete` create — has been seen. Runs that span a page boundary are
|
|
15
|
+
* handled by the counter requiring both a start and a terminal signal
|
|
16
|
+
* before counting a message complete.
|
|
17
|
+
*
|
|
18
|
+
* Because Ably history returns newest-first, each page's `rawMessages` are
|
|
19
|
+
* reversed to chronological (oldest-first) so the caller can fold them in
|
|
20
|
+
* order.
|
|
21
|
+
*
|
|
22
|
+
* Spec: AIT-CT11, AIT-CT11b.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type * as Ably from 'ably';
|
|
26
|
+
|
|
27
|
+
import { HEADER_CODEC_MESSAGE_ID, HEADER_DISCRETE, HEADER_STATUS, HEADER_STREAM } from '../../constants.js';
|
|
28
|
+
import type { Logger } from '../../logger.js';
|
|
29
|
+
import { getTransportHeaders } from '../../utils.js';
|
|
30
|
+
import { type HistoryPagesCursor, loadHistoryPages } from './load-history-pages.js';
|
|
31
|
+
import type { HistoryPage, LoadHistoryOptions } from './types.js';
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Shared state across pages within one history traversal
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
interface HistoryState {
|
|
38
|
+
/** Cursor over the shared `loadHistoryPages` primitive; each `next()` returns one Ably page's messages (newest-first within the page). */
|
|
39
|
+
cursor: HistoryPagesCursor;
|
|
40
|
+
/** All raw Ably messages collected so far, in newest-first order (as received from Ably). */
|
|
41
|
+
rawMessages: Ably.InboundMessage[];
|
|
42
|
+
/**
|
|
43
|
+
* How many complete messages have been served to the consumer so far.
|
|
44
|
+
* Drives the buffered-page logic: when a single fetch gathers more than
|
|
45
|
+
* `limit` completions, later pages are served from the buffer without
|
|
46
|
+
* fetching, advancing this counter `limit` at a time.
|
|
47
|
+
*/
|
|
48
|
+
returnedCount: number;
|
|
49
|
+
/** How many raw Ably messages have been served to the consumer so far. */
|
|
50
|
+
returnedRawCount: number;
|
|
51
|
+
/**
|
|
52
|
+
* `codec-message-id`s for which a start signal has been seen: any
|
|
53
|
+
* `message.create` / `message.update` / `message.append` with
|
|
54
|
+
* `stream: "true"` (the decoder establishes a tracker via create or
|
|
55
|
+
* first-contact), or a `message.create` carrying `discrete` (a discrete
|
|
56
|
+
* message, created and terminated in one Ably message).
|
|
57
|
+
*/
|
|
58
|
+
startedCodecMessageIds: Set<string>;
|
|
59
|
+
/**
|
|
60
|
+
* `codec-message-id`s with a terminal wire signal: either `discrete`
|
|
61
|
+
* on a `message.create` (discrete message) or `status: "complete"`
|
|
62
|
+
* / `"cancelled"` on any action (closed stream).
|
|
63
|
+
*/
|
|
64
|
+
terminatedCodecMessageIds: Set<string>;
|
|
65
|
+
/**
|
|
66
|
+
* `codec-message-id`s that are both started AND terminated — counted as
|
|
67
|
+
* complete. The fetch loop reads this set's size to decide when to stop
|
|
68
|
+
* paging. Maintained incrementally by {@link countNewCompletions}. Grows
|
|
69
|
+
* monotonically.
|
|
70
|
+
*/
|
|
71
|
+
completedCodecMessageIds: Set<string>;
|
|
72
|
+
logger: Logger;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Incremental completion counting (header scan, no decode)
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Scan newly-added raw messages and track which `codec-message-id`s have
|
|
81
|
+
* become complete. Used by {@link fetchUntilLimit} to decide when enough
|
|
82
|
+
* completed messages have been collected, without running the decoder.
|
|
83
|
+
*
|
|
84
|
+
* A codec-message-id is considered complete only when BOTH of these have been seen:
|
|
85
|
+
* - a "start" signal: either `discrete` on a `message.create`
|
|
86
|
+
* (discrete messages are created and terminated by the same Ably message
|
|
87
|
+
* message), OR any `message.create` / `message.update` / `message.append`
|
|
88
|
+
* with `stream: "true"` (the decoder establishes a tracker via
|
|
89
|
+
* create or first-contact).
|
|
90
|
+
* - a "terminal" signal: `discrete` on the create, or
|
|
91
|
+
* `status: "complete"` / `"cancelled"` on any later action.
|
|
92
|
+
*
|
|
93
|
+
* Why update and append count as starts: Ably history can compact a live
|
|
94
|
+
* `create + append + ... + append{status:complete}` sequence into a single
|
|
95
|
+
* `message.update` with `STREAM=true` and `STATUS=complete`. The decoder
|
|
96
|
+
* handles that via first-contact. Counting only `message.create` as a start
|
|
97
|
+
* would cause the fetch loop to page past a compacted run without ever
|
|
98
|
+
* marking it complete.
|
|
99
|
+
*
|
|
100
|
+
* Requiring both halves matters when a streaming run spans a page
|
|
101
|
+
* boundary: the terminal arrives in the newer page (fetched first) while
|
|
102
|
+
* the start sits in an older page. Counting the terminal alone would stop
|
|
103
|
+
* the fetch loop prematurely - the decoder would have no stream state to
|
|
104
|
+
* resolve, and the message wouldn't make it into the result.
|
|
105
|
+
*
|
|
106
|
+
* Messages skipped for counting:
|
|
107
|
+
* - Missing `codec-message-id`: lifecycle events not tied to a domain message.
|
|
108
|
+
* - `message.delete`: clears the tracker, doesn't produce output.
|
|
109
|
+
*
|
|
110
|
+
* Amend-class Ably messages (events targeting an existing message via
|
|
111
|
+
* `HEADER_CODEC_MESSAGE_ID`) flow through the same counter — the Sets naturally
|
|
112
|
+
* dedup so a tool-output amend on an already-seen codec-message-id is idempotent.
|
|
113
|
+
*
|
|
114
|
+
* Known edge case: if Ably history is truncated and a terminal survives
|
|
115
|
+
* while every start signal for its codec-message-id has rolled off, the counter will
|
|
116
|
+
* never mark that `codec-message-id` complete. The loop keeps fetching until it runs
|
|
117
|
+
* out of pages, then returns whatever raw messages it collected.
|
|
118
|
+
* @param state - The shared history traversal state.
|
|
119
|
+
* @param newMessages - The Ably messages just pushed onto `state.rawMessages`.
|
|
120
|
+
*/
|
|
121
|
+
const countNewCompletions = (state: HistoryState, newMessages: readonly Ably.InboundMessage[]): void => {
|
|
122
|
+
for (const msg of newMessages) {
|
|
123
|
+
const headers = getTransportHeaders(msg);
|
|
124
|
+
const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
|
|
125
|
+
if (!codecMessageId) continue;
|
|
126
|
+
|
|
127
|
+
const action = msg.action;
|
|
128
|
+
const isDiscreteCreate = action === 'message.create' && HEADER_DISCRETE in headers;
|
|
129
|
+
// Any content-producing action on a streamed serial counts as a start:
|
|
130
|
+
// the decoder uses create or first-contact (update/append) to establish
|
|
131
|
+
// its tracker. Delete clears tracker state and emits nothing, so it
|
|
132
|
+
// never counts as a start.
|
|
133
|
+
const hasStreamContent =
|
|
134
|
+
headers[HEADER_STREAM] === 'true' &&
|
|
135
|
+
(action === 'message.create' || action === 'message.update' || action === 'message.append');
|
|
136
|
+
const status = headers[HEADER_STATUS];
|
|
137
|
+
const isTerminal = status === 'complete' || status === 'cancelled';
|
|
138
|
+
|
|
139
|
+
if (isDiscreteCreate || hasStreamContent) state.startedCodecMessageIds.add(codecMessageId);
|
|
140
|
+
if (isDiscreteCreate || isTerminal) state.terminatedCodecMessageIds.add(codecMessageId);
|
|
141
|
+
if (state.startedCodecMessageIds.has(codecMessageId) && state.terminatedCodecMessageIds.has(codecMessageId)) {
|
|
142
|
+
state.completedCodecMessageIds.add(codecMessageId);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Pull pages from the shared iterator until we have enough completions
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Pull chunks from the shared {@link loadHistoryPages} iterator until enough
|
|
153
|
+
* completed messages have been collected (target = already-returned +
|
|
154
|
+
* `limit`) or the iterator is exhausted.
|
|
155
|
+
*
|
|
156
|
+
* Uses {@link countNewCompletions} — a cheap O(new messages) header scan —
|
|
157
|
+
* to decide when to stop, rather than running the decoder per page.
|
|
158
|
+
* @param state - The shared history traversal state.
|
|
159
|
+
* @param limit - Target number of completed messages beyond what has already been returned.
|
|
160
|
+
*/
|
|
161
|
+
const fetchUntilLimit = async (state: HistoryState, limit: number): Promise<void> => {
|
|
162
|
+
const target = state.returnedCount + limit;
|
|
163
|
+
while (state.completedCodecMessageIds.size < target && state.cursor.hasNext()) {
|
|
164
|
+
state.logger.debug('loadHistory.fetchUntilLimit(); pulling next page', {
|
|
165
|
+
collected: state.rawMessages.length,
|
|
166
|
+
completed: state.completedCodecMessageIds.size,
|
|
167
|
+
});
|
|
168
|
+
const chunk = await state.cursor.next();
|
|
169
|
+
if (!chunk) break;
|
|
170
|
+
state.rawMessages.push(...chunk);
|
|
171
|
+
countNewCompletions(state, chunk);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Build HistoryPage result from current state
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build a HistoryPage of raw Ably messages from the current fetch state.
|
|
181
|
+
* @param state - The shared history traversal state.
|
|
182
|
+
* @param limit - Max complete messages per page.
|
|
183
|
+
* @returns A page of raw history messages with a `next()` cursor.
|
|
184
|
+
*/
|
|
185
|
+
const buildResult = (state: HistoryState, limit: number): HistoryPage => {
|
|
186
|
+
// Advance the served-completion counter by up to `limit`, mirroring the
|
|
187
|
+
// page granularity the consumer asked for. `rawMessages` for this page are
|
|
188
|
+
// all messages fetched since the previous page (empty for buffered pages).
|
|
189
|
+
const totalCompleted = state.completedCodecMessageIds.size;
|
|
190
|
+
const served = Math.min(limit, Math.max(0, totalCompleted - state.returnedCount));
|
|
191
|
+
state.returnedCount += served;
|
|
192
|
+
|
|
193
|
+
const moreCompleted = totalCompleted > state.returnedCount;
|
|
194
|
+
const moreFromCursor = state.cursor.hasNext();
|
|
195
|
+
|
|
196
|
+
// Raw Ably messages for this page in chronological order (oldest first).
|
|
197
|
+
const newRawCount = state.rawMessages.length - state.returnedRawCount;
|
|
198
|
+
const rawMessages = newRawCount > 0 ? state.rawMessages.slice(state.returnedRawCount).toReversed() : [];
|
|
199
|
+
state.returnedRawCount = state.rawMessages.length;
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
rawMessages,
|
|
203
|
+
hasNext: () => moreCompleted || moreFromCursor,
|
|
204
|
+
next: async () => {
|
|
205
|
+
if (moreCompleted) {
|
|
206
|
+
return buildResult(state, limit);
|
|
207
|
+
}
|
|
208
|
+
if (!moreFromCursor) return;
|
|
209
|
+
await fetchUntilLimit(state, limit);
|
|
210
|
+
return buildResult(state, limit);
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Public API
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Load conversation history from a channel and return the raw Ably messages.
|
|
221
|
+
*
|
|
222
|
+
* Drives the shared {@link loadHistoryPages} primitive with
|
|
223
|
+
* `untilAttach: true` (gapless continuity with the live subscription) and a
|
|
224
|
+
* Ably message page limit that overprovisions for the many-Ably-messages-per-domain-message
|
|
225
|
+
* ratio. Each `HistoryPage` returned covers up to `limit` complete domain
|
|
226
|
+
* messages; subsequent pages drain over-collected messages from the buffer
|
|
227
|
+
* before pulling more from the iterator.
|
|
228
|
+
*
|
|
229
|
+
* The `limit` option controls the number of complete messages returned per
|
|
230
|
+
* page, not the number of Ably messages fetched.
|
|
231
|
+
* @param channel - The Ably channel to load history from.
|
|
232
|
+
* @param options - Pagination options.
|
|
233
|
+
* @param logger - Logger for diagnostic output.
|
|
234
|
+
* @returns The first page of raw history messages.
|
|
235
|
+
*/
|
|
236
|
+
// Spec: AIT-CT11, AIT-CT11b
|
|
237
|
+
export const loadHistory = async (
|
|
238
|
+
channel: Ably.RealtimeChannel,
|
|
239
|
+
options: LoadHistoryOptions | undefined,
|
|
240
|
+
logger: Logger,
|
|
241
|
+
): Promise<HistoryPage> => {
|
|
242
|
+
const limit = options?.limit ?? 100;
|
|
243
|
+
|
|
244
|
+
logger.trace('loadHistory();', { limit });
|
|
245
|
+
|
|
246
|
+
// Request more Ably messages per page than the domain-message limit to
|
|
247
|
+
// account for the many-to-one ratio (multiple Ably messages per domain message).
|
|
248
|
+
// The wire pagination is internal to the iterator; the consumer-visible
|
|
249
|
+
// pagination is by complete domain messages.
|
|
250
|
+
const wireLimit = limit * 10;
|
|
251
|
+
|
|
252
|
+
const cursor = await loadHistoryPages(channel, {
|
|
253
|
+
pageLimit: wireLimit,
|
|
254
|
+
untilAttach: true,
|
|
255
|
+
logger,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const state: HistoryState = {
|
|
259
|
+
cursor,
|
|
260
|
+
rawMessages: [],
|
|
261
|
+
returnedCount: 0,
|
|
262
|
+
returnedRawCount: 0,
|
|
263
|
+
startedCodecMessageIds: new Set<string>(),
|
|
264
|
+
terminatedCodecMessageIds: new Set<string>(),
|
|
265
|
+
completedCodecMessageIds: new Set<string>(),
|
|
266
|
+
logger,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
await fetchUntilLimit(state, limit);
|
|
270
|
+
return buildResult(state, limit);
|
|
271
|
+
};
|
|
@@ -1,53 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure stream piping function.
|
|
3
3
|
*
|
|
4
|
-
* Reads
|
|
5
|
-
* and handles
|
|
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 {
|
|
10
|
+
import type { CodecInputEvent, CodecOutputEvent, Encoder, WriteOptions } from '../codec/types.js';
|
|
10
11
|
import type { StreamResult } from './types.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
|
-
*
|
|
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
|
|
18
|
-
* @param encoder - The
|
|
19
|
-
* @param signal -
|
|
20
|
-
* @param
|
|
21
|
-
* @param resolveWriteOptions - Optional per-
|
|
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
|
|
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 <
|
|
26
|
-
stream: ReadableStream<
|
|
27
|
-
encoder:
|
|
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
|
-
|
|
30
|
-
resolveWriteOptions?: (
|
|
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,28 +70,37 @@ 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
|
|
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(),
|
|
75
|
+
const result = await Promise.race([reader.read(), abort.promise.then(() => 'cancelled' as const)]);
|
|
61
76
|
|
|
62
|
-
if (result === '
|
|
77
|
+
if (result === 'cancelled') {
|
|
63
78
|
reason = 'cancelled';
|
|
64
|
-
logger?.debug('pipeStream(); stream cancelled by
|
|
65
|
-
if (
|
|
66
|
-
await
|
|
79
|
+
logger?.debug('pipeStream(); stream cancelled by AbortSignal');
|
|
80
|
+
if (onCancelled) {
|
|
81
|
+
await onCancelled(async (output: TOutput) => encoder.publishOutput(output));
|
|
67
82
|
}
|
|
68
|
-
|
|
83
|
+
// Transport mechanics only — close in-flight streamed messages as
|
|
84
|
+
// cancelled. Run termination is the transport ai-run-end event,
|
|
85
|
+
// guaranteed by Run.pipe on a cancelled result.
|
|
86
|
+
await encoder.cancelStreams();
|
|
69
87
|
break;
|
|
70
88
|
}
|
|
71
89
|
|
|
72
90
|
const { done, value } = result;
|
|
73
91
|
if (done) {
|
|
92
|
+
// An agent-side self-abort (e.g. the AI SDK's abort signal firing)
|
|
93
|
+
// completes the stream without end chunks for in-flight streamed
|
|
94
|
+
// messages. Terminate any still-open wire streams with a cancelled
|
|
95
|
+
// status so decoders and history see a terminal; streams that closed
|
|
96
|
+
// normally are skipped (no-op on a clean completion).
|
|
97
|
+
await encoder.cancelStreams();
|
|
74
98
|
await encoder.close();
|
|
75
99
|
logger?.debug('pipeStream(); stream completed');
|
|
76
100
|
break;
|
|
77
101
|
}
|
|
78
102
|
|
|
79
|
-
await encoder.
|
|
103
|
+
await encoder.publishOutput(value, resolveWriteOptions?.(value));
|
|
80
104
|
}
|
|
81
105
|
} catch (error) {
|
|
82
106
|
reason = 'error';
|
|
@@ -90,7 +114,7 @@ export const pipeStream = async <TEvent, TMessage>(
|
|
|
90
114
|
// the StreamResult reason ("error").
|
|
91
115
|
}
|
|
92
116
|
} finally {
|
|
93
|
-
|
|
117
|
+
abort.cleanup();
|
|
94
118
|
reader.releaseLock();
|
|
95
119
|
}
|
|
96
120
|
|