@ably/ai-transport 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -116
- package/dist/ably-ai-transport.js +1743 -961
- package/dist/ably-ai-transport.js.map +1 -1
- package/dist/ably-ai-transport.umd.cjs +1 -1
- package/dist/ably-ai-transport.umd.cjs.map +1 -1
- package/dist/constants.d.ts +117 -39
- package/dist/core/agent.d.ts +29 -0
- package/dist/core/codec/decoder.d.ts +20 -23
- package/dist/core/codec/encoder.d.ts +11 -8
- package/dist/core/codec/index.d.ts +1 -2
- package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
- package/dist/core/codec/types.d.ts +410 -101
- package/dist/core/transport/agent-session.d.ts +10 -0
- package/dist/core/transport/branch-chain.d.ts +43 -0
- package/dist/core/transport/client-session.d.ts +13 -0
- package/dist/core/transport/decode-fold.d.ts +47 -0
- package/dist/core/transport/headers.d.ts +97 -17
- package/dist/core/transport/index.d.ts +5 -3
- package/dist/core/transport/internal/bounded-map.d.ts +20 -0
- package/dist/core/transport/invocation.d.ts +74 -0
- package/dist/core/transport/load-conversation.d.ts +128 -0
- package/dist/core/transport/load-history.d.ts +39 -0
- package/dist/core/transport/pipe-stream.d.ts +9 -8
- package/dist/core/transport/run-manager.d.ts +78 -0
- package/dist/core/transport/tree.d.ts +435 -0
- package/dist/core/transport/types/agent.d.ts +353 -0
- package/dist/core/transport/types/client.d.ts +168 -0
- package/dist/core/transport/types/shared.d.ts +24 -0
- package/dist/core/transport/types/tree.d.ts +315 -0
- package/dist/core/transport/types/view.d.ts +222 -0
- package/dist/core/transport/types.d.ts +13 -402
- package/dist/core/transport/view.d.ts +354 -0
- package/dist/errors.d.ts +37 -9
- package/dist/index.d.ts +6 -6
- package/dist/logger.d.ts +12 -0
- package/dist/react/ably-ai-transport-react.js +1164 -645
- package/dist/react/ably-ai-transport-react.js.map +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
- package/dist/react/contexts/client-session-context.d.ts +36 -0
- package/dist/react/contexts/client-session-provider.d.ts +53 -0
- package/dist/react/create-session-hooks.d.ts +116 -0
- package/dist/react/index.d.ts +16 -10
- package/dist/react/internal/use-resolved-session.d.ts +36 -0
- package/dist/react/use-ably-messages.d.ts +20 -11
- package/dist/react/use-client-session.d.ts +81 -0
- package/dist/react/use-create-view.d.ts +23 -0
- package/dist/react/use-tree.d.ts +35 -0
- package/dist/react/use-view.d.ts +110 -0
- package/dist/utils.d.ts +32 -23
- package/dist/vercel/ably-ai-transport-vercel.js +2748 -1625
- package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
- package/dist/vercel/codec/decoder.d.ts +5 -18
- package/dist/vercel/codec/encoder.d.ts +6 -36
- package/dist/vercel/codec/events.d.ts +51 -0
- package/dist/vercel/codec/index.d.ts +24 -12
- package/dist/vercel/codec/reducer.d.ts +144 -0
- package/dist/vercel/codec/tool-transitions.d.ts +50 -0
- package/dist/vercel/index.d.ts +4 -2
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +10298 -1410
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +70 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +33 -0
- package/dist/vercel/react/contexts/chat-transport-provider.d.ts +96 -0
- package/dist/vercel/react/index.d.ts +4 -0
- package/dist/vercel/react/use-chat-transport.d.ts +66 -21
- package/dist/vercel/react/use-message-sync.d.ts +31 -12
- package/dist/vercel/run-end-reason.d.ts +29 -0
- package/dist/vercel/transport/chat-transport.d.ts +71 -30
- package/dist/vercel/transport/index.d.ts +25 -18
- package/dist/vercel/transport/run-output-stream.d.ts +56 -0
- package/dist/version.d.ts +2 -0
- package/package.json +47 -34
- package/src/constants.ts +126 -47
- package/src/core/agent.ts +68 -0
- package/src/core/codec/decoder.ts +71 -98
- package/src/core/codec/encoder.ts +115 -58
- package/src/core/codec/index.ts +13 -6
- package/src/core/codec/lifecycle-tracker.ts +10 -9
- package/src/core/codec/types.ts +438 -106
- package/src/core/transport/agent-session.ts +1344 -0
- package/src/core/transport/branch-chain.ts +58 -0
- package/src/core/transport/client-session.ts +775 -0
- package/src/core/transport/decode-fold.ts +91 -0
- package/src/core/transport/headers.ts +182 -19
- package/src/core/transport/index.ts +29 -22
- package/src/core/transport/internal/bounded-map.ts +27 -0
- package/src/core/transport/invocation.ts +98 -0
- package/src/core/transport/load-conversation.ts +355 -0
- package/src/core/transport/load-history.ts +269 -0
- package/src/core/transport/pipe-stream.ts +58 -40
- package/src/core/transport/run-manager.ts +249 -0
- package/src/core/transport/tree.ts +1167 -0
- package/src/core/transport/types/agent.ts +407 -0
- package/src/core/transport/types/client.ts +211 -0
- package/src/core/transport/types/shared.ts +27 -0
- package/src/core/transport/types/tree.ts +344 -0
- package/src/core/transport/types/view.ts +259 -0
- package/src/core/transport/types.ts +13 -527
- package/src/core/transport/view.ts +1271 -0
- package/src/errors.ts +42 -9
- package/src/event-emitter.ts +3 -2
- package/src/index.ts +55 -39
- package/src/logger.ts +14 -1
- package/src/react/contexts/client-session-context.ts +41 -0
- package/src/react/contexts/client-session-provider.tsx +186 -0
- package/src/react/create-session-hooks.ts +141 -0
- package/src/react/index.ts +27 -10
- package/src/react/internal/use-resolved-session.ts +63 -0
- package/src/react/use-ably-messages.ts +47 -19
- package/src/react/use-client-session.ts +201 -0
- package/src/react/use-create-view.ts +72 -0
- package/src/react/use-tree.ts +84 -0
- package/src/react/use-view.ts +275 -0
- package/src/react/vite.config.ts +4 -1
- package/src/utils.ts +63 -45
- package/src/vercel/codec/decoder.ts +336 -255
- package/src/vercel/codec/encoder.ts +348 -196
- package/src/vercel/codec/events.ts +87 -0
- package/src/vercel/codec/index.ts +59 -14
- package/src/vercel/codec/reducer.ts +977 -0
- package/src/vercel/codec/tool-transitions.ts +122 -0
- package/src/vercel/index.ts +7 -3
- package/src/vercel/react/contexts/chat-transport-context.ts +41 -0
- package/src/vercel/react/contexts/chat-transport-provider.tsx +150 -0
- package/src/vercel/react/index.ts +13 -1
- package/src/vercel/react/use-chat-transport.ts +162 -42
- package/src/vercel/react/use-message-sync.ts +121 -22
- package/src/vercel/react/vite.config.ts +4 -2
- package/src/vercel/run-end-reason.ts +78 -0
- package/src/vercel/transport/chat-transport.ts +553 -113
- package/src/vercel/transport/index.ts +40 -28
- package/src/vercel/transport/run-output-stream.ts +170 -0
- package/src/version.ts +2 -0
- package/dist/core/transport/client-transport.d.ts +0 -10
- package/dist/core/transport/conversation-tree.d.ts +0 -9
- package/dist/core/transport/decode-history.d.ts +0 -41
- package/dist/core/transport/server-transport.d.ts +0 -7
- package/dist/core/transport/stream-router.d.ts +0 -19
- package/dist/core/transport/turn-manager.d.ts +0 -34
- package/dist/react/use-active-turns.d.ts +0 -8
- package/dist/react/use-client-transport.d.ts +0 -7
- package/dist/react/use-conversation-tree.d.ts +0 -20
- package/dist/react/use-edit.d.ts +0 -7
- package/dist/react/use-history.d.ts +0 -19
- package/dist/react/use-messages.d.ts +0 -7
- package/dist/react/use-regenerate.d.ts +0 -7
- package/dist/react/use-send.d.ts +0 -7
- package/dist/vercel/codec/accumulator.d.ts +0 -21
- package/src/core/transport/client-transport.ts +0 -959
- package/src/core/transport/conversation-tree.ts +0 -434
- package/src/core/transport/decode-history.ts +0 -337
- package/src/core/transport/server-transport.ts +0 -458
- package/src/core/transport/stream-router.ts +0 -118
- package/src/core/transport/turn-manager.ts +0 -147
- package/src/react/use-active-turns.ts +0 -61
- package/src/react/use-client-transport.ts +0 -37
- package/src/react/use-conversation-tree.ts +0 -71
- package/src/react/use-edit.ts +0 -24
- package/src/react/use-history.ts +0 -111
- package/src/react/use-messages.ts +0 -32
- package/src/react/use-regenerate.ts +0 -24
- package/src/react/use-send.ts +0 -25
- package/src/vercel/codec/accumulator.ts +0 -603
|
@@ -0,0 +1,1271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DefaultView — a paginated, branch-aware projection over the Tree.
|
|
3
|
+
*
|
|
4
|
+
* Wraps a Tree (RunNode-keyed) and manages a pagination window that controls
|
|
5
|
+
* which Runs are visible to the UI. New live Runs appear immediately; older
|
|
6
|
+
* Runs are revealed progressively via `loadOlder()`.
|
|
7
|
+
*
|
|
8
|
+
* `getMessages()` reads the Tree's visible node chain (input nodes + reply
|
|
9
|
+
* runs, with sibling selection applied) and concatenates each node's
|
|
10
|
+
* `codec.getMessages(node.projection)` to produce the flat
|
|
11
|
+
* `CodecMessage<TMessage>[]` the UI renders.
|
|
12
|
+
*
|
|
13
|
+
* Each View owns its own branch selection state and pagination window,
|
|
14
|
+
* allowing multiple independent Views over the same Tree.
|
|
15
|
+
*
|
|
16
|
+
* Events are scoped to the visible window — 'update' only fires when the
|
|
17
|
+
* visible output changes, 'ably-message' only for messages corresponding to
|
|
18
|
+
* visible Runs, and 'run' only for runs with visible content.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as Ably from 'ably';
|
|
22
|
+
|
|
23
|
+
import { HEADER_CODEC_MESSAGE_ID, HEADER_RUN_ID } from '../../constants.js';
|
|
24
|
+
import { ErrorCode } from '../../errors.js';
|
|
25
|
+
import { EventEmitter } from '../../event-emitter.js';
|
|
26
|
+
import type { Logger } from '../../logger.js';
|
|
27
|
+
import { getTransportHeaders } from '../../utils.js';
|
|
28
|
+
import type { Codec, CodecInputEvent, CodecMessage, CodecOutputEvent } from '../codec/types.js';
|
|
29
|
+
import { applyWireMessage } from './decode-fold.js';
|
|
30
|
+
import { loadHistory } from './load-history.js';
|
|
31
|
+
import { nodeKey, type TreeInternal } from './tree.js';
|
|
32
|
+
import type {
|
|
33
|
+
ActiveRun,
|
|
34
|
+
BranchSelection,
|
|
35
|
+
ConversationNode,
|
|
36
|
+
HistoryPage,
|
|
37
|
+
OutputEvent,
|
|
38
|
+
RunInfo,
|
|
39
|
+
RunLifecycleEvent,
|
|
40
|
+
RunNode,
|
|
41
|
+
SendOptions,
|
|
42
|
+
View,
|
|
43
|
+
} from './types.js';
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Events map
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
interface ViewEventsMap {
|
|
50
|
+
update: undefined;
|
|
51
|
+
'ably-message': Ably.InboundMessage;
|
|
52
|
+
run: RunLifecycleEvent;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Send delegate
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Internal delegate function provided by the session for executing sends.
|
|
61
|
+
* The View pre-computes the visible branch's flat message list and the
|
|
62
|
+
* codec-message-id of its tail (for auto-parent routing) before calling
|
|
63
|
+
* the delegate, so the delegate has no back-reference to the View.
|
|
64
|
+
*
|
|
65
|
+
* Each TInput carries its own routing metadata (`parent` / `target` /
|
|
66
|
+
* `codecMessageId`) via the {@link CodecInputEvent} base; the delegate
|
|
67
|
+
* reads those fields directly without runtime classification.
|
|
68
|
+
*
|
|
69
|
+
* `parentCodecMessageId` is the codec-message-id of the last message in
|
|
70
|
+
* the visible branch (extracted from the tail Run's projection per codec
|
|
71
|
+
* convention), or `undefined` for an empty conversation. The session
|
|
72
|
+
* uses it as the auto-parent for fresh user messages.
|
|
73
|
+
*/
|
|
74
|
+
export type SendDelegate<TInput extends CodecInputEvent> = (
|
|
75
|
+
input: TInput[],
|
|
76
|
+
options: SendOptions | undefined,
|
|
77
|
+
parentCodecMessageId: string | undefined,
|
|
78
|
+
) => Promise<ActiveRun>;
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Options
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/** Options for creating a View. */
|
|
85
|
+
export interface ViewOptions<TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection, TMessage> {
|
|
86
|
+
/** The tree to project. */
|
|
87
|
+
tree: TreeInternal<TInput, TOutput, TProjection>;
|
|
88
|
+
/** The Ably channel to load history from. */
|
|
89
|
+
channel: Ably.RealtimeChannel;
|
|
90
|
+
/** The codec used to project messages, mint regenerate inputs, and decode history. */
|
|
91
|
+
codec: Codec<TInput, TOutput, TProjection, TMessage>;
|
|
92
|
+
/** Delegate for executing sends through the session. */
|
|
93
|
+
sendDelegate: SendDelegate<TInput>;
|
|
94
|
+
/** Logger for diagnostic output. */
|
|
95
|
+
logger: Logger;
|
|
96
|
+
/** Called when the view is closed, allowing the owner to clean up references. */
|
|
97
|
+
onClose?: () => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Branch selection
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Internal tagged union representing why a branch was selected for an
|
|
106
|
+
* edit-fork group. Stored per group-root runId in the View's
|
|
107
|
+
* `_branchSelections` map. Not the public-facing {@link BranchSelection}
|
|
108
|
+
* — that's a UI-facing bundle returned by `view.branchSelection(id)`.
|
|
109
|
+
*/
|
|
110
|
+
type BranchSelectionState =
|
|
111
|
+
/** Explicit navigation via `selectSibling()`. The selected input-node key. */
|
|
112
|
+
| { kind: 'user'; selectedKey: string }
|
|
113
|
+
/** This view initiated an edit fork — auto-selected the new input node. */
|
|
114
|
+
| { kind: 'auto'; selectedKey: string }
|
|
115
|
+
/** An external fork appeared — pinned to the currently-visible sibling to prevent drift. */
|
|
116
|
+
| { kind: 'pinned'; selectedKey: string };
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Selection state for a regenerate group. Keyed by the anchor codec-message-id (the
|
|
120
|
+
* assistant codec-message-id being regenerated). Distinct from {@link BranchSelectionState}
|
|
121
|
+
* because regenerate groups are message-level (group members share an
|
|
122
|
+
* anchor codec-message-id), not edit forks of the user prompt.
|
|
123
|
+
*
|
|
124
|
+
* Unlike fork-of groups, regenerate groups do not "pin to current visible"
|
|
125
|
+
* when a new member appears externally — the default for a regenerate
|
|
126
|
+
* slot is always the latest member, so an external regenerator auto-rolls
|
|
127
|
+
* forward unless the user has explicitly selected an earlier member.
|
|
128
|
+
*/
|
|
129
|
+
type RegenSelection =
|
|
130
|
+
/** Explicit navigation via `selectSibling()`. The selected reply-run id. */
|
|
131
|
+
| { kind: 'user'; selectedRunId: string }
|
|
132
|
+
/** This view initiated a regenerate — auto-selected the new reply run when it arrived. */
|
|
133
|
+
| { kind: 'auto'; selectedRunId: string }
|
|
134
|
+
/**
|
|
135
|
+
* This view's `regenerate()` is in flight. Keyed (in `_regenSelections`) by
|
|
136
|
+
* the regenerate group's root; `carrierCodecMessageId` is the regenerate
|
|
137
|
+
* carrier event's id, used to recognise the new reply run when it appears.
|
|
138
|
+
*/
|
|
139
|
+
| { kind: 'pending'; carrierCodecMessageId: string };
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A resolved branch point: the group `kind` plus the sibling nodes that make
|
|
143
|
+
* up the alternatives. `fork-of` is an edit-style branch anchored at the user
|
|
144
|
+
* input node; `regen` is a regenerate-style branch anchored at the assistant
|
|
145
|
+
* slot. `groupRoot` is the group's key (input group root for fork-of, the
|
|
146
|
+
* original reply's group root for regen).
|
|
147
|
+
*/
|
|
148
|
+
type MessageBranchPoint<TProjection> =
|
|
149
|
+
| { kind: 'fork-of'; groupRoot: string; siblings: ConversationNode<TProjection>[] }
|
|
150
|
+
| { kind: 'regen'; groupRoot: string; siblings: ConversationNode<TProjection>[] };
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Send-input normalisation
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Normalise the two input shapes `View.send` accepts (a single TInput
|
|
158
|
+
* or an array) into the array shape the SendDelegate consumes.
|
|
159
|
+
* @param input - The raw input from `View.send`.
|
|
160
|
+
* @returns The normalised input array.
|
|
161
|
+
*/
|
|
162
|
+
const _normaliseSend = <TInput extends CodecInputEvent>(input: TInput | TInput[]): TInput[] =>
|
|
163
|
+
Array.isArray(input) ? input : [input];
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Fetch tuning
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Multiplier applied to the user-supplied Run-unit `loadOlder(limit)`
|
|
171
|
+
* when issuing the first `loadHistory` page request. `loadHistory`
|
|
172
|
+
* counts complete domain *messages* per page, not Runs; a typical Run
|
|
173
|
+
* produces ~2 messages (user + assistant). Asking for `limit * factor`
|
|
174
|
+
* messages on the first page reduces extra round-trips when the actual
|
|
175
|
+
* messages-per-Run ratio is around the factor. `_loadUntilVisible`
|
|
176
|
+
* still loops on the Run count regardless, so this is purely a
|
|
177
|
+
* fetch-efficiency hint.
|
|
178
|
+
*/
|
|
179
|
+
const _RUN_TO_MESSAGE_FETCH_FACTOR = 3;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Project a Tree `RunNode` down to the View-facing `RunInfo` shape:
|
|
183
|
+
* drop the codec projection and the structural fields that callers
|
|
184
|
+
* reach via `session.tree` when they need them.
|
|
185
|
+
* @param run - The tree's RunNode.
|
|
186
|
+
* @returns A projection-free RunInfo.
|
|
187
|
+
*/
|
|
188
|
+
const _toRunInfo = <TProjection>(run: RunNode<TProjection>): RunInfo => ({
|
|
189
|
+
runId: run.runId,
|
|
190
|
+
clientId: run.clientId,
|
|
191
|
+
status: run.status,
|
|
192
|
+
invocationId: run.invocationId,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Implementation
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
export class DefaultView<
|
|
200
|
+
TInput extends CodecInputEvent,
|
|
201
|
+
TOutput extends CodecOutputEvent,
|
|
202
|
+
TProjection,
|
|
203
|
+
TMessage,
|
|
204
|
+
> implements View<TInput, TMessage> {
|
|
205
|
+
private readonly _tree: TreeInternal<TInput, TOutput, TProjection>;
|
|
206
|
+
private readonly _channel: Ably.RealtimeChannel;
|
|
207
|
+
private readonly _codec: Codec<TInput, TOutput, TProjection, TMessage>;
|
|
208
|
+
private readonly _sendDelegate: SendDelegate<TInput>;
|
|
209
|
+
private readonly _logger: Logger;
|
|
210
|
+
private readonly _emitter: EventEmitter<ViewEventsMap>;
|
|
211
|
+
private readonly _onClose?: () => void;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* View-local branch selections: group-root runId → selection intent.
|
|
215
|
+
* Fork points not present here default to the latest sibling.
|
|
216
|
+
*/
|
|
217
|
+
private readonly _branchSelections = new Map<string, BranchSelectionState>();
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* View-local regenerate-group selections: anchor codec-message-id (the assistant
|
|
221
|
+
* codec-message-id being regenerated) → selection intent. Distinct from
|
|
222
|
+
* {@link _branchSelections} because a regenerate group is a set of
|
|
223
|
+
* same-parent reply runs — message-level alternatives at a single
|
|
224
|
+
* conversation slot, not edit forks of the prompt. Groups not present here default to the latest
|
|
225
|
+
* member (the most recent regenerator, or the original if no regen has
|
|
226
|
+
* landed).
|
|
227
|
+
*/
|
|
228
|
+
private readonly _regenSelections = new Map<string, RegenSelection>();
|
|
229
|
+
|
|
230
|
+
/** Spec: AIT-CT11c — runIds loaded from history but not yet revealed to the UI. */
|
|
231
|
+
private readonly _withheldRunIds = new Set<string>();
|
|
232
|
+
|
|
233
|
+
/** Snapshot of visible node keys — used to detect structural changes and for selection pinning. */
|
|
234
|
+
private _lastVisibleNodeKeys: string[] = [];
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Snapshot of visible projection references — used to detect in-place
|
|
238
|
+
* projection updates (streaming). One entry per visible Run.
|
|
239
|
+
*/
|
|
240
|
+
private _lastVisibleProjections: TProjection[] = [];
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Snapshot of the visible flat message chain with codec-message-ids —
|
|
244
|
+
* exposed verbatim via `getMessages()` and the internal correlation
|
|
245
|
+
* source for parent/branch routing.
|
|
246
|
+
*/
|
|
247
|
+
private _lastVisibleMessagePairs: CodecMessage<TMessage>[] = [];
|
|
248
|
+
|
|
249
|
+
/** Cached visible node-key Set — for O(1) lookup in event scoping. */
|
|
250
|
+
private _lastVisibleNodeKeySet = new Set<string>();
|
|
251
|
+
|
|
252
|
+
/** Whether there are more history pages to fetch from the channel. */
|
|
253
|
+
private _hasMoreHistory = false;
|
|
254
|
+
|
|
255
|
+
/** Internal state for continuing history pagination. */
|
|
256
|
+
private _lastHistoryPage: HistoryPage | undefined;
|
|
257
|
+
|
|
258
|
+
/** Buffer of withheld nodes (input + reply), drained newest-first by successive loadOlder() calls. */
|
|
259
|
+
private readonly _withheldBuffer: ConversationNode<TProjection>[] = [];
|
|
260
|
+
|
|
261
|
+
/** Unsubscribe functions for tree event subscriptions. */
|
|
262
|
+
private readonly _unsubs: (() => void)[] = [];
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Cached result of the last flat-nodes computation. Drives the visible
|
|
266
|
+
* message snapshot exposed via `getMessages()`; refreshed by
|
|
267
|
+
* `_computeFlatNodes()` on structural changes, selection changes,
|
|
268
|
+
* and history reveal.
|
|
269
|
+
*/
|
|
270
|
+
private _cachedNodes: ConversationNode<TProjection>[] = [];
|
|
271
|
+
|
|
272
|
+
private _loadingOlder = false;
|
|
273
|
+
private _processingHistory = false;
|
|
274
|
+
private _closed = false;
|
|
275
|
+
|
|
276
|
+
constructor(options: ViewOptions<TInput, TOutput, TProjection, TMessage>) {
|
|
277
|
+
this._tree = options.tree;
|
|
278
|
+
this._channel = options.channel;
|
|
279
|
+
this._codec = options.codec;
|
|
280
|
+
this._sendDelegate = options.sendDelegate;
|
|
281
|
+
this._onClose = options.onClose;
|
|
282
|
+
this._logger = options.logger.withContext({ component: 'View' });
|
|
283
|
+
this._logger.trace('DefaultView();');
|
|
284
|
+
this._emitter = new EventEmitter<ViewEventsMap>(this._logger);
|
|
285
|
+
|
|
286
|
+
// Compute initial cache and snapshot visible state
|
|
287
|
+
this._cachedNodes = this._computeFlatNodes();
|
|
288
|
+
this._updateVisibleSnapshot(this._cachedNodes);
|
|
289
|
+
|
|
290
|
+
// Subscribe to tree events and re-emit scoped versions
|
|
291
|
+
this._unsubs.push(
|
|
292
|
+
this._tree.on('update', () => {
|
|
293
|
+
this._onTreeUpdate();
|
|
294
|
+
}),
|
|
295
|
+
this._tree.on('ably-message', (msg) => {
|
|
296
|
+
this._onTreeAblyMessage(msg);
|
|
297
|
+
}),
|
|
298
|
+
this._tree.on('run', (event) => {
|
|
299
|
+
this._onTreeRun(event);
|
|
300
|
+
}),
|
|
301
|
+
this._tree.on('output', (event) => {
|
|
302
|
+
this._onTreeOutput(event);
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Handle decoded outputs folded into a Run (streaming delta). If the run
|
|
309
|
+
* is on the visible chain, recompute the flat message list and emit
|
|
310
|
+
* `update`.
|
|
311
|
+
* @param event - The output event from the Tree.
|
|
312
|
+
*/
|
|
313
|
+
private _onTreeOutput(event: OutputEvent<TOutput>): void {
|
|
314
|
+
if (this._processingHistory) return;
|
|
315
|
+
// The fold target may be a reply run (event.runId) or a user input node
|
|
316
|
+
// (event.runId undefined — the agent mints run-ids, so an input fold has
|
|
317
|
+
// none). Gate on whichever key the visible set holds.
|
|
318
|
+
const folded =
|
|
319
|
+
(event.runId !== undefined && this._lastVisibleNodeKeySet.has(event.runId)) ||
|
|
320
|
+
(event.inputCodecMessageId !== undefined && this._lastVisibleNodeKeySet.has(event.inputCodecMessageId));
|
|
321
|
+
if (!folded) return;
|
|
322
|
+
|
|
323
|
+
// The Tree emits `output` once per inbound message fold (with empty
|
|
324
|
+
// `events` for inputs-only folds), so it fires whenever a visible Run's
|
|
325
|
+
// projection changed and we always re-emit. The Reducer contract permits
|
|
326
|
+
// in-place mutation, which means we cannot use projection-ref or
|
|
327
|
+
// TMessage-ref equality to detect change: a streaming chunk legitimately
|
|
328
|
+
// mutates the same UIMessage object, and a ref-equality short-circuit
|
|
329
|
+
// would suppress every update. React state setters at the subscriber
|
|
330
|
+
// boundary already dedup by array reference, so a redundant emit is a
|
|
331
|
+
// no-op for unchanged hook consumers.
|
|
332
|
+
this._lastVisibleProjections = this._cachedNodes.map((n) => n.projection);
|
|
333
|
+
this._lastVisibleMessagePairs = this._extractMessages(this._cachedNodes);
|
|
334
|
+
this._emitter.emit('update');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// -------------------------------------------------------------------------
|
|
338
|
+
// Public query methods
|
|
339
|
+
// -------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
getMessages(): CodecMessage<TMessage>[] {
|
|
342
|
+
return this._lastVisibleMessagePairs;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
runs(): RunInfo[] {
|
|
346
|
+
// `_cachedNodes` is the visible node chain (inputs + reply runs) with
|
|
347
|
+
// pagination and sibling selection already applied. RunInfo is reply-run
|
|
348
|
+
// shaped, so filter to runs before projecting.
|
|
349
|
+
return this._cachedNodes
|
|
350
|
+
.filter((node): node is RunNode<TProjection> => node.kind === 'run')
|
|
351
|
+
.map((node) => _toRunInfo(node));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Compute the fresh visible node chain. The Tree's `visibleNodes` already
|
|
356
|
+
* applies kind-blind reachability and sibling selection (edit versions /
|
|
357
|
+
* regenerate runs collapse to the selected member), so the View only layers
|
|
358
|
+
* its pagination window on top: drop nodes whose key is currently withheld.
|
|
359
|
+
* @returns A fresh array of visible nodes (inputs + reply runs).
|
|
360
|
+
*/
|
|
361
|
+
private _computeFlatNodes(): ConversationNode<TProjection>[] {
|
|
362
|
+
const treeNodes = this._treeVisibleNodes();
|
|
363
|
+
if (this._withheldRunIds.size === 0) return treeNodes;
|
|
364
|
+
return treeNodes.filter((node) => !this._withheldRunIds.has(nodeKey(node)));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Recompute the visible node chain, refresh the cache + snapshot, and emit
|
|
369
|
+
* `update` unconditionally. Use after a mutation that always changes the
|
|
370
|
+
* visible output (e.g. an explicit selection or a withheld-batch reveal).
|
|
371
|
+
*/
|
|
372
|
+
private _recomputeAndEmit(): void {
|
|
373
|
+
this._cachedNodes = this._computeFlatNodes();
|
|
374
|
+
this._updateVisibleSnapshot(this._cachedNodes);
|
|
375
|
+
this._emitter.emit('update');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Recompute the visible node chain and, only if it differs from the current
|
|
380
|
+
* snapshot, refresh the cache + snapshot and emit `update`. Use after a
|
|
381
|
+
* mutation that may or may not move the visible window (e.g. a structural
|
|
382
|
+
* tree update, or a deferred regenerate promotion that may already match).
|
|
383
|
+
*/
|
|
384
|
+
private _recomputeAndEmitIfChanged(): void {
|
|
385
|
+
const nodes = this._computeFlatNodes();
|
|
386
|
+
if (this._visibleChanged(nodes)) {
|
|
387
|
+
this._cachedNodes = nodes;
|
|
388
|
+
this._updateVisibleSnapshot(nodes);
|
|
389
|
+
this._emitter.emit('update');
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Resolve the reply Run that owns a codec-message-id, narrowing the Tree's
|
|
395
|
+
* node union to a {@link RunNode}. A user-input codec-message-id resolves to
|
|
396
|
+
* an input node and yields `undefined` here — callers that must handle input
|
|
397
|
+
* nodes use {@link _tree.getNodeByCodecMessageId} directly.
|
|
398
|
+
* @param codecMessageId - The codec-message-id to resolve.
|
|
399
|
+
* @returns The owning RunNode, or undefined if absent or not a reply Run.
|
|
400
|
+
*/
|
|
401
|
+
private _runByCodecMessageId(codecMessageId: string): RunNode<TProjection> | undefined {
|
|
402
|
+
const node = this._tree.getNodeByCodecMessageId(codecMessageId);
|
|
403
|
+
return node?.kind === 'run' ? node : undefined;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Extract the flat TMessage[] from a visible node chain.
|
|
408
|
+
*
|
|
409
|
+
* In the two-node model the Tree's `visibleNodes` has already selected one
|
|
410
|
+
* member per sibling group (the chosen edit version, the chosen regenerate
|
|
411
|
+
* run), so a regenerate is just a sibling reply run that appears in place of
|
|
412
|
+
* the original. Each visible node contributes its own messages in projection
|
|
413
|
+
* order; the flat list is their concatenation.
|
|
414
|
+
*
|
|
415
|
+
* Deferred caveat: a mid-reply regenerate that replaces a non-head message
|
|
416
|
+
* inside a multi-message reply run is not expressible as a sibling run in
|
|
417
|
+
* this model and is not handled here (see the `regenerate-of-multi-message`
|
|
418
|
+
* golden test).
|
|
419
|
+
* @param nodes - The visible nodes (inputs + reply runs) in chronological order.
|
|
420
|
+
* @returns The flat message list, each message paired with its codec-message-id.
|
|
421
|
+
*/
|
|
422
|
+
private _extractMessages(nodes: ConversationNode<TProjection>[]): CodecMessage<TMessage>[] {
|
|
423
|
+
const messages: CodecMessage<TMessage>[] = [];
|
|
424
|
+
for (const node of nodes) {
|
|
425
|
+
for (const m of this._codec.getMessages(node.projection)) {
|
|
426
|
+
messages.push(m);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return messages;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
hasOlder(): boolean {
|
|
433
|
+
return this._withheldBuffer.length > 0 || this._hasMoreHistory;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Reveal up to `limit` older Runs in this view.
|
|
438
|
+
*
|
|
439
|
+
* The pagination unit is the **Run**, not the message. A single Run
|
|
440
|
+
* typically materialises into multiple messages (e.g. user + assistant
|
|
441
|
+
* pair) so revealing `limit` Runs may add several messages to the flat
|
|
442
|
+
* list returned by {@link getMessages}. Channel pages don't align to
|
|
443
|
+
* Run boundaries, so {@link _loadUntilVisible} keeps fetching channel
|
|
444
|
+
* pages until at least `limit` Runs are buffered (or the channel is
|
|
445
|
+
* exhausted).
|
|
446
|
+
* @param limit - Maximum number of older Runs to reveal. Defaults to 100.
|
|
447
|
+
*/
|
|
448
|
+
async loadOlder(limit = 100): Promise<void> {
|
|
449
|
+
if (this._closed || this._loadingOlder) return;
|
|
450
|
+
this._loadingOlder = true;
|
|
451
|
+
this._logger.trace('DefaultView.loadOlder();', { limit });
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
// Drain withheld buffer first (older nodes, released newest-first). The
|
|
455
|
+
// buffer holds a union of input + reply nodes, so this splices the newest
|
|
456
|
+
// `limit` NODES, not `limit` runs. Because an input node travels with the
|
|
457
|
+
// reply run it precedes, a drain may surface fewer than `limit` runs.
|
|
458
|
+
if (this._withheldBuffer.length > 0) {
|
|
459
|
+
const batch = this._withheldBuffer.splice(-limit, limit);
|
|
460
|
+
this._releaseWithheld(batch);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Buffer exhausted - load from channel history.
|
|
465
|
+
if (!this._hasMoreHistory && !this._lastHistoryPage) {
|
|
466
|
+
await this._loadFirstPage(limit);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (!this._hasMoreHistory) return;
|
|
471
|
+
|
|
472
|
+
if (!this._lastHistoryPage?.hasNext()) {
|
|
473
|
+
this._hasMoreHistory = false;
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const nextPage = await this._lastHistoryPage.next();
|
|
478
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be called during await
|
|
479
|
+
if (this._closed || !nextPage) {
|
|
480
|
+
if (!nextPage) this._hasMoreHistory = false;
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
await this._revealFromPage(nextPage, limit);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
this._logger.error('DefaultView.loadOlder(); failed', { error });
|
|
487
|
+
throw error;
|
|
488
|
+
} finally {
|
|
489
|
+
this._loadingOlder = false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// -------------------------------------------------------------------------
|
|
494
|
+
// Run lookup
|
|
495
|
+
// -------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
runOf(codecMessageId: string): RunInfo | undefined {
|
|
498
|
+
this._logger.trace('DefaultView.runOf();', { codecMessageId });
|
|
499
|
+
const node = this._tree.getNodeByCodecMessageId(codecMessageId);
|
|
500
|
+
if (!node) return undefined;
|
|
501
|
+
if (node.kind === 'run') return _toRunInfo(node);
|
|
502
|
+
// Input node: resolve to its selected reply run (undefined if none started).
|
|
503
|
+
const reply = this._selectedReplyRun(node.codecMessageId);
|
|
504
|
+
return reply ? _toRunInfo(reply) : undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Resolve the reply run currently selected for an input node, honouring the
|
|
509
|
+
* View's regenerate selection. Falls back to the latest reply run when no
|
|
510
|
+
* selection has been recorded; undefined when no reply run has started.
|
|
511
|
+
* @param inputCodecMessageId - The input node's codec-message-id.
|
|
512
|
+
* @returns The selected reply RunNode, or undefined.
|
|
513
|
+
*/
|
|
514
|
+
private _selectedReplyRun(inputCodecMessageId: string): RunNode<TProjection> | undefined {
|
|
515
|
+
const replies = this._tree.getReplyRuns(inputCodecMessageId);
|
|
516
|
+
if (replies.length === 0) return undefined;
|
|
517
|
+
if (replies.length === 1) return replies[0];
|
|
518
|
+
// Multiple reply runs = a regenerate group. Honour the View's selection
|
|
519
|
+
// (keyed by group root) else default to the latest.
|
|
520
|
+
const groupRoot = this._tree.getGroupRoot(replies[0]?.runId ?? '');
|
|
521
|
+
const sel = this._regenSelections.get(groupRoot);
|
|
522
|
+
const selectedKey = sel && sel.kind !== 'pending' ? sel.selectedRunId : undefined;
|
|
523
|
+
if (selectedKey !== undefined) {
|
|
524
|
+
const chosen = replies.find((r) => r.runId === selectedKey);
|
|
525
|
+
if (chosen) return chosen;
|
|
526
|
+
}
|
|
527
|
+
// Latest by startSerial; getReplyRuns is set-ordered, so sort defensively.
|
|
528
|
+
return replies.toSorted((a, b) => (a.startSerial ?? '').localeCompare(b.startSerial ?? '')).at(-1);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
run(runId: string): RunInfo | undefined {
|
|
532
|
+
this._logger.trace('DefaultView.run();', { runId });
|
|
533
|
+
const run = this._tree.getRunNode(runId);
|
|
534
|
+
return run ? _toRunInfo(run) : undefined;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// -------------------------------------------------------------------------
|
|
538
|
+
// Branch navigation (msg-anchored)
|
|
539
|
+
// -------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
// Spec: AIT-CT13c, AIT-CT13d — branch points are codec-message-id
|
|
542
|
+
// anchored. The View resolves the anchor (the user prompt for edits,
|
|
543
|
+
// the assistant slot for regens) and routes the selection to the
|
|
544
|
+
// appropriate internal selection map. Tree-level introspection
|
|
545
|
+
// (RunNode access, runId-keyed queries) remains on the {@link Tree}.
|
|
546
|
+
|
|
547
|
+
branchSelection(codecMessageId: string): BranchSelection<TMessage> {
|
|
548
|
+
const branch = this._resolveMessageBranchPoint(codecMessageId);
|
|
549
|
+
if (branch) {
|
|
550
|
+
// Each sibling contributes its head message as the branch-arrow slot:
|
|
551
|
+
// for an edit fork that is the alternate user prompt; for a regenerate
|
|
552
|
+
// group it is the variant's first (anchor-equivalent) message.
|
|
553
|
+
const siblings = branch.siblings.flatMap((s) => {
|
|
554
|
+
const first = this._codec.getMessages(s.projection).at(0);
|
|
555
|
+
return first ? [first.message] : [];
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
if (siblings.length > 0) {
|
|
559
|
+
const index = this._resolveSelectedIndex(branch);
|
|
560
|
+
const clamped = Math.max(0, Math.min(index, siblings.length - 1));
|
|
561
|
+
const selected = siblings[clamped];
|
|
562
|
+
return {
|
|
563
|
+
hasSiblings: siblings.length > 1,
|
|
564
|
+
siblings,
|
|
565
|
+
index: clamped,
|
|
566
|
+
selected,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Known non-anchor message: the bundle's invariant is that
|
|
572
|
+
// `siblings` contains the rendered message itself for any known
|
|
573
|
+
// codec-message-id, so plain bubbles get `siblings.length === 1`
|
|
574
|
+
// (not `0`) and the indexing space matches between read and write.
|
|
575
|
+
// Resolve the owning node kind-blind — a plain user prompt is an input
|
|
576
|
+
// node, an assistant message lives in a reply run; both carry a projection.
|
|
577
|
+
const owner = this._tree.getNodeByCodecMessageId(codecMessageId);
|
|
578
|
+
if (owner) {
|
|
579
|
+
const found = this._codec.getMessages(owner.projection).find((m) => m.codecMessageId === codecMessageId);
|
|
580
|
+
if (found !== undefined) {
|
|
581
|
+
return { hasSiblings: false, siblings: [found.message], index: 0, selected: found.message };
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Unknown id, or the owner Run is known but the codec doesn't surface
|
|
586
|
+
// a message with this id from the projection (e.g. an event-only fold
|
|
587
|
+
// such as a tool result that mutates an assistant in-place without
|
|
588
|
+
// exposing its own TMessage). Treat both as "no rendered message",
|
|
589
|
+
// returning the safe empty bundle.
|
|
590
|
+
return { hasSiblings: false, siblings: [], index: 0, selected: undefined };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Spec: AIT-CT13c, AIT-CT13d
|
|
594
|
+
selectSibling(codecMessageId: string, index: number): void {
|
|
595
|
+
this._logger.trace('DefaultView.selectSibling();', { codecMessageId, index });
|
|
596
|
+
const branch = this._resolveMessageBranchPoint(codecMessageId);
|
|
597
|
+
if (!branch) return;
|
|
598
|
+
const clamped = Math.max(0, Math.min(index, branch.siblings.length - 1));
|
|
599
|
+
const selected = branch.siblings[clamped];
|
|
600
|
+
if (!selected) return; // unreachable: clamped is always in bounds
|
|
601
|
+
if (branch.kind === 'fork-of') {
|
|
602
|
+
this._branchSelections.set(branch.groupRoot, { kind: 'user', selectedKey: nodeKey(selected) });
|
|
603
|
+
this._logger.debug('DefaultView.selectSibling(); fork-of', {
|
|
604
|
+
codecMessageId,
|
|
605
|
+
index: clamped,
|
|
606
|
+
selectedKey: nodeKey(selected),
|
|
607
|
+
});
|
|
608
|
+
} else {
|
|
609
|
+
this._regenSelections.set(branch.groupRoot, { kind: 'user', selectedRunId: nodeKey(selected) });
|
|
610
|
+
this._logger.debug('DefaultView.selectSibling(); regenerate', {
|
|
611
|
+
codecMessageId,
|
|
612
|
+
index: clamped,
|
|
613
|
+
selectedRunId: nodeKey(selected),
|
|
614
|
+
groupRoot: branch.groupRoot,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
this._recomputeAndEmit();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Resolve the currently selected sibling's index inside a branch group.
|
|
622
|
+
* Pending selections fall back to the latest sibling. The caller clamps
|
|
623
|
+
* the returned index against any post-extraction filtering.
|
|
624
|
+
* @param branch - Resolved branch-point descriptor from `_resolveMessageBranchPoint`.
|
|
625
|
+
* @returns The selected sibling's index within `branch.siblings`.
|
|
626
|
+
*/
|
|
627
|
+
private _resolveSelectedIndex(branch: MessageBranchPoint<TProjection>): number {
|
|
628
|
+
if (branch.kind === 'fork-of') {
|
|
629
|
+
const sel = this._branchSelections.get(branch.groupRoot);
|
|
630
|
+
if (!sel) return branch.siblings.length - 1;
|
|
631
|
+
const idx = branch.siblings.findIndex((n) => nodeKey(n) === sel.selectedKey);
|
|
632
|
+
return idx === -1 ? branch.siblings.length - 1 : idx;
|
|
633
|
+
}
|
|
634
|
+
const sel = this._regenSelections.get(branch.groupRoot);
|
|
635
|
+
if (!sel || sel.kind === 'pending') return branch.siblings.length - 1;
|
|
636
|
+
const idx = branch.siblings.findIndex((n) => nodeKey(n) === sel.selectedRunId);
|
|
637
|
+
return idx === -1 ? branch.siblings.length - 1 : idx;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Resolve the branch point anchored at `codecMessageId`, if any.
|
|
642
|
+
*
|
|
643
|
+
* Returns the resolved group `kind` along with the sibling list so the
|
|
644
|
+
* caller can update the correct selection map without re-entering the
|
|
645
|
+
* runId-based `select()` dispatch (which biases to fork-of first and
|
|
646
|
+
* would mis-route a regen-anchor codec-message-id when the owning Run is in
|
|
647
|
+
* BOTH groups — e.g. R1 owns both a user prompt that got edited and
|
|
648
|
+
* an assistant that got regenerated).
|
|
649
|
+
*
|
|
650
|
+
* Two anchor cases:
|
|
651
|
+
* - **fork-of** — `codecMessageId` is the first message of a Run in a fork-of
|
|
652
|
+
* sibling group (edit-style branch point anchored at the user prompt).
|
|
653
|
+
* - **regen** — `codecMessageId` is the regen-anchor itself (in the owner Run)
|
|
654
|
+
* or content of a regenerator Run (regen-style branch point anchored
|
|
655
|
+
* at the assistant slot).
|
|
656
|
+
* @param codecMessageId - The codec-message-id to look up.
|
|
657
|
+
* @returns The kind + sibling list + group key (runId for fork-of,
|
|
658
|
+
* anchor codec-message-id for regen), or undefined when `codecMessageId` is not an
|
|
659
|
+
* anchor in either group type.
|
|
660
|
+
*/
|
|
661
|
+
private _resolveMessageBranchPoint(codecMessageId: string): MessageBranchPoint<TProjection> | undefined {
|
|
662
|
+
const node = this._tree.getNodeByCodecMessageId(codecMessageId);
|
|
663
|
+
if (!node) return undefined;
|
|
664
|
+
|
|
665
|
+
// Edit-fork branch point: `codecMessageId` is a user INPUT node that has
|
|
666
|
+
// sibling input nodes (alternate prompts via fork-of). The anchor is the
|
|
667
|
+
// input node's own codec-message-id.
|
|
668
|
+
if (node.kind === 'input') {
|
|
669
|
+
const siblings = this._tree.getSiblingNodes(node.codecMessageId);
|
|
670
|
+
if (siblings.length > 1) {
|
|
671
|
+
return { kind: 'fork-of', groupRoot: this._tree.getGroupRoot(node.codecMessageId), siblings };
|
|
672
|
+
}
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Regenerate branch point: `codecMessageId` is owned by a reply run that has
|
|
677
|
+
// sibling reply runs (the original reply + its regenerators, all parented at
|
|
678
|
+
// the same input node). Anchor on the head message of the run so arrows
|
|
679
|
+
// appear once per variant, not on every follow-up message.
|
|
680
|
+
const siblings = this._tree.getSiblingNodes(node.runId);
|
|
681
|
+
if (siblings.length > 1) {
|
|
682
|
+
const firstMsg = this._codec.getMessages(node.projection).at(0);
|
|
683
|
+
if (firstMsg?.codecMessageId === codecMessageId) {
|
|
684
|
+
return { kind: 'regen', groupRoot: this._tree.getGroupRoot(node.runId), siblings };
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return undefined;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// -------------------------------------------------------------------------
|
|
692
|
+
// Write operations
|
|
693
|
+
// -------------------------------------------------------------------------
|
|
694
|
+
|
|
695
|
+
// Spec: AIT-CT3, AIT-CT4
|
|
696
|
+
async send(input: TInput | TInput[], options?: SendOptions): Promise<ActiveRun> {
|
|
697
|
+
this._logger.trace('DefaultView.send();');
|
|
698
|
+
if (this._closed) {
|
|
699
|
+
throw new Ably.ErrorInfo('unable to send; view is closed', ErrorCode.InvalidArgument, 400);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const normalised = _normaliseSend<TInput>(input);
|
|
703
|
+
|
|
704
|
+
// The codec-message-id of the visible branch tail — the delegate uses it
|
|
705
|
+
// for auto-parent routing on fresh user messages.
|
|
706
|
+
const parentCodecMessageId = this._lastVisibleMessagePairs.at(-1)?.codecMessageId;
|
|
707
|
+
|
|
708
|
+
const result = await this._sendDelegate(normalised, options, parentCodecMessageId);
|
|
709
|
+
this._applyForkAutoSelect(result, options);
|
|
710
|
+
return result;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Auto-select / pin branch selections after a forking send.
|
|
715
|
+
* @param result - The ActiveRun returned by the delegate.
|
|
716
|
+
* @param options - The SendOptions passed by the caller.
|
|
717
|
+
*/
|
|
718
|
+
private _applyForkAutoSelect(result: ActiveRun, options: SendOptions | undefined): void {
|
|
719
|
+
// Spec: AIT-CT13e
|
|
720
|
+
if (!options?.forkOf) return;
|
|
721
|
+
|
|
722
|
+
// An edit inserts a NEW user input node optimistically; its codec-message-id
|
|
723
|
+
// is the (only) optimistic id and IS its node key. Edit forks are input-node
|
|
724
|
+
// sibling groups, so the selection is keyed by the input group root and the
|
|
725
|
+
// selected member is the new input node's key.
|
|
726
|
+
const editedInputKey = result.optimisticCodecMessageIds.at(0);
|
|
727
|
+
if (editedInputKey === undefined) return;
|
|
728
|
+
const groupRoot = this._tree.getGroupRoot(editedInputKey);
|
|
729
|
+
|
|
730
|
+
this._branchSelections.set(groupRoot, { kind: 'auto', selectedKey: editedInputKey });
|
|
731
|
+
this._recomputeAndEmit();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Auto-select / pin the regenerate group anchored at `anchorCodecMessageId` so
|
|
736
|
+
* the new Run's content appears as soon as the agent's run-start lands.
|
|
737
|
+
*
|
|
738
|
+
* `View.regenerate()` calls this with the assistant codec-message-id being
|
|
739
|
+
* regenerated. The Run doesn't exist yet on the channel (the regenerate
|
|
740
|
+
* wire is wire-only); the selection is recorded as `pending` and
|
|
741
|
+
* promoted to `auto` by `_pinRegenSelections` once the corresponding
|
|
742
|
+
* Run is created in the tree.
|
|
743
|
+
* @param result - The ActiveRun returned by the delegate (run-id is the new regenerator's).
|
|
744
|
+
* @param anchorCodecMessageId - The codec-message-id of the assistant being regenerated.
|
|
745
|
+
*/
|
|
746
|
+
private _applyRegenerateAutoSelect(result: ActiveRun, anchorCodecMessageId: string): void {
|
|
747
|
+
// A regenerate produces a new reply run parented at the SAME input node as
|
|
748
|
+
// the original reply (the regenerate group). The agent mints the run-id, so
|
|
749
|
+
// we cannot pin by it synchronously. Resolve the group root from the
|
|
750
|
+
// original reply run owning the anchor, and pin a pending selection keyed by
|
|
751
|
+
// that group root, carrying the regenerate carrier's codec-message-id
|
|
752
|
+
// (`result.inputCodecMessageId`) so we can promote when the new reply run lands.
|
|
753
|
+
const anchorRun = this._runByCodecMessageId(anchorCodecMessageId);
|
|
754
|
+
if (!anchorRun) return;
|
|
755
|
+
const groupRoot = this._tree.getGroupRoot(anchorRun.runId);
|
|
756
|
+
|
|
757
|
+
this._regenSelections.set(groupRoot, {
|
|
758
|
+
kind: 'pending',
|
|
759
|
+
carrierCodecMessageId: result.inputCodecMessageId,
|
|
760
|
+
});
|
|
761
|
+
this._logger.debug('DefaultView._applyRegenerateAutoSelect(); deferring regenerate selection', {
|
|
762
|
+
anchorCodecMessageId,
|
|
763
|
+
groupRoot,
|
|
764
|
+
carrier: result.inputCodecMessageId,
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
// The new reply run may already be in the tree (run-start raced ahead of the
|
|
768
|
+
// sendDelegate resolution). Promote now and recompute so the visible set
|
|
769
|
+
// catches up without waiting for the next structural change.
|
|
770
|
+
this._resolvePendingRegenSelections();
|
|
771
|
+
this._recomputeAndEmitIfChanged();
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Spec: AIT-CT5, AIT-CT13d
|
|
775
|
+
async regenerate(messageId: string, options?: SendOptions): Promise<ActiveRun> {
|
|
776
|
+
this._logger.trace('DefaultView.regenerate();', { messageId });
|
|
777
|
+
|
|
778
|
+
if (this._closed) {
|
|
779
|
+
throw new Ably.ErrorInfo('unable to regenerate; view is closed', ErrorCode.InvalidArgument, 400);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// `messageId` is the assistant being regenerated. The new Run is a
|
|
783
|
+
// continuation of the regenerated message's Run, not a fork: the
|
|
784
|
+
// message-level replacement (new assistant supersedes the original)
|
|
785
|
+
// happens at projection extraction time. We still resolve the parent
|
|
786
|
+
// user prompt so the new assistant's wire `parent` is correct,
|
|
787
|
+
// and we send the truncated history (through the parent inclusive)
|
|
788
|
+
// so the LLM re-answers the right message.
|
|
789
|
+
const targetRun = this._runByCodecMessageId(messageId);
|
|
790
|
+
if (!targetRun) {
|
|
791
|
+
throw new Ably.ErrorInfo(
|
|
792
|
+
`unable to regenerate; message not found in tree: ${messageId}`,
|
|
793
|
+
ErrorCode.InvalidArgument,
|
|
794
|
+
400,
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
const parentCodecMessageId = this._findParentMsgId(targetRun, messageId);
|
|
798
|
+
if (!parentCodecMessageId) {
|
|
799
|
+
throw new Ably.ErrorInfo(
|
|
800
|
+
`unable to regenerate; parent user message not found for ${messageId}`,
|
|
801
|
+
ErrorCode.InvalidArgument,
|
|
802
|
+
400,
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Canonical regen anchor: when the user clicks Regenerate on an
|
|
807
|
+
// already-regenerated assistant, the new alternative SHOULD belong
|
|
808
|
+
// to the SAME branch point as the previous regen — but ONLY when
|
|
809
|
+
// the target is the position-equivalent of the group anchor (the
|
|
810
|
+
// head message of the regenerator Run). For a trailing follow-up
|
|
811
|
+
// message inside a regenerator Run (e.g. the LLM text after the
|
|
812
|
+
// regenerated tool call), the user expects the regen to anchor at
|
|
813
|
+
// the specific message they clicked, not roll up to the group root.
|
|
814
|
+
// Rebasing trailing regens to the group root produces a confusing
|
|
815
|
+
// "N+1 / N+1" counter on the tool-call bubble and runs the whole
|
|
816
|
+
// turn from scratch instead of just regenerating the text.
|
|
817
|
+
let regenAnchorMsgId = messageId;
|
|
818
|
+
if (targetRun.regeneratesCodecMessageId !== undefined) {
|
|
819
|
+
const firstMsg = this._codec.getMessages(targetRun.projection).at(0);
|
|
820
|
+
if (firstMsg?.codecMessageId === messageId) {
|
|
821
|
+
regenAnchorMsgId = targetRun.regeneratesCodecMessageId;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const sendOptions: SendOptions = {
|
|
826
|
+
...options,
|
|
827
|
+
parent: parentCodecMessageId,
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// Mint a regenerate input via the codec. The codec's well-known
|
|
831
|
+
// `Regenerate` carries `target: regenAnchorMsgId` and `parent:
|
|
832
|
+
// parentCodecMessageId`; the session reads those fields off the input
|
|
833
|
+
// directly when building transport headers (`fork-of` and
|
|
834
|
+
// `parent`). The agent's input-event lookup catches the wire signal;
|
|
835
|
+
// no tree-upsert / projection fold runs locally.
|
|
836
|
+
const regenerate = this._codec.createRegenerate(regenAnchorMsgId, parentCodecMessageId);
|
|
837
|
+
const result = await this._sendDelegate([regenerate], sendOptions, parentCodecMessageId);
|
|
838
|
+
this._applyRegenerateAutoSelect(result, regenAnchorMsgId);
|
|
839
|
+
return result;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Spec: AIT-CT6
|
|
843
|
+
async edit(messageId: string, inputs: TInput | TInput[], options?: SendOptions): Promise<ActiveRun> {
|
|
844
|
+
this._logger.trace('DefaultView.edit();', { messageId });
|
|
845
|
+
|
|
846
|
+
if (this._closed) {
|
|
847
|
+
throw new Ably.ErrorInfo('unable to edit; view is closed', ErrorCode.InvalidArgument, 400);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// The edit target is a user prompt — a run-less INPUT node — so resolve
|
|
851
|
+
// it kind-blind, not via the reply-run-only lookup.
|
|
852
|
+
const targetNode = this._tree.getNodeByCodecMessageId(messageId);
|
|
853
|
+
if (!targetNode) {
|
|
854
|
+
throw new Ably.ErrorInfo(
|
|
855
|
+
`unable to edit; message not found in tree: ${messageId}`,
|
|
856
|
+
ErrorCode.InvalidArgument,
|
|
857
|
+
400,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
const parentCodecMessageId = this._findParentMsgId(targetNode, messageId);
|
|
861
|
+
|
|
862
|
+
return this.send(inputs, {
|
|
863
|
+
...options,
|
|
864
|
+
forkOf: messageId,
|
|
865
|
+
parent: parentCodecMessageId,
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Find the codec-message-id of the message immediately preceding `targetMsgId` in
|
|
871
|
+
* the visible conversation.
|
|
872
|
+
*
|
|
873
|
+
* Consults the View's visible message chain first so message-level
|
|
874
|
+
* replacements (regenerate) are respected: regenerating an
|
|
875
|
+
* already-regenerated assistant lands the predecessor on the user
|
|
876
|
+
* prompt the regen is responding to, NOT on the hidden original
|
|
877
|
+
* assistant that occupies the same conversation slot. Falls back to a
|
|
878
|
+
* projection-walk for the rare case where `targetMsgId` isn't on the
|
|
879
|
+
* visible chain (e.g. caller is operating on a Run that's selection-
|
|
880
|
+
* hidden by the current branch).
|
|
881
|
+
* @param targetNode - The node (input node or reply run) that owns `targetMsgId`.
|
|
882
|
+
* @param targetMsgId - The codec-message-id to find the parent of.
|
|
883
|
+
* @returns The parent codec-message-id, or undefined if no predecessor exists.
|
|
884
|
+
*/
|
|
885
|
+
private _findParentMsgId(targetNode: ConversationNode<TProjection>, targetMsgId: string): string | undefined {
|
|
886
|
+
const visible = this._lastVisibleMessagePairs;
|
|
887
|
+
const visIdx = visible.findIndex((m) => m.codecMessageId === targetMsgId);
|
|
888
|
+
if (visIdx > 0) {
|
|
889
|
+
return visible[visIdx - 1]?.codecMessageId;
|
|
890
|
+
}
|
|
891
|
+
if (visIdx === 0) return undefined;
|
|
892
|
+
|
|
893
|
+
const messages = this._codec.getMessages(targetNode.projection);
|
|
894
|
+
const idx = messages.findIndex((m) => m.codecMessageId === targetMsgId);
|
|
895
|
+
if (idx > 0) {
|
|
896
|
+
return messages[idx - 1]?.codecMessageId;
|
|
897
|
+
}
|
|
898
|
+
if (idx === 0 && targetNode.parentCodecMessageId !== undefined) {
|
|
899
|
+
// The structural predecessor is the node owning parentCodecMessageId
|
|
900
|
+
// (an input node, or a prior reply run). Its tail message is the parent.
|
|
901
|
+
const parentNode = this._tree.getNodeByCodecMessageId(targetNode.parentCodecMessageId);
|
|
902
|
+
if (parentNode) {
|
|
903
|
+
return this._codec.getMessages(parentNode.projection).at(-1)?.codecMessageId;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return undefined;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// -------------------------------------------------------------------------
|
|
910
|
+
// Event subscription
|
|
911
|
+
// -------------------------------------------------------------------------
|
|
912
|
+
|
|
913
|
+
// Spec: AIT-CT8a, AIT-CT8b, AIT-CT8e
|
|
914
|
+
on(event: 'update', handler: () => void): () => void;
|
|
915
|
+
on(event: 'ably-message', handler: (msg: Ably.InboundMessage) => void): () => void;
|
|
916
|
+
on(event: 'run', handler: (event: RunLifecycleEvent) => void): () => void;
|
|
917
|
+
on(
|
|
918
|
+
event: 'update' | 'ably-message' | 'run',
|
|
919
|
+
handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event: RunLifecycleEvent) => void),
|
|
920
|
+
): () => void {
|
|
921
|
+
// CAST: overload signatures enforce correct handler types per event name.
|
|
922
|
+
const cb = handler as (arg: ViewEventsMap[keyof ViewEventsMap]) => void;
|
|
923
|
+
this._emitter.on(event, cb);
|
|
924
|
+
return () => {
|
|
925
|
+
this._emitter.off(event, cb);
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// -------------------------------------------------------------------------
|
|
930
|
+
// Lifecycle
|
|
931
|
+
// -------------------------------------------------------------------------
|
|
932
|
+
|
|
933
|
+
close(): void {
|
|
934
|
+
if (this._closed) return;
|
|
935
|
+
this._logger.info('DefaultView.close();');
|
|
936
|
+
this._closed = true;
|
|
937
|
+
this._loadingOlder = false;
|
|
938
|
+
for (const unsub of this._unsubs) unsub();
|
|
939
|
+
this._unsubs.length = 0;
|
|
940
|
+
this._emitter.off();
|
|
941
|
+
this._branchSelections.clear();
|
|
942
|
+
this._regenSelections.clear();
|
|
943
|
+
this._withheldRunIds.clear();
|
|
944
|
+
this._withheldBuffer.length = 0;
|
|
945
|
+
this._onClose?.();
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// -------------------------------------------------------------------------
|
|
949
|
+
// Private: history loading
|
|
950
|
+
// -------------------------------------------------------------------------
|
|
951
|
+
|
|
952
|
+
private async _loadFirstPage(limit: number): Promise<void> {
|
|
953
|
+
// loadHistory's limit counts complete domain messages per page (not
|
|
954
|
+
// Runs); see `_RUN_TO_MESSAGE_FETCH_FACTOR` for the scaling rationale.
|
|
955
|
+
const messageLimit = limit * _RUN_TO_MESSAGE_FETCH_FACTOR;
|
|
956
|
+
const firstPage = await loadHistory(this._channel, { limit: messageLimit }, this._logger);
|
|
957
|
+
if (this._closed) return;
|
|
958
|
+
await this._revealFromPage(firstPage, limit);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Walk channel history from `page` until at least `limit` new Runs are
|
|
963
|
+
* observed (or the channel is exhausted), then reveal the newest batch and
|
|
964
|
+
* withhold the rest. Snapshots the already-visible nodes up front so only
|
|
965
|
+
* newly-observed Runs count toward `limit`. No-op if the view closed during
|
|
966
|
+
* the page walk.
|
|
967
|
+
* @param page - The decoded history page to start from.
|
|
968
|
+
* @param limit - Max Runs to reveal in this batch.
|
|
969
|
+
*/
|
|
970
|
+
private async _revealFromPage(page: HistoryPage, limit: number): Promise<void> {
|
|
971
|
+
// Snapshot before loading: every node already in the tree stays visible.
|
|
972
|
+
const beforeRunIds = new Set(this._treeVisibleNodes().map((n) => nodeKey(n)));
|
|
973
|
+
|
|
974
|
+
const { newVisible, lastPage } = await this._loadUntilVisible(page, limit, beforeRunIds);
|
|
975
|
+
if (this._closed) return;
|
|
976
|
+
this._lastHistoryPage = lastPage;
|
|
977
|
+
this._hasMoreHistory = lastPage.hasNext();
|
|
978
|
+
this._splitReveal(newVisible, limit);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Reveal the newest `limit` Runs from `newVisible` and withhold the rest
|
|
983
|
+
* so subsequent `loadOlder` calls can drain them. Called by
|
|
984
|
+
* {@link _revealFromPage} to enforce the Run-unit pagination contract.
|
|
985
|
+
* @param newVisible - Newly observed Runs from the history fetch.
|
|
986
|
+
* @param limit - Max Runs to reveal in this batch.
|
|
987
|
+
*/
|
|
988
|
+
private _splitReveal(newVisible: ConversationNode<TProjection>[], limit: number): void {
|
|
989
|
+
// Reveal granularity is the reply RUN; an input node travels with the reply
|
|
990
|
+
// run it precedes. Walk newest-first, counting reply runs toward `limit`,
|
|
991
|
+
// and split the union list at the resulting boundary so an input + its reply
|
|
992
|
+
// are revealed or withheld together.
|
|
993
|
+
let runs = 0;
|
|
994
|
+
let splitIdx = newVisible.length; // index of first revealed node
|
|
995
|
+
for (let i = newVisible.length - 1; i >= 0; i--) {
|
|
996
|
+
const node = newVisible[i];
|
|
997
|
+
if (node?.kind === 'run') {
|
|
998
|
+
if (runs === limit) break;
|
|
999
|
+
runs++;
|
|
1000
|
+
}
|
|
1001
|
+
splitIdx = i;
|
|
1002
|
+
}
|
|
1003
|
+
const batch = newVisible.slice(splitIdx);
|
|
1004
|
+
const withheld = newVisible.slice(0, splitIdx);
|
|
1005
|
+
for (const n of withheld) {
|
|
1006
|
+
this._withheldRunIds.add(nodeKey(n));
|
|
1007
|
+
}
|
|
1008
|
+
this._withheldBuffer.push(...withheld);
|
|
1009
|
+
this._releaseWithheld(batch);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Replay a history page's raw messages into the Tree. Dispatches by Ably
|
|
1014
|
+
* message name to run-lifecycle vs. regular wire messages, mirroring the
|
|
1015
|
+
* live `client-session._handleMessage` decode loop. Uses a fresh decoder
|
|
1016
|
+
* since the session's live decoder maintains its own stream-tracker state.
|
|
1017
|
+
* @param page - The history page returned by `loadHistory`.
|
|
1018
|
+
*/
|
|
1019
|
+
private _processHistoryPage(page: HistoryPage): void {
|
|
1020
|
+
this._processingHistory = true;
|
|
1021
|
+
try {
|
|
1022
|
+
// Reconstruct the tree via the shared decode-fold engine — the same path
|
|
1023
|
+
// the client's live loop uses, so history replay can't drift from it.
|
|
1024
|
+
const decoder = this._codec.createDecoder();
|
|
1025
|
+
for (const rawMsg of page.rawMessages) {
|
|
1026
|
+
applyWireMessage(this._tree, decoder, rawMsg);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Emit ably-message in a batch AFTER the whole page is applied, so a
|
|
1030
|
+
// subscriber resolving the owning Run sees the fully-rebuilt tree.
|
|
1031
|
+
for (const msg of page.rawMessages) {
|
|
1032
|
+
this._tree.emitAblyMessage(msg);
|
|
1033
|
+
}
|
|
1034
|
+
} finally {
|
|
1035
|
+
this._processingHistory = false;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
private async _loadUntilVisible(
|
|
1040
|
+
firstPage: HistoryPage,
|
|
1041
|
+
target: number,
|
|
1042
|
+
beforeRunIds: Set<string>,
|
|
1043
|
+
): Promise<{ newVisible: ConversationNode<TProjection>[]; lastPage: HistoryPage }> {
|
|
1044
|
+
this._processHistoryPage(firstPage);
|
|
1045
|
+
let page = firstPage;
|
|
1046
|
+
|
|
1047
|
+
const newVisibleCount = (): number => {
|
|
1048
|
+
let count = 0;
|
|
1049
|
+
for (const n of this._treeVisibleNodes()) {
|
|
1050
|
+
// Pagination counts reply RUNS toward the target (an input node travels
|
|
1051
|
+
// with the reply run it precedes — see `_splitReveal`).
|
|
1052
|
+
if (n.kind === 'run' && !beforeRunIds.has(nodeKey(n))) count++;
|
|
1053
|
+
}
|
|
1054
|
+
return count;
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
while (newVisibleCount() < target && page.hasNext()) {
|
|
1058
|
+
const nextPage = await page.next();
|
|
1059
|
+
if (!nextPage || this._closed) break;
|
|
1060
|
+
this._processHistoryPage(nextPage);
|
|
1061
|
+
page = nextPage;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const newVisible = this._treeVisibleNodes().filter((n) => !beforeRunIds.has(nodeKey(n)));
|
|
1065
|
+
return { newVisible, lastPage: page };
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Spec: AIT-CT11a
|
|
1069
|
+
private _releaseWithheld(nodes: ConversationNode<TProjection>[]): void {
|
|
1070
|
+
for (const n of nodes) {
|
|
1071
|
+
this._withheldRunIds.delete(nodeKey(n));
|
|
1072
|
+
}
|
|
1073
|
+
if (nodes.length > 0) {
|
|
1074
|
+
this._recomputeAndEmit();
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// -------------------------------------------------------------------------
|
|
1079
|
+
// Private: scoped event forwarding
|
|
1080
|
+
// -------------------------------------------------------------------------
|
|
1081
|
+
|
|
1082
|
+
private _updateVisibleSnapshot(nodes?: ConversationNode<TProjection>[]): void {
|
|
1083
|
+
const resolved = nodes ?? this._cachedNodes;
|
|
1084
|
+
// Identity key = nodeKey (runId for reply runs, codecMessageId for inputs),
|
|
1085
|
+
// so the visible set scopes events for both kinds and input-node parents.
|
|
1086
|
+
this._lastVisibleNodeKeys = resolved.map((n) => nodeKey(n));
|
|
1087
|
+
this._lastVisibleNodeKeySet = new Set(this._lastVisibleNodeKeys);
|
|
1088
|
+
this._lastVisibleProjections = resolved.map((n) => n.projection);
|
|
1089
|
+
this._lastVisibleMessagePairs = this._extractMessages(resolved);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
private _onTreeUpdate(): void {
|
|
1093
|
+
// Suppress update forwarding while processing history pages. During
|
|
1094
|
+
// _processHistoryPage, each tree.applyMessage() fires this handler
|
|
1095
|
+
// synchronously — but _withheldRunIds hasn't been populated yet, so
|
|
1096
|
+
// _computeFlatNodes() would return unfiltered history. Without this guard,
|
|
1097
|
+
// subscribers briefly see all history Runs before the pagination window
|
|
1098
|
+
// is applied. The final update is emitted by _releaseWithheld after
|
|
1099
|
+
// withholding is set up.
|
|
1100
|
+
if (this._processingHistory) return;
|
|
1101
|
+
|
|
1102
|
+
// The Tree emits `update` only on structural change (new/removed Run,
|
|
1103
|
+
// sort-reorder, startSerial promotion, run-start backfill), so every
|
|
1104
|
+
// update reaching here warrants a full re-walk. Content-only folds flow
|
|
1105
|
+
// through `output` (_onTreeOutput) instead.
|
|
1106
|
+
|
|
1107
|
+
// Pin selections for previously-visible Runs that now have siblings.
|
|
1108
|
+
// This prevents new forks (from other views' edits/regenerates) from
|
|
1109
|
+
// shifting this view to a branch the user didn't navigate to.
|
|
1110
|
+
this._pinBranchSelections();
|
|
1111
|
+
this._resolvePendingRegenSelections();
|
|
1112
|
+
|
|
1113
|
+
this._recomputeAndEmitIfChanged();
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Build the unified selection map the Tree's `visibleNodes` consumes:
|
|
1118
|
+
* `groupRootKey -> selectedKey`, covering both edit forks (input-node groups,
|
|
1119
|
+
* keyed by the input group root) and regenerate groups (reply-run groups,
|
|
1120
|
+
* keyed by the original reply's group root). Pending entries (no chosen
|
|
1121
|
+
* member yet) are omitted so the Tree falls back to the latest sibling.
|
|
1122
|
+
* @returns The merged group-root → selected-key map.
|
|
1123
|
+
*/
|
|
1124
|
+
private _resolveSelections(): Map<string, string> {
|
|
1125
|
+
const resolved = new Map<string, string>();
|
|
1126
|
+
for (const [groupRoot, sel] of this._branchSelections) {
|
|
1127
|
+
resolved.set(groupRoot, sel.selectedKey);
|
|
1128
|
+
}
|
|
1129
|
+
for (const [groupRoot, sel] of this._regenSelections) {
|
|
1130
|
+
if (sel.kind === 'pending') continue;
|
|
1131
|
+
resolved.set(groupRoot, sel.selectedRunId);
|
|
1132
|
+
}
|
|
1133
|
+
return resolved;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* The Tree's visible node chain under this view's current selections — the
|
|
1138
|
+
* reachable, sibling-resolved nodes before the View's pagination window is
|
|
1139
|
+
* applied.
|
|
1140
|
+
* @returns The selection-resolved visible node chain.
|
|
1141
|
+
*/
|
|
1142
|
+
private _treeVisibleNodes(): ConversationNode<TProjection>[] {
|
|
1143
|
+
return this._tree.visibleNodes(this._resolveSelections());
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* For each previously-visible Run that now has siblings but no explicit
|
|
1148
|
+
* selection, pin the selection to that Run's runId. This preserves the
|
|
1149
|
+
* current branch when new forks appear from other views or external
|
|
1150
|
+
* sources.
|
|
1151
|
+
*
|
|
1152
|
+
* Exception: if the fork was initiated by this view (tracked as a
|
|
1153
|
+
* `pending` BranchSelection), select the newest sibling (the awaited Run)
|
|
1154
|
+
* instead of pinning the old one.
|
|
1155
|
+
*/
|
|
1156
|
+
private _pinBranchSelections(): void {
|
|
1157
|
+
for (const key of this._lastVisibleNodeKeys) {
|
|
1158
|
+
const node = this._tree.getNode(key);
|
|
1159
|
+
// Edit forks are INPUT-node sibling groups; only input nodes pin here.
|
|
1160
|
+
// Regenerate (reply-run) groups roll forward via _resolvePendingRegenSelections.
|
|
1161
|
+
if (node?.kind !== 'input') continue;
|
|
1162
|
+
const siblings = this._tree.getSiblingNodes(key);
|
|
1163
|
+
if (siblings.length <= 1) continue;
|
|
1164
|
+
const groupRoot = this._tree.getGroupRoot(key);
|
|
1165
|
+
const existing = this._branchSelections.get(groupRoot);
|
|
1166
|
+
|
|
1167
|
+
// Spec: AIT-CT13f — external edit fork: pin to the currently-visible
|
|
1168
|
+
// sibling so a fork from another view doesn't drift this view's branch.
|
|
1169
|
+
if (existing) continue;
|
|
1170
|
+
this._branchSelections.set(groupRoot, { kind: 'pinned', selectedKey: key });
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Roll `pending` and `auto` regenerate selections forward to the newest
|
|
1176
|
+
* group member. A regenerate slot defaults to the latest member, so each
|
|
1177
|
+
* new regenerator (this view's awaited run, or an external one) auto-rolls
|
|
1178
|
+
* the slot forward — UNLESS the user explicitly selected an earlier member
|
|
1179
|
+
* (`user`), which pins and is left untouched. The agent mints the run-id, so
|
|
1180
|
+
* we can't match the awaited run by id — once the group grows we adopt the
|
|
1181
|
+
* newest as the selected member.
|
|
1182
|
+
*/
|
|
1183
|
+
private _resolvePendingRegenSelections(): void {
|
|
1184
|
+
for (const [groupRoot, sel] of this._regenSelections) {
|
|
1185
|
+
if (sel.kind === 'user') continue;
|
|
1186
|
+
const group = this._tree.getSiblingNodes(groupRoot).filter((n): n is RunNode<TProjection> => n.kind === 'run');
|
|
1187
|
+
if (group.length <= 1) continue;
|
|
1188
|
+
const newest = group.at(-1);
|
|
1189
|
+
if (!newest) continue;
|
|
1190
|
+
this._regenSelections.set(groupRoot, { kind: 'auto', selectedRunId: newest.runId });
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
private _onTreeAblyMessage(msg: Ably.InboundMessage): void {
|
|
1195
|
+
// Re-emit only if the message corresponds to a visible Run
|
|
1196
|
+
const headers = getTransportHeaders(msg);
|
|
1197
|
+
const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
|
|
1198
|
+
const runId = headers[HEADER_RUN_ID];
|
|
1199
|
+
|
|
1200
|
+
if (!codecMessageId && !runId) {
|
|
1201
|
+
// Lifecycle / control events with no run/message identity (cancel, error)
|
|
1202
|
+
// are always forwarded.
|
|
1203
|
+
this._emitter.emit('ably-message', msg);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (runId && this._lastVisibleNodeKeySet.has(runId)) {
|
|
1208
|
+
this._emitter.emit('ably-message', msg);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
private _onTreeRun(event: RunLifecycleEvent): void {
|
|
1213
|
+
// Check if the run is already on the visible branch.
|
|
1214
|
+
if (this._lastVisibleNodeKeySet.has(event.runId)) {
|
|
1215
|
+
this._emitter.emit('run', event);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// For run-start, use branch metadata to predict visibility before
|
|
1220
|
+
// messages arrive. Own runs have optimistic inserts (caught above).
|
|
1221
|
+
// Remote runs carry parent/forkOf from the agent.
|
|
1222
|
+
if (event.type === 'start' && this._isRunStartVisible(event)) {
|
|
1223
|
+
this._lastVisibleNodeKeySet.add(event.runId);
|
|
1224
|
+
this._emitter.emit('run', event);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Predict whether a run-start's messages will be visible on this view's
|
|
1230
|
+
* branch using the parent/forkOf metadata from the event.
|
|
1231
|
+
* @param event - The run-start lifecycle event.
|
|
1232
|
+
* @returns True if the run is expected to be visible on this view's branch.
|
|
1233
|
+
*/
|
|
1234
|
+
private _isRunStartVisible(event: RunLifecycleEvent & { type: 'start' }): boolean {
|
|
1235
|
+
const { parent } = event;
|
|
1236
|
+
|
|
1237
|
+
// No parent metadata — can't determine branch, forward as default.
|
|
1238
|
+
if (parent === undefined) return true;
|
|
1239
|
+
|
|
1240
|
+
// The wire `parent` is a codec-message-id (the prior message). Resolve it
|
|
1241
|
+
// kind-blind to its owning NODE — an input node (the user prompt this run
|
|
1242
|
+
// replies to) or a prior reply run — and check that node's key against the
|
|
1243
|
+
// visible set. Input-node keys are populated into the set by
|
|
1244
|
+
// _updateVisibleSnapshot.
|
|
1245
|
+
const parentNode = this._tree.getNodeByCodecMessageId(parent);
|
|
1246
|
+
if (!parentNode) return true; // unknown parent: forward conservatively
|
|
1247
|
+
return this._lastVisibleNodeKeySet.has(nodeKey(parentNode));
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
private _visibleChanged(newNodes: ConversationNode<TProjection>[]): boolean {
|
|
1251
|
+
if (newNodes.length !== this._lastVisibleNodeKeys.length) return true;
|
|
1252
|
+
for (const [i, node] of newNodes.entries()) {
|
|
1253
|
+
if (nodeKey(node) !== this._lastVisibleNodeKeys[i]) return true;
|
|
1254
|
+
if (node.projection !== this._lastVisibleProjections[i]) return true;
|
|
1255
|
+
}
|
|
1256
|
+
return false;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// ---------------------------------------------------------------------------
|
|
1261
|
+
// Factory
|
|
1262
|
+
// ---------------------------------------------------------------------------
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Create a View that projects a paginated window over a Tree.
|
|
1266
|
+
* @param options - The tree, channel, codec, and logger to use.
|
|
1267
|
+
* @returns A new {@link DefaultView} instance.
|
|
1268
|
+
*/
|
|
1269
|
+
export const createView = <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection, TMessage>(
|
|
1270
|
+
options: ViewOptions<TInput, TOutput, TProjection, TMessage>,
|
|
1271
|
+
): DefaultView<TInput, TOutput, TProjection, TMessage> => new DefaultView(options);
|