@ably/ai-transport 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -100
- package/dist/ably-ai-transport.js +1553 -1238
- 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 +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 +407 -115
- 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 +96 -18
- 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-conversation.d.ts +128 -0
- package/dist/core/transport/load-history.d.ts +39 -0
- package/dist/core/transport/pipe-stream.d.ts +9 -9
- package/dist/core/transport/run-manager.d.ts +78 -0
- package/dist/core/transport/tree.d.ts +373 -109
- 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 -553
- package/dist/core/transport/view.d.ts +272 -84
- package/dist/errors.d.ts +21 -10
- package/dist/index.d.ts +6 -8
- package/dist/logger.d.ts +12 -0
- package/dist/react/ably-ai-transport-react.js +976 -990
- 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 +12 -12
- 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 +82 -51
- package/dist/utils.d.ts +32 -23
- package/dist/vercel/ably-ai-transport-vercel.js +2573 -2086
- 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 +2 -2
- package/dist/vercel/index.d.ts +4 -5
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +3907 -3266
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +33 -8
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +7 -6
- 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 +29 -0
- package/dist/vercel/transport/chat-transport.d.ts +43 -24
- package/dist/vercel/transport/index.d.ts +25 -21
- package/dist/vercel/transport/run-output-stream.d.ts +56 -0
- package/dist/version.d.ts +2 -0
- package/package.json +30 -23
- package/src/constants.ts +124 -51
- package/src/core/agent.ts +68 -0
- package/src/core/codec/decoder.ts +71 -98
- package/src/core/codec/encoder.ts +113 -65
- package/src/core/codec/index.ts +13 -6
- package/src/core/codec/lifecycle-tracker.ts +10 -9
- package/src/core/codec/types.ts +436 -120
- 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 +181 -22
- package/src/core/transport/index.ts +25 -26
- 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 +54 -39
- package/src/core/transport/run-manager.ts +249 -0
- package/src/core/transport/tree.ts +926 -308
- 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 -706
- package/src/core/transport/view.ts +864 -433
- package/src/errors.ts +22 -9
- package/src/event-emitter.ts +3 -2
- package/src/index.ts +52 -41
- 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 +23 -13
- 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 +201 -0
- package/src/react/use-create-view.ts +33 -29
- package/src/react/use-tree.ts +61 -30
- package/src/react/use-view.ts +139 -97
- package/src/utils.ts +63 -45
- package/src/vercel/codec/decoder.ts +336 -258
- package/src/vercel/codec/encoder.ts +343 -205
- package/src/vercel/codec/events.ts +87 -0
- package/src/vercel/codec/index.ts +60 -13
- package/src/vercel/codec/reducer.ts +977 -0
- package/src/vercel/codec/tool-transitions.ts +2 -2
- package/src/vercel/index.ts +6 -19
- package/src/vercel/react/contexts/chat-transport-context.ts +7 -6
- 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 +47 -49
- package/src/vercel/react/use-message-sync.ts +80 -39
- package/src/vercel/run-end-reason.ts +78 -0
- package/src/vercel/transport/chat-transport.ts +392 -98
- package/src/vercel/transport/index.ts +39 -38
- 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/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/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/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
|
@@ -1,35 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DefaultView — a paginated, branch-aware projection over the Tree.
|
|
3
3
|
*
|
|
4
|
-
* Wraps a Tree and manages a pagination window that controls
|
|
5
|
-
* are visible to the UI. New live
|
|
6
|
-
* are revealed progressively via `loadOlder()`.
|
|
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.
|
|
7
12
|
*
|
|
8
13
|
* Each View owns its own branch selection state and pagination window,
|
|
9
14
|
* allowing multiple independent Views over the same Tree.
|
|
10
15
|
*
|
|
11
16
|
* Events are scoped to the visible window — 'update' only fires when the
|
|
12
17
|
* visible output changes, 'ably-message' only for messages corresponding to
|
|
13
|
-
* visible
|
|
18
|
+
* visible Runs, and 'run' only for runs with visible content.
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
21
|
import * as Ably from 'ably';
|
|
17
22
|
|
|
18
|
-
import {
|
|
23
|
+
import { HEADER_CODEC_MESSAGE_ID, HEADER_RUN_ID } from '../../constants.js';
|
|
19
24
|
import { ErrorCode } from '../../errors.js';
|
|
20
25
|
import { EventEmitter } from '../../event-emitter.js';
|
|
21
26
|
import type { Logger } from '../../logger.js';
|
|
22
|
-
import {
|
|
23
|
-
import type { Codec } from '../codec/types.js';
|
|
24
|
-
import {
|
|
25
|
-
import
|
|
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';
|
|
26
32
|
import type {
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
ActiveRun,
|
|
34
|
+
BranchSelection,
|
|
35
|
+
ConversationNode,
|
|
29
36
|
HistoryPage,
|
|
30
|
-
|
|
37
|
+
OutputEvent,
|
|
38
|
+
RunInfo,
|
|
39
|
+
RunLifecycleEvent,
|
|
40
|
+
RunNode,
|
|
31
41
|
SendOptions,
|
|
32
|
-
TurnLifecycleEvent,
|
|
33
42
|
View,
|
|
34
43
|
} from './types.js';
|
|
35
44
|
|
|
@@ -40,7 +49,7 @@ import type {
|
|
|
40
49
|
interface ViewEventsMap {
|
|
41
50
|
update: undefined;
|
|
42
51
|
'ably-message': Ably.InboundMessage;
|
|
43
|
-
|
|
52
|
+
run: RunLifecycleEvent;
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
// ---------------------------------------------------------------------------
|
|
@@ -48,33 +57,40 @@ interface ViewEventsMap {
|
|
|
48
57
|
// ---------------------------------------------------------------------------
|
|
49
58
|
|
|
50
59
|
/**
|
|
51
|
-
* Internal delegate function provided by the
|
|
52
|
-
* The View pre-computes the visible branch
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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.
|
|
56
73
|
*/
|
|
57
|
-
export type SendDelegate<
|
|
58
|
-
input:
|
|
74
|
+
export type SendDelegate<TInput extends CodecInputEvent> = (
|
|
75
|
+
input: TInput[],
|
|
59
76
|
options: SendOptions | undefined,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
) => Promise<ActiveTurn<TEvent>>;
|
|
77
|
+
parentCodecMessageId: string | undefined,
|
|
78
|
+
) => Promise<ActiveRun>;
|
|
63
79
|
|
|
64
80
|
// ---------------------------------------------------------------------------
|
|
65
81
|
// Options
|
|
66
82
|
// ---------------------------------------------------------------------------
|
|
67
83
|
|
|
68
84
|
/** Options for creating a View. */
|
|
69
|
-
export interface ViewOptions<
|
|
85
|
+
export interface ViewOptions<TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection, TMessage> {
|
|
70
86
|
/** The tree to project. */
|
|
71
|
-
tree: TreeInternal<
|
|
87
|
+
tree: TreeInternal<TInput, TOutput, TProjection>;
|
|
72
88
|
/** The Ably channel to load history from. */
|
|
73
89
|
channel: Ably.RealtimeChannel;
|
|
74
|
-
/** The codec
|
|
75
|
-
codec: Codec<
|
|
76
|
-
/** Delegate for executing sends through the
|
|
77
|
-
sendDelegate: SendDelegate<
|
|
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>;
|
|
78
94
|
/** Logger for diagnostic output. */
|
|
79
95
|
logger: Logger;
|
|
80
96
|
/** Called when the view is closed, allowing the owner to clean up references. */
|
|
@@ -86,80 +102,178 @@ export interface ViewOptions<TEvent, TMessage> {
|
|
|
86
102
|
// ---------------------------------------------------------------------------
|
|
87
103
|
|
|
88
104
|
/**
|
|
89
|
-
*
|
|
90
|
-
* Stored per group
|
|
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)`.
|
|
91
109
|
*/
|
|
92
|
-
type
|
|
93
|
-
/** Explicit navigation via `
|
|
94
|
-
| { kind: 'user';
|
|
95
|
-
/** This view initiated
|
|
96
|
-
| { kind: 'auto';
|
|
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 }
|
|
97
115
|
/** An external fork appeared — pinned to the currently-visible sibling to prevent drift. */
|
|
98
|
-
| { kind: 'pinned';
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
});
|
|
101
194
|
|
|
102
195
|
// ---------------------------------------------------------------------------
|
|
103
196
|
// Implementation
|
|
104
197
|
// ---------------------------------------------------------------------------
|
|
105
198
|
|
|
106
|
-
export class DefaultView<
|
|
107
|
-
|
|
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>;
|
|
108
206
|
private readonly _channel: Ably.RealtimeChannel;
|
|
109
|
-
private readonly _codec: Codec<
|
|
110
|
-
private readonly _sendDelegate: SendDelegate<
|
|
207
|
+
private readonly _codec: Codec<TInput, TOutput, TProjection, TMessage>;
|
|
208
|
+
private readonly _sendDelegate: SendDelegate<TInput>;
|
|
111
209
|
private readonly _logger: Logger;
|
|
112
210
|
private readonly _emitter: EventEmitter<ViewEventsMap>;
|
|
113
211
|
private readonly _onClose?: () => void;
|
|
114
212
|
|
|
115
213
|
/**
|
|
116
|
-
* View-local branch selections: group
|
|
214
|
+
* View-local branch selections: group-root runId → selection intent.
|
|
117
215
|
* Fork points not present here default to the latest sibling.
|
|
118
|
-
* Replaces the previous numeric-index _selections and _pendingForkSelections
|
|
119
|
-
* with a single tagged-union map that carries the selected msgId (not index)
|
|
120
|
-
* and the reason for the selection.
|
|
121
216
|
*/
|
|
122
|
-
private readonly _branchSelections = new Map<string,
|
|
217
|
+
private readonly _branchSelections = new Map<string, BranchSelectionState>();
|
|
123
218
|
|
|
124
|
-
/**
|
|
125
|
-
|
|
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>();
|
|
126
229
|
|
|
127
|
-
/**
|
|
128
|
-
private
|
|
230
|
+
/** Spec: AIT-CT11c — runIds loaded from history but not yet revealed to the UI. */
|
|
231
|
+
private readonly _withheldRunIds = new Set<string>();
|
|
129
232
|
|
|
130
|
-
/** Snapshot of visible
|
|
131
|
-
private
|
|
233
|
+
/** Snapshot of visible node keys — used to detect structural changes and for selection pinning. */
|
|
234
|
+
private _lastVisibleNodeKeys: string[] = [];
|
|
132
235
|
|
|
133
|
-
/**
|
|
134
|
-
|
|
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>();
|
|
135
251
|
|
|
136
252
|
/** Whether there are more history pages to fetch from the channel. */
|
|
137
253
|
private _hasMoreHistory = false;
|
|
138
254
|
|
|
139
255
|
/** Internal state for continuing history pagination. */
|
|
140
|
-
private _lastHistoryPage: HistoryPage
|
|
256
|
+
private _lastHistoryPage: HistoryPage | undefined;
|
|
141
257
|
|
|
142
|
-
/** Buffer of withheld nodes, drained newest-first by successive loadOlder() calls. */
|
|
143
|
-
private readonly _withheldBuffer:
|
|
258
|
+
/** Buffer of withheld nodes (input + reply), drained newest-first by successive loadOlder() calls. */
|
|
259
|
+
private readonly _withheldBuffer: ConversationNode<TProjection>[] = [];
|
|
144
260
|
|
|
145
261
|
/** Unsubscribe functions for tree event subscriptions. */
|
|
146
262
|
private readonly _unsubs: (() => void)[] = [];
|
|
147
263
|
|
|
148
264
|
/**
|
|
149
|
-
* Cached result of the last
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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.
|
|
152
269
|
*/
|
|
153
|
-
private _cachedNodes:
|
|
154
|
-
|
|
155
|
-
/** Last seen tree structural version - used to distinguish content-only from structural updates. */
|
|
156
|
-
private _lastStructuralVersion = -1;
|
|
270
|
+
private _cachedNodes: ConversationNode<TProjection>[] = [];
|
|
157
271
|
|
|
158
272
|
private _loadingOlder = false;
|
|
159
273
|
private _processingHistory = false;
|
|
160
274
|
private _closed = false;
|
|
161
275
|
|
|
162
|
-
constructor(options: ViewOptions<
|
|
276
|
+
constructor(options: ViewOptions<TInput, TOutput, TProjection, TMessage>) {
|
|
163
277
|
this._tree = options.tree;
|
|
164
278
|
this._channel = options.channel;
|
|
165
279
|
this._codec = options.codec;
|
|
@@ -171,7 +285,6 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
171
285
|
|
|
172
286
|
// Compute initial cache and snapshot visible state
|
|
173
287
|
this._cachedNodes = this._computeFlatNodes();
|
|
174
|
-
this._lastStructuralVersion = this._tree.structuralVersion;
|
|
175
288
|
this._updateVisibleSnapshot(this._cachedNodes);
|
|
176
289
|
|
|
177
290
|
// Subscribe to tree events and re-emit scoped versions
|
|
@@ -182,79 +295,193 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
182
295
|
this._tree.on('ably-message', (msg) => {
|
|
183
296
|
this._onTreeAblyMessage(msg);
|
|
184
297
|
}),
|
|
185
|
-
this._tree.on('
|
|
186
|
-
this.
|
|
298
|
+
this._tree.on('run', (event) => {
|
|
299
|
+
this._onTreeRun(event);
|
|
300
|
+
}),
|
|
301
|
+
this._tree.on('output', (event) => {
|
|
302
|
+
this._onTreeOutput(event);
|
|
187
303
|
}),
|
|
188
304
|
);
|
|
189
305
|
}
|
|
190
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
|
+
|
|
191
337
|
// -------------------------------------------------------------------------
|
|
192
338
|
// Public query methods
|
|
193
339
|
// -------------------------------------------------------------------------
|
|
194
340
|
|
|
195
|
-
getMessages(): TMessage[] {
|
|
196
|
-
return this.
|
|
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
|
+
}
|
|
197
391
|
}
|
|
198
392
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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;
|
|
202
404
|
}
|
|
203
405
|
|
|
204
406
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
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.
|
|
210
421
|
*/
|
|
211
|
-
private
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
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;
|
|
215
430
|
}
|
|
216
431
|
|
|
217
432
|
hasOlder(): boolean {
|
|
218
433
|
return this._withheldBuffer.length > 0 || this._hasMoreHistory;
|
|
219
434
|
}
|
|
220
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
|
+
*/
|
|
221
448
|
async loadOlder(limit = 100): Promise<void> {
|
|
222
449
|
if (this._closed || this._loadingOlder) return;
|
|
223
450
|
this._loadingOlder = true;
|
|
224
451
|
this._logger.trace('DefaultView.loadOlder();', { limit });
|
|
225
452
|
|
|
226
453
|
try {
|
|
227
|
-
// Drain withheld buffer first (older
|
|
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.
|
|
228
458
|
if (this._withheldBuffer.length > 0) {
|
|
229
459
|
const batch = this._withheldBuffer.splice(-limit, limit);
|
|
230
460
|
this._releaseWithheld(batch);
|
|
231
461
|
return;
|
|
232
462
|
}
|
|
233
463
|
|
|
234
|
-
// Buffer exhausted
|
|
464
|
+
// Buffer exhausted - load from channel history.
|
|
235
465
|
if (!this._hasMoreHistory && !this._lastHistoryPage) {
|
|
236
|
-
// First load
|
|
237
466
|
await this._loadFirstPage(limit);
|
|
238
467
|
return;
|
|
239
468
|
}
|
|
240
469
|
|
|
241
470
|
if (!this._hasMoreHistory) return;
|
|
242
471
|
|
|
243
|
-
// Continue from last page
|
|
244
472
|
if (!this._lastHistoryPage?.hasNext()) {
|
|
245
473
|
this._hasMoreHistory = false;
|
|
246
474
|
return;
|
|
247
475
|
}
|
|
248
476
|
|
|
249
477
|
const nextPage = await this._lastHistoryPage.next();
|
|
250
|
-
// Re-check: close() may be called during the await from another call stack
|
|
251
478
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be called during await
|
|
252
479
|
if (this._closed || !nextPage) {
|
|
253
480
|
if (!nextPage) this._hasMoreHistory = false;
|
|
254
481
|
return;
|
|
255
482
|
}
|
|
256
483
|
|
|
257
|
-
await this.
|
|
484
|
+
await this._revealFromPage(nextPage, limit);
|
|
258
485
|
} catch (error) {
|
|
259
486
|
this._logger.error('DefaultView.loadOlder(); failed', { error });
|
|
260
487
|
throw error;
|
|
@@ -264,47 +491,201 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
264
491
|
}
|
|
265
492
|
|
|
266
493
|
// -------------------------------------------------------------------------
|
|
267
|
-
//
|
|
494
|
+
// Run lookup
|
|
268
495
|
// -------------------------------------------------------------------------
|
|
269
496
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
this.
|
|
273
|
-
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
if (!selected) return; // unreachable: clamped is always in bounds
|
|
279
|
-
this._branchSelections.set(groupRootId, { kind: 'user', selectedId: selected.msgId });
|
|
280
|
-
this._logger.debug('DefaultView.select();', { msgId, index: clamped, selectedId: selected.msgId });
|
|
281
|
-
this._cachedNodes = this._computeFlatNodes();
|
|
282
|
-
this._updateVisibleSnapshot(this._cachedNodes);
|
|
283
|
-
this._emitter.emit('update');
|
|
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;
|
|
284
505
|
}
|
|
285
506
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
return
|
|
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);
|
|
296
529
|
}
|
|
297
530
|
|
|
298
|
-
|
|
299
|
-
|
|
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;
|
|
300
535
|
}
|
|
301
536
|
|
|
302
|
-
|
|
303
|
-
|
|
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();
|
|
304
618
|
}
|
|
305
619
|
|
|
306
|
-
|
|
307
|
-
|
|
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;
|
|
308
689
|
}
|
|
309
690
|
|
|
310
691
|
// -------------------------------------------------------------------------
|
|
@@ -312,159 +693,217 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
312
693
|
// -------------------------------------------------------------------------
|
|
313
694
|
|
|
314
695
|
// Spec: AIT-CT3, AIT-CT4
|
|
315
|
-
async send(input:
|
|
696
|
+
async send(input: TInput | TInput[], options?: SendOptions): Promise<ActiveRun> {
|
|
316
697
|
this._logger.trace('DefaultView.send();');
|
|
317
698
|
if (this._closed) {
|
|
318
699
|
throw new Ably.ErrorInfo('unable to send; view is closed', ErrorCode.InvalidArgument, 400);
|
|
319
700
|
}
|
|
320
701
|
|
|
321
|
-
|
|
322
|
-
// transport has no back-reference to the View (one-way dependency).
|
|
323
|
-
const history = this.flattenNodes();
|
|
324
|
-
const result = await this._sendDelegate(input, options, history);
|
|
702
|
+
const normalised = _normaliseSend<TInput>(input);
|
|
325
703
|
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
const groupRoot = this._tree.getGroupRoot(options.forkOf);
|
|
330
|
-
|
|
331
|
-
if (result.optimisticMsgIds.length > 0) {
|
|
332
|
-
// The delegate optimistically inserted user messages (edit path).
|
|
333
|
-
// Auto-select the last optimistic msgId — this is deterministic and
|
|
334
|
-
// avoids the sibling-count race that exists when inferring from tree state.
|
|
335
|
-
const lastMsgId = result.optimisticMsgIds.at(-1);
|
|
336
|
-
if (lastMsgId) {
|
|
337
|
-
this._branchSelections.set(groupRoot, { kind: 'auto', selectedId: lastMsgId });
|
|
338
|
-
this._cachedNodes = this._computeFlatNodes();
|
|
339
|
-
this._updateVisibleSnapshot(this._cachedNodes);
|
|
340
|
-
this._emitter.emit('update');
|
|
341
|
-
}
|
|
342
|
-
} else {
|
|
343
|
-
// No optimistic insert (e.g. regenerate sends no user messages). Defer
|
|
344
|
-
// auto-selection until the server response creates the new sibling.
|
|
345
|
-
// Store the group root (not the raw forkOf) so _pinBranchSelections
|
|
346
|
-
// can match it regardless of which sibling is currently visible.
|
|
347
|
-
this._branchSelections.set(groupRoot, { kind: 'pending', turnId: result.turnId });
|
|
348
|
-
this._logger.debug('DefaultView.send(); deferring fork auto-selection', {
|
|
349
|
-
forkOf: options.forkOf,
|
|
350
|
-
groupRoot,
|
|
351
|
-
turnId: result.turnId,
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
// Bound pending entry lifetime to the turn — clean up on turn-end.
|
|
355
|
-
const turnUnsub = this._tree.on('turn', (evt) => {
|
|
356
|
-
if (evt.type !== EVENT_TURN_END || evt.turnId !== result.turnId) return;
|
|
357
|
-
const sel = this._branchSelections.get(groupRoot);
|
|
358
|
-
if (sel?.kind === 'pending' && sel.turnId === result.turnId) {
|
|
359
|
-
this._branchSelections.delete(groupRoot);
|
|
360
|
-
}
|
|
361
|
-
turnUnsub();
|
|
362
|
-
const idx = this._unsubs.indexOf(turnUnsub);
|
|
363
|
-
if (idx !== -1) this._unsubs.splice(idx, 1);
|
|
364
|
-
});
|
|
365
|
-
this._unsubs.push(turnUnsub);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
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;
|
|
368
707
|
|
|
708
|
+
const result = await this._sendDelegate(normalised, options, parentCodecMessageId);
|
|
709
|
+
this._applyForkAutoSelect(result, options);
|
|
369
710
|
return result;
|
|
370
711
|
}
|
|
371
712
|
|
|
372
|
-
|
|
373
|
-
|
|
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> {
|
|
374
776
|
this._logger.trace('DefaultView.regenerate();', { messageId });
|
|
375
777
|
|
|
376
|
-
|
|
377
|
-
|
|
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) {
|
|
378
791
|
throw new Ably.ErrorInfo(
|
|
379
792
|
`unable to regenerate; message not found in tree: ${messageId}`,
|
|
380
793
|
ErrorCode.InvalidArgument,
|
|
381
794
|
400,
|
|
382
795
|
);
|
|
383
796
|
}
|
|
384
|
-
const
|
|
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
|
+
}
|
|
385
824
|
|
|
386
|
-
|
|
825
|
+
const sendOptions: SendOptions = {
|
|
387
826
|
...options,
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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;
|
|
395
840
|
}
|
|
396
841
|
|
|
397
842
|
// Spec: AIT-CT6
|
|
398
|
-
async edit(
|
|
399
|
-
messageId: string,
|
|
400
|
-
newMessages: TMessage | TMessage[],
|
|
401
|
-
options?: SendOptions,
|
|
402
|
-
): Promise<ActiveTurn<TEvent>> {
|
|
843
|
+
async edit(messageId: string, inputs: TInput | TInput[], options?: SendOptions): Promise<ActiveRun> {
|
|
403
844
|
this._logger.trace('DefaultView.edit();', { messageId });
|
|
404
845
|
|
|
405
|
-
|
|
406
|
-
|
|
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) {
|
|
407
854
|
throw new Ably.ErrorInfo(
|
|
408
855
|
`unable to edit; message not found in tree: ${messageId}`,
|
|
409
856
|
ErrorCode.InvalidArgument,
|
|
410
857
|
400,
|
|
411
858
|
);
|
|
412
859
|
}
|
|
413
|
-
const
|
|
860
|
+
const parentCodecMessageId = this._findParentMsgId(targetNode, messageId);
|
|
414
861
|
|
|
415
|
-
return this.send(
|
|
862
|
+
return this.send(inputs, {
|
|
416
863
|
...options,
|
|
417
|
-
body: {
|
|
418
|
-
history: this._getHistoryBefore(messageId),
|
|
419
|
-
...options?.body,
|
|
420
|
-
},
|
|
421
864
|
forkOf: messageId,
|
|
422
|
-
parent:
|
|
865
|
+
parent: parentCodecMessageId,
|
|
423
866
|
});
|
|
424
867
|
}
|
|
425
868
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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;
|
|
429
890
|
}
|
|
430
|
-
|
|
431
|
-
const eventNodes: EventsNode<TEvent>[] = [{ kind: 'event', msgId, events }];
|
|
432
|
-
return this._sendDelegate([], options, this.flattenNodes(), eventNodes);
|
|
433
|
-
}
|
|
891
|
+
if (visIdx === 0) return undefined;
|
|
434
892
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
if (idx === -1) {
|
|
440
|
-
this._logger.warn('DefaultView._getHistoryBefore(); target not in visible nodes, returning full list', {
|
|
441
|
-
messageId,
|
|
442
|
-
});
|
|
443
|
-
return all;
|
|
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;
|
|
444
897
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
// Spec: AIT-CT17
|
|
453
|
-
getActiveTurnIds(): Map<string, Set<string>> {
|
|
454
|
-
this._logger.trace('DefaultView.getActiveTurnIds();');
|
|
455
|
-
const allTurns = this._tree.getActiveTurnIds();
|
|
456
|
-
if (this._withheldMsgIds.size === 0) return allTurns;
|
|
457
|
-
|
|
458
|
-
// Filter to turns that have at least one visible message
|
|
459
|
-
const result = new Map<string, Set<string>>();
|
|
460
|
-
for (const [clientId, turnIds] of allTurns) {
|
|
461
|
-
const filtered = new Set<string>();
|
|
462
|
-
for (const turnId of turnIds) {
|
|
463
|
-
if (this._lastVisibleTurnIds.has(turnId)) filtered.add(turnId);
|
|
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;
|
|
464
904
|
}
|
|
465
|
-
if (filtered.size > 0) result.set(clientId, filtered);
|
|
466
905
|
}
|
|
467
|
-
return
|
|
906
|
+
return undefined;
|
|
468
907
|
}
|
|
469
908
|
|
|
470
909
|
// -------------------------------------------------------------------------
|
|
@@ -474,10 +913,10 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
474
913
|
// Spec: AIT-CT8a, AIT-CT8b, AIT-CT8e
|
|
475
914
|
on(event: 'update', handler: () => void): () => void;
|
|
476
915
|
on(event: 'ably-message', handler: (msg: Ably.InboundMessage) => void): () => void;
|
|
477
|
-
on(event: '
|
|
916
|
+
on(event: 'run', handler: (event: RunLifecycleEvent) => void): () => void;
|
|
478
917
|
on(
|
|
479
|
-
event: 'update' | 'ably-message' | '
|
|
480
|
-
handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event:
|
|
918
|
+
event: 'update' | 'ably-message' | 'run',
|
|
919
|
+
handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event: RunLifecycleEvent) => void),
|
|
481
920
|
): () => void {
|
|
482
921
|
// CAST: overload signatures enforce correct handler types per event name.
|
|
483
922
|
const cb = handler as (arg: ViewEventsMap[keyof ViewEventsMap]) => void;
|
|
@@ -491,10 +930,8 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
491
930
|
// Lifecycle
|
|
492
931
|
// -------------------------------------------------------------------------
|
|
493
932
|
|
|
494
|
-
/**
|
|
495
|
-
* Tear down the view — unsubscribe from tree events.
|
|
496
|
-
*/
|
|
497
933
|
close(): void {
|
|
934
|
+
if (this._closed) return;
|
|
498
935
|
this._logger.info('DefaultView.close();');
|
|
499
936
|
this._closed = true;
|
|
500
937
|
this._loadingOlder = false;
|
|
@@ -502,7 +939,8 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
502
939
|
this._unsubs.length = 0;
|
|
503
940
|
this._emitter.off();
|
|
504
941
|
this._branchSelections.clear();
|
|
505
|
-
this.
|
|
942
|
+
this._regenSelections.clear();
|
|
943
|
+
this._withheldRunIds.clear();
|
|
506
944
|
this._withheldBuffer.length = 0;
|
|
507
945
|
this._onClose?.();
|
|
508
946
|
}
|
|
@@ -512,62 +950,84 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
512
950
|
// -------------------------------------------------------------------------
|
|
513
951
|
|
|
514
952
|
private async _loadFirstPage(limit: number): Promise<void> {
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
const firstPage = await
|
|
519
|
-
if (this._closed) return;
|
|
520
|
-
const { newVisible, lastPage } = await this._loadUntilVisible(firstPage, limit, beforeMsgIds);
|
|
521
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be called during await
|
|
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);
|
|
522
957
|
if (this._closed) return;
|
|
523
|
-
|
|
524
|
-
this._lastHistoryPage = lastPage;
|
|
525
|
-
this._hasMoreHistory = lastPage.hasNext();
|
|
526
|
-
|
|
527
|
-
// Split into withheld (older, kept hidden) and released (newest, shown now).
|
|
528
|
-
// Only add the actually-withheld messages to the set — adding all then
|
|
529
|
-
// releasing would cause a spurious empty-list update if a tree event fires
|
|
530
|
-
// between the two operations.
|
|
531
|
-
const released = newVisible.slice(-limit);
|
|
532
|
-
const withheld = newVisible.slice(0, -limit);
|
|
533
|
-
for (const n of withheld) {
|
|
534
|
-
this._withheldMsgIds.add(n.msgId);
|
|
535
|
-
}
|
|
536
|
-
this._withheldBuffer.push(...withheld);
|
|
537
|
-
this._releaseWithheld(released);
|
|
958
|
+
await this._revealFromPage(firstPage, limit);
|
|
538
959
|
}
|
|
539
960
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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)));
|
|
543
973
|
|
|
544
|
-
const { newVisible, lastPage } = await this._loadUntilVisible(page, limit,
|
|
974
|
+
const { newVisible, lastPage } = await this._loadUntilVisible(page, limit, beforeRunIds);
|
|
545
975
|
if (this._closed) return;
|
|
546
976
|
this._lastHistoryPage = lastPage;
|
|
547
977
|
this._hasMoreHistory = lastPage.hasNext();
|
|
978
|
+
this._splitReveal(newVisible, limit);
|
|
979
|
+
}
|
|
548
980
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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);
|
|
555
1005
|
for (const n of withheld) {
|
|
556
|
-
this.
|
|
1006
|
+
this._withheldRunIds.add(nodeKey(n));
|
|
557
1007
|
}
|
|
558
1008
|
this._withheldBuffer.push(...withheld);
|
|
559
1009
|
this._releaseWithheld(batch);
|
|
560
1010
|
}
|
|
561
1011
|
|
|
562
|
-
|
|
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 {
|
|
563
1020
|
this._processingHistory = true;
|
|
564
1021
|
try {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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);
|
|
569
1027
|
}
|
|
570
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.
|
|
571
1031
|
for (const msg of page.rawMessages) {
|
|
572
1032
|
this._tree.emitAblyMessage(msg);
|
|
573
1033
|
}
|
|
@@ -577,17 +1037,19 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
577
1037
|
}
|
|
578
1038
|
|
|
579
1039
|
private async _loadUntilVisible(
|
|
580
|
-
firstPage: HistoryPage
|
|
1040
|
+
firstPage: HistoryPage,
|
|
581
1041
|
target: number,
|
|
582
|
-
|
|
583
|
-
): Promise<{ newVisible:
|
|
1042
|
+
beforeRunIds: Set<string>,
|
|
1043
|
+
): Promise<{ newVisible: ConversationNode<TProjection>[]; lastPage: HistoryPage }> {
|
|
584
1044
|
this._processHistoryPage(firstPage);
|
|
585
1045
|
let page = firstPage;
|
|
586
1046
|
|
|
587
1047
|
const newVisibleCount = (): number => {
|
|
588
1048
|
let count = 0;
|
|
589
|
-
for (const n of this.
|
|
590
|
-
|
|
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++;
|
|
591
1053
|
}
|
|
592
1054
|
return count;
|
|
593
1055
|
};
|
|
@@ -599,19 +1061,17 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
599
1061
|
page = nextPage;
|
|
600
1062
|
}
|
|
601
1063
|
|
|
602
|
-
const newVisible = this.
|
|
1064
|
+
const newVisible = this._treeVisibleNodes().filter((n) => !beforeRunIds.has(nodeKey(n)));
|
|
603
1065
|
return { newVisible, lastPage: page };
|
|
604
1066
|
}
|
|
605
1067
|
|
|
606
1068
|
// Spec: AIT-CT11a
|
|
607
|
-
private _releaseWithheld(nodes:
|
|
1069
|
+
private _releaseWithheld(nodes: ConversationNode<TProjection>[]): void {
|
|
608
1070
|
for (const n of nodes) {
|
|
609
|
-
this.
|
|
1071
|
+
this._withheldRunIds.delete(nodeKey(n));
|
|
610
1072
|
}
|
|
611
1073
|
if (nodes.length > 0) {
|
|
612
|
-
this.
|
|
613
|
-
this._updateVisibleSnapshot(this._cachedNodes);
|
|
614
|
-
this._emitter.emit('update');
|
|
1074
|
+
this._recomputeAndEmit();
|
|
615
1075
|
}
|
|
616
1076
|
}
|
|
617
1077
|
|
|
@@ -619,209 +1079,179 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
619
1079
|
// Private: scoped event forwarding
|
|
620
1080
|
// -------------------------------------------------------------------------
|
|
621
1081
|
|
|
622
|
-
private _updateVisibleSnapshot(nodes?:
|
|
623
|
-
const resolved = nodes ?? this.
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
this.
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
}
|
|
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);
|
|
631
1090
|
}
|
|
632
1091
|
|
|
633
1092
|
private _onTreeUpdate(): void {
|
|
634
1093
|
// Suppress update forwarding while processing history pages. During
|
|
635
|
-
// _processHistoryPage, each tree.
|
|
636
|
-
// — but
|
|
637
|
-
// return unfiltered history. Without this guard,
|
|
638
|
-
// history
|
|
639
|
-
// is emitted by _releaseWithheld after
|
|
640
|
-
//
|
|
641
|
-
// updates arriving during the async history fetch are still forwarded.
|
|
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.
|
|
642
1100
|
if (this._processingHistory) return;
|
|
643
1101
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
// still structurally valid. The tree mutated an existing node's
|
|
649
|
-
// .message in place - check if any visible message reference changed.
|
|
650
|
-
// JS single-threaded: structuralVersion cannot change between the
|
|
651
|
-
// check and the response within this synchronous handler invocation.
|
|
652
|
-
if (currentVersion === this._lastStructuralVersion) {
|
|
653
|
-
const changed = this._cachedNodes.some((node, i) => node.message !== this._lastVisibleMessages[i]);
|
|
654
|
-
if (changed) {
|
|
655
|
-
this._lastVisibleMessages = this._cachedNodes.map((n) => n.message);
|
|
656
|
-
this._cachedNodes = [...this._cachedNodes];
|
|
657
|
-
this._emitter.emit('update');
|
|
658
|
-
}
|
|
659
|
-
return;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// Structural update: full re-walk required.
|
|
663
|
-
this._lastStructuralVersion = currentVersion;
|
|
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.
|
|
664
1106
|
|
|
665
|
-
// Pin selections for previously-visible
|
|
1107
|
+
// Pin selections for previously-visible Runs that now have siblings.
|
|
666
1108
|
// This prevents new forks (from other views' edits/regenerates) from
|
|
667
1109
|
// shifting this view to a branch the user didn't navigate to.
|
|
668
1110
|
this._pinBranchSelections();
|
|
669
|
-
this.
|
|
1111
|
+
this._resolvePendingRegenSelections();
|
|
670
1112
|
|
|
671
|
-
|
|
672
|
-
const newIds = nodes.map((n) => n.msgId);
|
|
673
|
-
const newMessages = nodes.map((n) => n.message);
|
|
674
|
-
if (this._visibleChanged(newIds, newMessages)) {
|
|
675
|
-
this._cachedNodes = nodes;
|
|
676
|
-
this._updateVisibleSnapshot(nodes);
|
|
677
|
-
this._emitter.emit('update');
|
|
678
|
-
}
|
|
1113
|
+
this._recomputeAndEmitIfChanged();
|
|
679
1114
|
}
|
|
680
1115
|
|
|
681
1116
|
/**
|
|
682
|
-
* Build
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
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.
|
|
686
1123
|
*/
|
|
687
1124
|
private _resolveSelections(): Map<string, string> {
|
|
688
1125
|
const resolved = new Map<string, string>();
|
|
689
1126
|
for (const [groupRoot, sel] of this._branchSelections) {
|
|
1127
|
+
resolved.set(groupRoot, sel.selectedKey);
|
|
1128
|
+
}
|
|
1129
|
+
for (const [groupRoot, sel] of this._regenSelections) {
|
|
690
1130
|
if (sel.kind === 'pending') continue;
|
|
691
|
-
resolved.set(groupRoot, sel.
|
|
1131
|
+
resolved.set(groupRoot, sel.selectedRunId);
|
|
692
1132
|
}
|
|
693
1133
|
return resolved;
|
|
694
1134
|
}
|
|
695
1135
|
|
|
696
1136
|
/**
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
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.
|
|
701
1151
|
*
|
|
702
1152
|
* Exception: if the fork was initiated by this view (tracked as a
|
|
703
|
-
* `pending` BranchSelection), select the newest sibling
|
|
704
|
-
* pinning the old one.
|
|
705
|
-
* insert was possible at send time.
|
|
1153
|
+
* `pending` BranchSelection), select the newest sibling (the awaited Run)
|
|
1154
|
+
* instead of pinning the old one.
|
|
706
1155
|
*/
|
|
707
1156
|
private _pinBranchSelections(): void {
|
|
708
|
-
for (const
|
|
709
|
-
|
|
710
|
-
|
|
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);
|
|
711
1165
|
const existing = this._branchSelections.get(groupRoot);
|
|
712
1166
|
|
|
713
|
-
// Spec: AIT-
|
|
714
|
-
//
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
// concurrent fork would be incorrectly auto-selected.
|
|
718
|
-
if (existing?.kind === 'pending') {
|
|
719
|
-
const nodes = this._tree.getSiblingNodes(msgId);
|
|
720
|
-
const newest = nodes.at(-1);
|
|
721
|
-
if (newest && newest.msgId !== msgId) {
|
|
722
|
-
const newestTurnId = newest.headers[HEADER_TURN_ID];
|
|
723
|
-
if (newestTurnId === existing.turnId) {
|
|
724
|
-
this._logger.debug('DefaultView._pinBranchSelections(); auto-selecting pending fork', {
|
|
725
|
-
msgId,
|
|
726
|
-
newestId: newest.msgId,
|
|
727
|
-
turnId: existing.turnId,
|
|
728
|
-
});
|
|
729
|
-
this._branchSelections.set(groupRoot, { kind: 'auto', selectedId: newest.msgId });
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
continue;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// Spec: AIT-CT13f
|
|
736
|
-
// External fork — pin to the currently-visible sibling.
|
|
737
|
-
if (existing) continue; // already have a selection
|
|
738
|
-
this._branchSelections.set(groupRoot, { kind: 'pinned', selectedId: msgId });
|
|
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 });
|
|
739
1171
|
}
|
|
740
1172
|
}
|
|
741
1173
|
|
|
742
1174
|
/**
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
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.
|
|
747
1182
|
*/
|
|
748
|
-
private
|
|
749
|
-
for (const [groupRoot, sel] of this.
|
|
750
|
-
if (sel.kind
|
|
751
|
-
const
|
|
752
|
-
if (
|
|
753
|
-
const newest =
|
|
754
|
-
if (!newest
|
|
755
|
-
|
|
756
|
-
if (newestTurnId === sel.turnId) {
|
|
757
|
-
this._logger.debug('DefaultView._resolvePendingSelections(); resolving off-branch pending', {
|
|
758
|
-
groupRoot,
|
|
759
|
-
newestId: newest.msgId,
|
|
760
|
-
turnId: sel.turnId,
|
|
761
|
-
});
|
|
762
|
-
this._branchSelections.set(groupRoot, { kind: 'auto', selectedId: newest.msgId });
|
|
763
|
-
}
|
|
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 });
|
|
764
1191
|
}
|
|
765
1192
|
}
|
|
766
1193
|
|
|
767
1194
|
private _onTreeAblyMessage(msg: Ably.InboundMessage): void {
|
|
768
|
-
// Re-emit only if the message corresponds to a visible
|
|
769
|
-
const headers =
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
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.
|
|
773
1203
|
this._emitter.emit('ably-message', msg);
|
|
774
1204
|
return;
|
|
775
1205
|
}
|
|
776
|
-
|
|
777
|
-
if (this.
|
|
1206
|
+
|
|
1207
|
+
if (runId && this._lastVisibleNodeKeySet.has(runId)) {
|
|
778
1208
|
this._emitter.emit('ably-message', msg);
|
|
779
1209
|
}
|
|
780
1210
|
}
|
|
781
1211
|
|
|
782
|
-
private
|
|
783
|
-
// Check if
|
|
784
|
-
if (this.
|
|
785
|
-
this._emitter.emit('
|
|
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);
|
|
786
1216
|
return;
|
|
787
1217
|
}
|
|
788
1218
|
|
|
789
|
-
// For
|
|
790
|
-
// messages arrive. Own
|
|
791
|
-
// Remote
|
|
792
|
-
if (event.type ===
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
this._lastVisibleTurnIds.add(event.turnId);
|
|
796
|
-
this._emitter.emit('turn', event);
|
|
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);
|
|
797
1225
|
}
|
|
798
1226
|
}
|
|
799
1227
|
|
|
800
1228
|
/**
|
|
801
|
-
* Predict whether a
|
|
802
|
-
* using the parent/forkOf metadata from the event.
|
|
803
|
-
* @param event - The
|
|
804
|
-
* @returns True if the
|
|
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.
|
|
805
1233
|
*/
|
|
806
|
-
private
|
|
1234
|
+
private _isRunStartVisible(event: RunLifecycleEvent & { type: 'start' }): boolean {
|
|
807
1235
|
const { parent } = event;
|
|
808
1236
|
|
|
809
1237
|
// No parent metadata — can't determine branch, forward as default.
|
|
810
|
-
// This covers root turns (parent omitted) and backward compat.
|
|
811
1238
|
if (parent === undefined) return true;
|
|
812
1239
|
|
|
813
|
-
//
|
|
814
|
-
|
|
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));
|
|
815
1248
|
}
|
|
816
1249
|
|
|
817
|
-
private _visibleChanged(
|
|
818
|
-
if (
|
|
819
|
-
for (const [i,
|
|
820
|
-
if (
|
|
821
|
-
|
|
822
|
-
// Also detect in-place content updates (e.g. streaming) via reference comparison
|
|
823
|
-
for (const [i, msg] of newMessages.entries()) {
|
|
824
|
-
if (msg !== this._lastVisibleMessages[i]) return true;
|
|
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;
|
|
825
1255
|
}
|
|
826
1256
|
return false;
|
|
827
1257
|
}
|
|
@@ -836,5 +1266,6 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
836
1266
|
* @param options - The tree, channel, codec, and logger to use.
|
|
837
1267
|
* @returns A new {@link DefaultView} instance.
|
|
838
1268
|
*/
|
|
839
|
-
export const createView = <
|
|
840
|
-
|
|
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);
|