@ably/ai-transport 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -111
- package/dist/ably-ai-transport.js +2401 -1387
- package/dist/ably-ai-transport.js.map +1 -1
- package/dist/ably-ai-transport.umd.cjs +1 -1
- package/dist/ably-ai-transport.umd.cjs.map +1 -1
- package/dist/constants.d.ts +116 -42
- package/dist/core/agent.d.ts +44 -0
- package/dist/core/channel-options.d.ts +57 -0
- package/dist/core/codec/codec-event.d.ts +9 -0
- package/dist/core/codec/decoder.d.ts +24 -24
- package/dist/core/codec/define-codec.d.ts +100 -0
- package/dist/core/codec/encoder.d.ts +10 -12
- package/dist/core/codec/field-bag.d.ts +85 -0
- package/dist/core/codec/fields.d.ts +141 -0
- package/dist/core/codec/index.d.ts +8 -2
- package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
- package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
- package/dist/core/codec/input-descriptors.d.ts +281 -0
- package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
- package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
- package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
- package/dist/core/codec/output-descriptors.d.ts +237 -0
- package/dist/core/codec/types.d.ts +470 -119
- package/dist/core/codec/well-known-inputs.d.ts +52 -0
- package/dist/core/transport/agent-session.d.ts +10 -0
- package/dist/core/transport/agent-view.d.ts +296 -0
- package/dist/core/transport/client-session.d.ts +13 -0
- package/dist/core/transport/decode-fold.d.ts +55 -0
- package/dist/core/transport/headers.d.ts +121 -14
- package/dist/core/transport/index.d.ts +5 -6
- package/dist/core/transport/internal/bounded-map.d.ts +20 -0
- package/dist/core/transport/invocation.d.ts +74 -0
- package/dist/core/transport/load-history-pages.d.ts +71 -0
- package/dist/core/transport/load-history.d.ts +44 -0
- package/dist/core/transport/pipe-stream.d.ts +9 -9
- package/dist/core/transport/run-manager.d.ts +76 -0
- package/dist/core/transport/session-support.d.ts +55 -0
- package/dist/core/transport/tree.d.ts +523 -109
- package/dist/core/transport/types/agent.d.ts +375 -0
- package/dist/core/transport/types/client.d.ts +201 -0
- package/dist/core/transport/types/shared.d.ts +24 -0
- package/dist/core/transport/types/tree.d.ts +357 -0
- package/dist/core/transport/types/view.d.ts +249 -0
- package/dist/core/transport/types.d.ts +13 -553
- package/dist/core/transport/view.d.ts +390 -84
- package/dist/core/transport/wire-log.d.ts +102 -0
- package/dist/errors.d.ts +27 -10
- package/dist/index.d.ts +8 -9
- package/dist/logger.d.ts +12 -0
- package/dist/react/ably-ai-transport-react.js +1365 -1010
- package/dist/react/ably-ai-transport-react.js.map +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
- package/dist/react/contexts/client-session-context.d.ts +37 -0
- package/dist/react/contexts/client-session-provider.d.ts +56 -0
- package/dist/react/create-session-hooks.d.ts +116 -0
- package/dist/react/index.d.ts +13 -12
- package/dist/react/internal/skipped-session.d.ts +8 -0
- package/dist/react/internal/use-resolved-session.d.ts +36 -0
- package/dist/react/use-ably-messages.d.ts +17 -14
- package/dist/react/use-client-session.d.ts +81 -0
- package/dist/react/use-create-view.d.ts +14 -13
- package/dist/react/use-tree.d.ts +30 -15
- package/dist/react/use-view.d.ts +81 -50
- package/dist/utils.d.ts +48 -71
- package/dist/vercel/ably-ai-transport-vercel.js +3257 -2499
- package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
- package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
- package/dist/vercel/codec/events.d.ts +50 -0
- package/dist/vercel/codec/fields.d.ts +44 -0
- package/dist/vercel/codec/fold-content.d.ts +16 -0
- package/dist/vercel/codec/fold-data.d.ts +16 -0
- package/dist/vercel/codec/fold-input.d.ts +67 -0
- package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
- package/dist/vercel/codec/fold-text.d.ts +16 -0
- package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
- package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
- package/dist/vercel/codec/index.d.ts +7 -20
- package/dist/vercel/codec/inputs.d.ts +11 -0
- package/dist/vercel/codec/outputs.d.ts +11 -0
- package/dist/vercel/codec/reducer-state.d.ts +121 -0
- package/dist/vercel/codec/reducer.d.ts +62 -0
- package/dist/vercel/codec/tool-transitions.d.ts +2 -8
- package/dist/vercel/codec/wire-data.d.ts +34 -0
- package/dist/vercel/index.d.ts +5 -5
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +2859 -9705
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -45
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +9 -7
- package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
- package/dist/vercel/react/index.d.ts +1 -2
- package/dist/vercel/react/use-chat-transport.d.ts +30 -26
- package/dist/vercel/react/use-message-sync.d.ts +17 -30
- package/dist/vercel/run-end-reason.d.ts +84 -0
- package/dist/vercel/tool-part.d.ts +21 -0
- package/dist/vercel/transport/chat-transport.d.ts +41 -24
- package/dist/vercel/transport/index.d.ts +24 -20
- package/dist/vercel/transport/run-output-stream.d.ts +54 -0
- package/dist/version.d.ts +2 -0
- package/package.json +31 -24
- package/src/constants.ts +124 -51
- package/src/core/agent.ts +92 -0
- package/src/core/channel-options.ts +89 -0
- package/src/core/codec/codec-event.ts +27 -0
- package/src/core/codec/decoder.ts +202 -105
- package/src/core/codec/define-codec.ts +432 -0
- package/src/core/codec/encoder.ts +114 -107
- package/src/core/codec/field-bag.ts +142 -0
- package/src/core/codec/fields.ts +193 -0
- package/src/core/codec/index.ts +56 -6
- package/src/core/codec/input-descriptor-decoder.ts +97 -0
- package/src/core/codec/input-descriptor-encoder.ts +150 -0
- package/src/core/codec/input-descriptors.ts +373 -0
- package/src/core/codec/lifecycle-tracker.ts +10 -9
- package/src/core/codec/output-descriptor-decoder.ts +139 -0
- package/src/core/codec/output-descriptor-encoder.ts +101 -0
- package/src/core/codec/output-descriptors.ts +307 -0
- package/src/core/codec/types.ts +505 -126
- package/src/core/codec/well-known-inputs.ts +96 -0
- package/src/core/transport/agent-session.ts +1085 -0
- package/src/core/transport/agent-view.ts +738 -0
- package/src/core/transport/client-session.ts +780 -0
- package/src/core/transport/decode-fold.ts +101 -0
- package/src/core/transport/headers.ts +234 -22
- package/src/core/transport/index.ts +27 -27
- package/src/core/transport/internal/bounded-map.ts +27 -0
- package/src/core/transport/invocation.ts +98 -0
- package/src/core/transport/load-history-pages.ts +220 -0
- package/src/core/transport/load-history.ts +271 -0
- package/src/core/transport/pipe-stream.ts +63 -39
- package/src/core/transport/run-manager.ts +243 -0
- package/src/core/transport/session-support.ts +96 -0
- package/src/core/transport/tree.ts +1293 -308
- package/src/core/transport/types/agent.ts +434 -0
- package/src/core/transport/types/client.ts +247 -0
- package/src/core/transport/types/shared.ts +27 -0
- package/src/core/transport/types/tree.ts +393 -0
- package/src/core/transport/types/view.ts +288 -0
- package/src/core/transport/types.ts +13 -706
- package/src/core/transport/view.ts +1229 -450
- package/src/core/transport/wire-log.ts +189 -0
- package/src/errors.ts +29 -9
- package/src/event-emitter.ts +3 -2
- package/src/index.ts +86 -42
- package/src/logger.ts +14 -1
- package/src/react/contexts/client-session-context.ts +41 -0
- package/src/react/contexts/client-session-provider.tsx +222 -0
- package/src/react/create-session-hooks.ts +141 -0
- package/src/react/index.ts +24 -13
- package/src/react/internal/skipped-session.ts +62 -0
- package/src/react/internal/use-resolved-session.ts +63 -0
- package/src/react/use-ably-messages.ts +32 -22
- package/src/react/use-client-session.ts +178 -0
- package/src/react/use-create-view.ts +33 -29
- package/src/react/use-tree.ts +61 -30
- package/src/react/use-view.ts +138 -96
- package/src/utils.ts +83 -131
- package/src/vercel/codec/decode-lifecycle.ts +70 -0
- package/src/vercel/codec/events.ts +85 -0
- package/src/vercel/codec/fields.ts +58 -0
- package/src/vercel/codec/fold-content.ts +54 -0
- package/src/vercel/codec/fold-data.ts +46 -0
- package/src/vercel/codec/fold-input.ts +255 -0
- package/src/vercel/codec/fold-lifecycle.ts +85 -0
- package/src/vercel/codec/fold-text.ts +55 -0
- package/src/vercel/codec/fold-tool-input.ts +86 -0
- package/src/vercel/codec/fold-tool-output.ts +79 -0
- package/src/vercel/codec/index.ts +28 -21
- package/src/vercel/codec/inputs.ts +116 -0
- package/src/vercel/codec/outputs.ts +207 -0
- package/src/vercel/codec/reducer-state.ts +169 -0
- package/src/vercel/codec/reducer.ts +191 -0
- package/src/vercel/codec/tool-transitions.ts +3 -14
- package/src/vercel/codec/wire-data.ts +64 -0
- package/src/vercel/index.ts +7 -19
- package/src/vercel/react/contexts/chat-transport-context.ts +8 -7
- package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
- package/src/vercel/react/index.ts +3 -5
- package/src/vercel/react/use-chat-transport.ts +44 -66
- package/src/vercel/react/use-message-sync.ts +75 -39
- package/src/vercel/run-end-reason.ts +157 -0
- package/src/vercel/tool-part.ts +25 -0
- package/src/vercel/transport/chat-transport.ts +380 -98
- package/src/vercel/transport/index.ts +38 -37
- package/src/vercel/transport/run-output-stream.ts +169 -0
- package/src/version.ts +2 -0
- package/dist/core/transport/client-transport.d.ts +0 -10
- package/dist/core/transport/decode-history.d.ts +0 -43
- package/dist/core/transport/server-transport.d.ts +0 -7
- package/dist/core/transport/stream-router.d.ts +0 -29
- package/dist/core/transport/turn-manager.d.ts +0 -37
- package/dist/react/contexts/transport-context.d.ts +0 -31
- package/dist/react/contexts/transport-provider.d.ts +0 -49
- package/dist/react/create-transport-hooks.d.ts +0 -124
- package/dist/react/use-active-turns.d.ts +0 -12
- package/dist/react/use-client-transport.d.ts +0 -80
- package/dist/vercel/codec/accumulator.d.ts +0 -21
- package/dist/vercel/codec/decoder.d.ts +0 -22
- package/dist/vercel/codec/encoder.d.ts +0 -41
- package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
- package/dist/vercel/tool-approvals.d.ts +0 -124
- package/dist/vercel/tool-events.d.ts +0 -26
- package/src/core/transport/client-transport.ts +0 -977
- package/src/core/transport/decode-history.ts +0 -485
- package/src/core/transport/server-transport.ts +0 -612
- package/src/core/transport/stream-router.ts +0 -136
- package/src/core/transport/turn-manager.ts +0 -165
- package/src/react/contexts/transport-context.ts +0 -37
- package/src/react/contexts/transport-provider.tsx +0 -164
- package/src/react/create-transport-hooks.ts +0 -144
- package/src/react/use-active-turns.ts +0 -72
- package/src/react/use-client-transport.ts +0 -197
- package/src/vercel/codec/accumulator.ts +0 -588
- package/src/vercel/codec/decoder.ts +0 -618
- package/src/vercel/codec/encoder.ts +0 -410
- package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
- package/src/vercel/tool-approvals.ts +0 -380
- package/src/vercel/tool-events.ts +0 -53
|
@@ -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 type { WireApplier } 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,46 @@ 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
|
-
|
|
85
|
+
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
|
-
/**
|
|
77
|
-
|
|
90
|
+
/** The codec used to project messages and mint regenerate inputs. */
|
|
91
|
+
codec: Codec<TInput, TOutput, TProjection, TMessage>;
|
|
92
|
+
/**
|
|
93
|
+
* The Tree's single decode-and-apply engine, owned by the session and
|
|
94
|
+
* shared with the live decode loop. History replay feeds pages through it
|
|
95
|
+
* so the one decoder instance sees every route into the Tree.
|
|
96
|
+
*/
|
|
97
|
+
applier: WireApplier;
|
|
98
|
+
/** Delegate for executing sends through the session. */
|
|
99
|
+
sendDelegate: SendDelegate<TInput>;
|
|
78
100
|
/** Logger for diagnostic output. */
|
|
79
101
|
logger: Logger;
|
|
80
102
|
/** Called when the view is closed, allowing the owner to clean up references. */
|
|
@@ -86,83 +108,222 @@ export interface ViewOptions<TEvent, TMessage> {
|
|
|
86
108
|
// ---------------------------------------------------------------------------
|
|
87
109
|
|
|
88
110
|
/**
|
|
89
|
-
*
|
|
90
|
-
* Stored per group
|
|
111
|
+
* Internal tagged union representing why a branch was selected for an
|
|
112
|
+
* edit-fork group. Stored per group-root runId in the View's
|
|
113
|
+
* `_branchSelections` map. Not the public-facing {@link BranchSelection}
|
|
114
|
+
* — that's a UI-facing bundle returned by `view.branchSelection(id)`.
|
|
91
115
|
*/
|
|
92
|
-
type
|
|
93
|
-
/** Explicit navigation via `
|
|
94
|
-
| { kind: 'user';
|
|
95
|
-
/** This view initiated
|
|
96
|
-
| { kind: 'auto';
|
|
116
|
+
type BranchSelectionState =
|
|
117
|
+
/** Explicit navigation via `selectSibling()`. The selected input-node key. */
|
|
118
|
+
| { kind: 'user'; selectedKey: string }
|
|
119
|
+
/** This view initiated an edit fork — auto-selected the new input node. */
|
|
120
|
+
| { kind: 'auto'; selectedKey: string }
|
|
97
121
|
/** An external fork appeared — pinned to the currently-visible sibling to prevent drift. */
|
|
98
|
-
| { kind: 'pinned';
|
|
99
|
-
|
|
100
|
-
|
|
122
|
+
| { kind: 'pinned'; selectedKey: string };
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Selection state for a regenerate group. Keyed by the anchor codec-message-id (the
|
|
126
|
+
* assistant codec-message-id being regenerated). Distinct from {@link BranchSelectionState}
|
|
127
|
+
* because regenerate groups are message-level (group members share an
|
|
128
|
+
* anchor codec-message-id), not edit forks of the user prompt.
|
|
129
|
+
*
|
|
130
|
+
* Unlike fork-of groups, regenerate groups do not "pin to current visible"
|
|
131
|
+
* when a new member appears externally — the default for a regenerate
|
|
132
|
+
* slot is always the latest member, so an external regenerator auto-rolls
|
|
133
|
+
* forward unless the user has explicitly selected an earlier member.
|
|
134
|
+
*/
|
|
135
|
+
type RegenSelection =
|
|
136
|
+
/** Explicit navigation via `selectSibling()`. The selected reply-run id. */
|
|
137
|
+
| { kind: 'user'; selectedRunId: string }
|
|
138
|
+
/** This view initiated a regenerate — auto-selected the new reply run when it arrived. */
|
|
139
|
+
| { kind: 'auto'; selectedRunId: string }
|
|
140
|
+
/**
|
|
141
|
+
* This view's `regenerate()` is in flight. Keyed (in `_regenSelections`) by
|
|
142
|
+
* the regenerate group's root; `carrierCodecMessageId` is the regenerate
|
|
143
|
+
* carrier event's id, used to recognise the new reply run when it appears.
|
|
144
|
+
*/
|
|
145
|
+
| { kind: 'pending'; carrierCodecMessageId: string };
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* One alternative inside a {@link MessageBranchPoint}. The representative is the
|
|
149
|
+
* member's own head message for fork-of and whole-reply regen groups, but the
|
|
150
|
+
* *regenerate target* (a non-head message) for a non-head regen group - so it is
|
|
151
|
+
* tracked explicitly rather than re-derived from the node's head.
|
|
152
|
+
*/
|
|
153
|
+
interface BranchMember {
|
|
154
|
+
/**
|
|
155
|
+
* The member node's `nodeKey` (tree.ts): a runId for a reply/regenerator run,
|
|
156
|
+
* a codecMessageId for an input node. Matched by `_resolveSelectedIndex`.
|
|
157
|
+
*/
|
|
158
|
+
memberNodeKey: string;
|
|
159
|
+
/** The codec-message-id rendered in this member's branch-arrow slot. */
|
|
160
|
+
representativeCodecMessageId: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A resolved branch point: the group `kind` plus the member alternatives.
|
|
165
|
+
*
|
|
166
|
+
* Terms: "regenerate target" = the message being replaced; "regenerator run" =
|
|
167
|
+
* the run that replaces it; "non-head message" = any message after a run's
|
|
168
|
+
* first (index > 0, includes the tail).
|
|
169
|
+
*
|
|
170
|
+
* The three kinds, by anchor:
|
|
171
|
+
* - `fork-of` — edit-style branch anchored at the user input node; members are
|
|
172
|
+
* the alternate prompts (input-node sibling group).
|
|
173
|
+
* - `regen` — whole-reply regenerate branch anchored at the assistant slot;
|
|
174
|
+
* members are the original reply + its regenerator runs (same-input-node
|
|
175
|
+
* sibling reply runs).
|
|
176
|
+
* - `non-head-regen` — a regenerate that replaced a non-head message inside a
|
|
177
|
+
* multi-message reply run; members are the owner run (the regenerate target in
|
|
178
|
+
* place) plus each regenerator run. Not expressible as a same-parent
|
|
179
|
+
* sibling-run group, so the View resolves and renders it itself (see
|
|
180
|
+
* `_extractMessages`).
|
|
181
|
+
*
|
|
182
|
+
* `groupRoot` is the selection-map key: the input group root for fork-of, the
|
|
183
|
+
* original reply's group root for regen, and the regenerate target's
|
|
184
|
+
* codec-message-id for non-head-regen.
|
|
185
|
+
*/
|
|
186
|
+
type MessageBranchPoint =
|
|
187
|
+
| { kind: 'fork-of'; groupRoot: string; members: BranchMember[] }
|
|
188
|
+
| { kind: 'regen'; groupRoot: string; members: BranchMember[] }
|
|
189
|
+
| { kind: 'non-head-regen'; groupRoot: string; members: BranchMember[] };
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Send-input normalisation
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Normalise the two input shapes `View.send` accepts (a single TInput
|
|
197
|
+
* or an array) into the array shape the SendDelegate consumes.
|
|
198
|
+
* @param input - The raw input from `View.send`.
|
|
199
|
+
* @returns The normalised input array.
|
|
200
|
+
*/
|
|
201
|
+
const _normaliseSend = <TInput extends CodecInputEvent>(input: TInput | TInput[]): TInput[] =>
|
|
202
|
+
Array.isArray(input) ? input : [input];
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Project a Tree `RunNode` down to the View-facing `RunInfo` shape:
|
|
206
|
+
* drop the codec projection and the structural fields that callers
|
|
207
|
+
* reach via `session.tree` when they need them.
|
|
208
|
+
* @param run - The tree's RunNode.
|
|
209
|
+
* @returns A projection-free RunInfo.
|
|
210
|
+
*/
|
|
211
|
+
const _toRunInfo = <TProjection>(run: RunNode<TProjection>): RunInfo => ({
|
|
212
|
+
runId: run.runId,
|
|
213
|
+
clientId: run.clientId,
|
|
214
|
+
invocationId: run.invocationId,
|
|
215
|
+
...run.state,
|
|
216
|
+
});
|
|
101
217
|
|
|
102
218
|
// ---------------------------------------------------------------------------
|
|
103
219
|
// Implementation
|
|
104
220
|
// ---------------------------------------------------------------------------
|
|
105
221
|
|
|
106
|
-
export class DefaultView<
|
|
107
|
-
|
|
222
|
+
export class DefaultView<
|
|
223
|
+
TInput extends CodecInputEvent,
|
|
224
|
+
TOutput extends CodecOutputEvent,
|
|
225
|
+
TProjection,
|
|
226
|
+
TMessage,
|
|
227
|
+
> implements View<TInput, TMessage> {
|
|
228
|
+
private readonly _tree: TreeInternal<TInput, TOutput, TProjection>;
|
|
108
229
|
private readonly _channel: Ably.RealtimeChannel;
|
|
109
|
-
private readonly _codec: Codec<
|
|
110
|
-
private readonly
|
|
230
|
+
private readonly _codec: Codec<TInput, TOutput, TProjection, TMessage>;
|
|
231
|
+
private readonly _applier: WireApplier;
|
|
232
|
+
private readonly _sendDelegate: SendDelegate<TInput>;
|
|
111
233
|
private readonly _logger: Logger;
|
|
112
234
|
private readonly _emitter: EventEmitter<ViewEventsMap>;
|
|
113
235
|
private readonly _onClose?: () => void;
|
|
114
236
|
|
|
115
237
|
/**
|
|
116
|
-
* View-local branch selections: group
|
|
238
|
+
* View-local branch selections: group-root runId → selection intent.
|
|
117
239
|
* 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
240
|
*/
|
|
122
|
-
private readonly _branchSelections = new Map<string,
|
|
241
|
+
private readonly _branchSelections = new Map<string, BranchSelectionState>();
|
|
123
242
|
|
|
124
|
-
/**
|
|
125
|
-
|
|
243
|
+
/**
|
|
244
|
+
* View-local regenerate-group selections: anchor codec-message-id (the assistant
|
|
245
|
+
* codec-message-id being regenerated) → selection intent. Distinct from
|
|
246
|
+
* {@link _branchSelections} because a regenerate group is a set of
|
|
247
|
+
* same-parent reply runs — message-level alternatives at a single
|
|
248
|
+
* conversation slot, not edit forks of the prompt. Groups not present here default to the latest
|
|
249
|
+
* member (the most recent regenerator, or the original if no regen has
|
|
250
|
+
* landed).
|
|
251
|
+
*/
|
|
252
|
+
private readonly _regenSelections = new Map<string, RegenSelection>();
|
|
126
253
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Non-head regenerate selections, keyed by the regenerate target's
|
|
256
|
+
* codec-message-id. Separate from {@link _regenSelections} because a non-head
|
|
257
|
+
* regenerator parents inside the owner run rather than as a same-parent
|
|
258
|
+
* sibling, so it lives outside the Tree's `visibleNodes` selection space and
|
|
259
|
+
* is resolved at extraction (see `_extractMessages`). Value is the selected
|
|
260
|
+
* member's nodeKey (the owner run id, or a regenerator run id); absent groups
|
|
261
|
+
* default to the newest regenerator.
|
|
262
|
+
*/
|
|
263
|
+
private readonly _nonHeadRegenSelections = new Map<string, RegenSelection>();
|
|
129
264
|
|
|
130
|
-
/**
|
|
131
|
-
private
|
|
265
|
+
/** Spec: AIT-CT11c — runIds loaded from history but not yet revealed to the UI. */
|
|
266
|
+
private readonly _withheldRunIds = new Set<string>();
|
|
132
267
|
|
|
133
|
-
/**
|
|
134
|
-
private
|
|
268
|
+
/** Snapshot of visible node keys — used to detect structural changes and for selection pinning. */
|
|
269
|
+
private _lastVisibleNodeKeys: string[] = [];
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Snapshot of visible projection references — used to detect in-place
|
|
273
|
+
* projection updates (streaming). One entry per visible Run.
|
|
274
|
+
*/
|
|
275
|
+
private _lastVisibleProjections: TProjection[] = [];
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Snapshot of the visible flat message chain with codec-message-ids —
|
|
279
|
+
* exposed verbatim via `getMessages()` and the internal correlation
|
|
280
|
+
* source for parent/branch routing.
|
|
281
|
+
*/
|
|
282
|
+
private _lastVisibleMessagePairs: CodecMessage<TMessage>[] = [];
|
|
283
|
+
|
|
284
|
+
/** Cached visible node-key Set — for O(1) lookup in event scoping. */
|
|
285
|
+
private _lastVisibleNodeKeySet = new Set<string>();
|
|
135
286
|
|
|
136
287
|
/** Whether there are more history pages to fetch from the channel. */
|
|
137
288
|
private _hasMoreHistory = false;
|
|
138
289
|
|
|
139
290
|
/** Internal state for continuing history pagination. */
|
|
140
|
-
private _lastHistoryPage: HistoryPage
|
|
291
|
+
private _lastHistoryPage: HistoryPage | undefined;
|
|
141
292
|
|
|
142
|
-
/** Buffer of withheld nodes, drained newest-first by successive loadOlder() calls. */
|
|
143
|
-
private readonly _withheldBuffer:
|
|
293
|
+
/** Buffer of withheld nodes (input + reply), drained newest-first by successive loadOlder() calls. */
|
|
294
|
+
private readonly _withheldBuffer: ConversationNode<TProjection>[] = [];
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Message-level trim on top of the run-level pagination window. Runs are
|
|
298
|
+
* revealed whole (via `_withheldRunIds`/`_withheldBuffer`), so a `loadOlder`
|
|
299
|
+
* may surface more messages than asked; this is the count of OLDEST messages
|
|
300
|
+
* of the visible node chain to hide from `getMessages()` so a page lands on
|
|
301
|
+
* exactly `limit` messages. The boundary run still appears in `runs()` (it's
|
|
302
|
+
* a revealed node); only its oldest messages are trimmed from the flat list.
|
|
303
|
+
* Live messages append at the newest end and are never trimmed.
|
|
304
|
+
*/
|
|
305
|
+
private _hiddenMessageCount = 0;
|
|
144
306
|
|
|
145
307
|
/** Unsubscribe functions for tree event subscriptions. */
|
|
146
308
|
private readonly _unsubs: (() => void)[] = [];
|
|
147
309
|
|
|
148
310
|
/**
|
|
149
|
-
* Cached result of the last
|
|
150
|
-
*
|
|
151
|
-
*
|
|
311
|
+
* Cached result of the last flat-nodes computation. Drives the visible
|
|
312
|
+
* message snapshot exposed via `getMessages()`; refreshed by
|
|
313
|
+
* `_computeFlatNodes()` on structural changes, selection changes,
|
|
314
|
+
* and history reveal.
|
|
152
315
|
*/
|
|
153
|
-
private _cachedNodes:
|
|
154
|
-
|
|
155
|
-
/** Last seen tree structural version - used to distinguish content-only from structural updates. */
|
|
156
|
-
private _lastStructuralVersion = -1;
|
|
316
|
+
private _cachedNodes: ConversationNode<TProjection>[] = [];
|
|
157
317
|
|
|
158
318
|
private _loadingOlder = false;
|
|
159
319
|
private _processingHistory = false;
|
|
160
320
|
private _closed = false;
|
|
161
321
|
|
|
162
|
-
constructor(options: ViewOptions<
|
|
322
|
+
constructor(options: ViewOptions<TInput, TOutput, TProjection, TMessage>) {
|
|
163
323
|
this._tree = options.tree;
|
|
164
324
|
this._channel = options.channel;
|
|
165
325
|
this._codec = options.codec;
|
|
326
|
+
this._applier = options.applier;
|
|
166
327
|
this._sendDelegate = options.sendDelegate;
|
|
167
328
|
this._onClose = options.onClose;
|
|
168
329
|
this._logger = options.logger.withContext({ component: 'View' });
|
|
@@ -171,7 +332,6 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
171
332
|
|
|
172
333
|
// Compute initial cache and snapshot visible state
|
|
173
334
|
this._cachedNodes = this._computeFlatNodes();
|
|
174
|
-
this._lastStructuralVersion = this._tree.structuralVersion;
|
|
175
335
|
this._updateVisibleSnapshot(this._cachedNodes);
|
|
176
336
|
|
|
177
337
|
// Subscribe to tree events and re-emit scoped versions
|
|
@@ -182,79 +342,294 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
182
342
|
this._tree.on('ably-message', (msg) => {
|
|
183
343
|
this._onTreeAblyMessage(msg);
|
|
184
344
|
}),
|
|
185
|
-
this._tree.on('
|
|
186
|
-
this.
|
|
345
|
+
this._tree.on('run', (event) => {
|
|
346
|
+
this._onTreeRun(event);
|
|
347
|
+
}),
|
|
348
|
+
this._tree.on('output', (event) => {
|
|
349
|
+
this._onTreeOutput(event);
|
|
187
350
|
}),
|
|
188
351
|
);
|
|
189
352
|
}
|
|
190
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Handle decoded outputs folded into a Run (streaming delta). If the run
|
|
356
|
+
* is on the visible chain, recompute the flat message list and emit
|
|
357
|
+
* `update`.
|
|
358
|
+
* @param event - The output event from the Tree.
|
|
359
|
+
*/
|
|
360
|
+
private _onTreeOutput(event: OutputEvent<TOutput>): void {
|
|
361
|
+
if (this._processingHistory) return;
|
|
362
|
+
// The fold target may be a reply run (event.runId) or a user input node
|
|
363
|
+
// (event.runId undefined — the agent mints run-ids, so an input fold has
|
|
364
|
+
// none). Gate on whichever key the visible set holds.
|
|
365
|
+
const folded =
|
|
366
|
+
(event.runId !== undefined && this._lastVisibleNodeKeySet.has(event.runId)) ||
|
|
367
|
+
(event.inputCodecMessageId !== undefined && this._lastVisibleNodeKeySet.has(event.inputCodecMessageId));
|
|
368
|
+
if (!folded) return;
|
|
369
|
+
|
|
370
|
+
// The Tree emits `output` once per inbound message fold (with empty
|
|
371
|
+
// `events` for inputs-only folds), so it fires whenever a visible Run's
|
|
372
|
+
// projection changed and we always re-emit. The Reducer contract permits
|
|
373
|
+
// in-place mutation, which means we cannot use projection-ref or
|
|
374
|
+
// TMessage-ref equality to detect change: a streaming chunk legitimately
|
|
375
|
+
// mutates the same UIMessage object, and a ref-equality short-circuit
|
|
376
|
+
// would suppress every update. React state setters at the subscriber
|
|
377
|
+
// boundary already dedup by array reference, so a redundant emit is a
|
|
378
|
+
// no-op for unchanged hook consumers.
|
|
379
|
+
this._lastVisibleProjections = this._cachedNodes.map((n) => n.projection);
|
|
380
|
+
this._lastVisibleMessagePairs = this._extractMessages(this._cachedNodes).slice(this._hiddenMessageCount);
|
|
381
|
+
this._emitter.emit('update');
|
|
382
|
+
}
|
|
383
|
+
|
|
191
384
|
// -------------------------------------------------------------------------
|
|
192
385
|
// Public query methods
|
|
193
386
|
// -------------------------------------------------------------------------
|
|
194
387
|
|
|
195
|
-
getMessages(): TMessage[] {
|
|
196
|
-
return this.
|
|
388
|
+
getMessages(): CodecMessage<TMessage>[] {
|
|
389
|
+
return this._lastVisibleMessagePairs;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
runs(): RunInfo[] {
|
|
393
|
+
// `_cachedNodes` is the visible node chain (inputs + reply runs) with
|
|
394
|
+
// pagination and sibling selection already applied. RunInfo is reply-run
|
|
395
|
+
// shaped, so filter to runs before projecting.
|
|
396
|
+
return this._cachedNodes
|
|
397
|
+
.filter((node): node is RunNode<TProjection> => node.kind === 'run')
|
|
398
|
+
.map((node) => _toRunInfo(node));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Compute the fresh visible node chain. The Tree's `visibleNodes` already
|
|
403
|
+
* applies kind-blind reachability and sibling selection (edit versions /
|
|
404
|
+
* regenerate runs collapse to the selected member), so the View only layers
|
|
405
|
+
* its pagination window on top: drop nodes whose key is currently withheld.
|
|
406
|
+
* @returns A fresh array of visible nodes (inputs + reply runs).
|
|
407
|
+
*/
|
|
408
|
+
private _computeFlatNodes(): ConversationNode<TProjection>[] {
|
|
409
|
+
const treeNodes = this._treeVisibleNodes();
|
|
410
|
+
if (this._withheldRunIds.size === 0) return treeNodes;
|
|
411
|
+
return treeNodes.filter((node) => !this._withheldRunIds.has(nodeKey(node)));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Recompute the visible node chain, refresh the cache + snapshot, and emit
|
|
416
|
+
* `update` unconditionally. Use after a mutation that always changes the
|
|
417
|
+
* visible output (e.g. an explicit selection or a withheld-batch reveal).
|
|
418
|
+
*/
|
|
419
|
+
private _recomputeAndEmit(): void {
|
|
420
|
+
this._cachedNodes = this._computeFlatNodes();
|
|
421
|
+
this._updateVisibleSnapshot(this._cachedNodes);
|
|
422
|
+
this._emitter.emit('update');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Recompute the visible node chain and, only if it differs from the current
|
|
427
|
+
* snapshot, refresh the cache + snapshot and emit `update`. Use after a
|
|
428
|
+
* mutation that may or may not move the visible window (e.g. a structural
|
|
429
|
+
* tree update, or a deferred regenerate promotion that may already match).
|
|
430
|
+
*/
|
|
431
|
+
private _recomputeAndEmitIfChanged(): void {
|
|
432
|
+
const nodes = this._computeFlatNodes();
|
|
433
|
+
if (this._visibleChanged(nodes)) {
|
|
434
|
+
this._cachedNodes = nodes;
|
|
435
|
+
this._updateVisibleSnapshot(nodes);
|
|
436
|
+
this._emitter.emit('update');
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Resolve the reply Run that owns a codec-message-id, narrowing the Tree's
|
|
442
|
+
* node union to a {@link RunNode}. A user-input codec-message-id resolves to
|
|
443
|
+
* an input node and yields `undefined` here — callers that must handle input
|
|
444
|
+
* nodes use {@link _tree.getNodeByCodecMessageId} directly.
|
|
445
|
+
* @param codecMessageId - The codec-message-id to resolve.
|
|
446
|
+
* @returns The owning RunNode, or undefined if absent or not a reply Run.
|
|
447
|
+
*/
|
|
448
|
+
private _runByCodecMessageId(codecMessageId: string): RunNode<TProjection> | undefined {
|
|
449
|
+
const node = this._tree.getNodeByCodecMessageId(codecMessageId);
|
|
450
|
+
return node?.kind === 'run' ? node : undefined;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The regenerator runs that replaced a non-head message of a reply run. They
|
|
455
|
+
* file under the target's predecessor (not the owner run's input node), so the
|
|
456
|
+
* Tree's `visibleNodes` cannot collapse them into the owner's slot; this
|
|
457
|
+
* surfaces them for the View to resolve and render. Head-message (index 0)
|
|
458
|
+
* regenerates are excluded - those are whole-reply sibling runs the Tree
|
|
459
|
+
* already groups.
|
|
460
|
+
* @param targetCodecMessageId - The regenerate target's (non-head) message id.
|
|
461
|
+
* @param predecessorCodecMessageId - The codec-message-id immediately before it in the owner run.
|
|
462
|
+
* @returns The regenerator runs in startSerial order (oldest first).
|
|
463
|
+
*/
|
|
464
|
+
private _nonHeadRegenerators(
|
|
465
|
+
targetCodecMessageId: string,
|
|
466
|
+
predecessorCodecMessageId: string,
|
|
467
|
+
): RunNode<TProjection>[] {
|
|
468
|
+
return this._tree
|
|
469
|
+
.getReplyRuns(predecessorCodecMessageId)
|
|
470
|
+
.filter((r) => r.regeneratesCodecMessageId === targetCodecMessageId)
|
|
471
|
+
.toSorted((a, b) => (a.startSerial ?? '').localeCompare(b.startSerial ?? ''));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Resolve the selected member of a non-head regenerate group anchored at
|
|
476
|
+
* `targetCodecMessageId`. Members are the owner run `O` (memberNodeKey =
|
|
477
|
+
* `ownerRunId`, the regenerate target in place) followed by each regenerator
|
|
478
|
+
* run. Honours an explicit {@link _nonHeadRegenSelections} entry, else
|
|
479
|
+
* defaults to the latest member (newest regenerator), mirroring the
|
|
480
|
+
* whole-reply regenerate default.
|
|
481
|
+
* @param targetCodecMessageId - The regenerate target's message id (the group anchor).
|
|
482
|
+
* @param ownerRunId - The runId of the run that owns the regenerate target.
|
|
483
|
+
* @param regenerators - The regenerator runs (oldest first) from `_nonHeadRegenerators`.
|
|
484
|
+
* @returns The selected member's node key (`ownerRunId` or a regenerator runId).
|
|
485
|
+
*/
|
|
486
|
+
private _selectedNonHeadMember(
|
|
487
|
+
targetCodecMessageId: string,
|
|
488
|
+
ownerRunId: string,
|
|
489
|
+
regenerators: RunNode<TProjection>[],
|
|
490
|
+
): string {
|
|
491
|
+
const sel = this._nonHeadRegenSelections.get(targetCodecMessageId);
|
|
492
|
+
if (sel && sel.kind !== 'pending') {
|
|
493
|
+
const keys = [ownerRunId, ...regenerators.map((r) => r.runId)];
|
|
494
|
+
if (keys.includes(sel.selectedRunId)) return sel.selectedRunId;
|
|
495
|
+
}
|
|
496
|
+
// Default: latest member = newest regenerator (regenerators is oldest-first).
|
|
497
|
+
return regenerators.at(-1)?.runId ?? ownerRunId;
|
|
197
498
|
}
|
|
198
499
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
500
|
+
/**
|
|
501
|
+
* Flatten visible nodes to messages, collapsing a non-head regenerate into the
|
|
502
|
+
* slot it replaces: while emitting a reply run, at each non-head message that
|
|
503
|
+
* has a selected regenerator, drop that message and the run's tail and emit the
|
|
504
|
+
* selected regenerator instead (recursive for regen-of-regen). Whole-reply
|
|
505
|
+
* regenerates need nothing here - visibleNodes already picks the sibling.
|
|
506
|
+
* @param nodes - Visible nodes (inputs + reply runs), chronological.
|
|
507
|
+
* @returns The flat message list, each paired with its codec-message-id.
|
|
508
|
+
*/
|
|
509
|
+
private _extractMessages(nodes: ConversationNode<TProjection>[]): CodecMessage<TMessage>[] {
|
|
510
|
+
const messages: CodecMessage<TMessage>[] = [];
|
|
511
|
+
// Regenerator runs already emitted via substitution at their anchor — skip
|
|
512
|
+
// them when the node walk reaches them directly.
|
|
513
|
+
const consumedRunIds = new Set<string>();
|
|
514
|
+
|
|
515
|
+
for (const node of nodes) {
|
|
516
|
+
if (node.kind === 'run' && consumedRunIds.has(node.runId)) continue;
|
|
517
|
+
this._emitNodeMessages(node, messages, consumedRunIds);
|
|
518
|
+
}
|
|
519
|
+
return messages;
|
|
202
520
|
}
|
|
203
521
|
|
|
204
522
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* @
|
|
523
|
+
* Emit one visible node's messages into `out`, applying non-head regenerate
|
|
524
|
+
* substitution for a reply run (see `_extractMessages`). Input nodes and runs
|
|
525
|
+
* with no non-head regenerators emit their projection verbatim.
|
|
526
|
+
* @param node - The node to emit.
|
|
527
|
+
* @param out - The accumulating flat message list (mutated in place).
|
|
528
|
+
* @param consumedRunIds - Set of regenerator runIds already emitted via substitution (mutated in place).
|
|
210
529
|
*/
|
|
211
|
-
private
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
530
|
+
private _emitNodeMessages(
|
|
531
|
+
node: ConversationNode<TProjection>,
|
|
532
|
+
out: CodecMessage<TMessage>[],
|
|
533
|
+
consumedRunIds: Set<string>,
|
|
534
|
+
): void {
|
|
535
|
+
const own = this._codec.getMessages(node.projection);
|
|
536
|
+
if (node.kind !== 'run') {
|
|
537
|
+
out.push(...own);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
for (let i = 0; i < own.length; i++) {
|
|
541
|
+
const m = own[i];
|
|
542
|
+
if (!m) continue;
|
|
543
|
+
// Head message (i === 0) regenerates are whole-reply sibling runs, already
|
|
544
|
+
// resolved by visibleNodes — only non-head messages anchor a non-head group.
|
|
545
|
+
const predecessor = i > 0 ? own[i - 1]?.codecMessageId : undefined;
|
|
546
|
+
if (predecessor !== undefined) {
|
|
547
|
+
const regenerators = this._nonHeadRegenerators(m.codecMessageId, predecessor);
|
|
548
|
+
if (regenerators.length > 0) {
|
|
549
|
+
// Every regenerator (and any same-anchor sibling the Tree already
|
|
550
|
+
// collapsed in `visibleNodes`) is an alternative at THIS one slot, so
|
|
551
|
+
// mark them all consumed up front — the node walk must not re-emit the
|
|
552
|
+
// Tree's default-latest sibling once we render a different member here.
|
|
553
|
+
for (const r of regenerators) consumedRunIds.add(r.runId);
|
|
554
|
+
const selectedKey = this._selectedNonHeadMember(m.codecMessageId, node.runId, regenerators);
|
|
555
|
+
if (selectedKey !== node.runId) {
|
|
556
|
+
// A regenerator is selected: drop M and the rest of O, emit the
|
|
557
|
+
// selected regenerator in M's place (recursively for nested regen).
|
|
558
|
+
const chosen = regenerators.find((r) => r.runId === selectedKey);
|
|
559
|
+
if (chosen) {
|
|
560
|
+
this._emitNodeMessages(chosen, out, consumedRunIds);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
// Original (owner run) selected: fall through and emit M from O.
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
out.push(m);
|
|
568
|
+
}
|
|
215
569
|
}
|
|
216
570
|
|
|
217
571
|
hasOlder(): boolean {
|
|
218
|
-
return this._withheldBuffer.length > 0 || this._hasMoreHistory;
|
|
572
|
+
return this._hiddenMessageCount > 0 || this._withheldBuffer.length > 0 || this._hasMoreHistory;
|
|
219
573
|
}
|
|
220
574
|
|
|
221
|
-
|
|
575
|
+
/**
|
|
576
|
+
* Reveal `limit` more older codecMessages in this view — fewer only when
|
|
577
|
+
* channel history is exhausted.
|
|
578
|
+
*
|
|
579
|
+
* Internally runs are revealed WHOLE (run-granular withholding), counting
|
|
580
|
+
* codecMessages to decide how many runs to bring in, then the flat list
|
|
581
|
+
* returned by {@link getMessages} is trimmed to exactly `limit` more
|
|
582
|
+
* messages. So a run straddling the boundary still appears in {@link runs}
|
|
583
|
+
* (it's a revealed node) while only its newest messages show in
|
|
584
|
+
* `getMessages`. Live messages append at the newest end and are never
|
|
585
|
+
* trimmed.
|
|
586
|
+
* @param limit - Number of older codecMessages to reveal. Defaults to 10.
|
|
587
|
+
*/
|
|
588
|
+
async loadOlder(limit = 10): Promise<void> {
|
|
222
589
|
if (this._closed || this._loadingOlder) return;
|
|
223
590
|
this._loadingOlder = true;
|
|
224
591
|
this._logger.trace('DefaultView.loadOlder();', { limit });
|
|
225
592
|
|
|
226
593
|
try {
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
594
|
+
// Phase A: the boundary run is already revealed (a previous loadOlder
|
|
595
|
+
// pulled in a whole run that overshot the message limit); reveal more of
|
|
596
|
+
// its trimmed-off oldest messages without fetching or revealing new runs.
|
|
597
|
+
if (this._hiddenMessageCount >= limit) {
|
|
598
|
+
this._hiddenMessageCount -= limit;
|
|
599
|
+
this._recomputeAndEmit();
|
|
231
600
|
return;
|
|
232
601
|
}
|
|
233
602
|
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
603
|
+
// Phase B: reveal whole older runs covering the remaining message budget,
|
|
604
|
+
// then re-trim so exactly `limit` new messages surface. Runs are revealed
|
|
605
|
+
// whole (node granularity); the trim makes the message count exact.
|
|
606
|
+
const need = limit - this._hiddenMessageCount;
|
|
607
|
+
const before = this._extractMessages(this._computeFlatNodes()).length;
|
|
608
|
+
const revealedSoFar = (): number => this._extractMessages(this._computeFlatNodes()).length - before;
|
|
240
609
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
this.
|
|
246
|
-
return;
|
|
610
|
+
// Drain the withheld buffer toward `need` (whole older runs, newest-first).
|
|
611
|
+
if (this._withheldBuffer.length > 0) {
|
|
612
|
+
const splitIdx = this._messageTailSplitIndex(this._withheldBuffer, need);
|
|
613
|
+
const batch = this._withheldBuffer.splice(splitIdx);
|
|
614
|
+
this._releaseWithheld(batch);
|
|
247
615
|
}
|
|
248
616
|
|
|
249
|
-
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
617
|
+
// If the buffer was empty or fell short of `need` (e.g. it held a
|
|
618
|
+
// zero-message run), fetch channel history for the remainder. The fetch
|
|
619
|
+
// path loops over pages internally until it covers its target or history
|
|
620
|
+
// is exhausted, so a single call here suffices.
|
|
621
|
+
if (revealedSoFar() < need) {
|
|
622
|
+
await this._fetchOlder(need - revealedSoFar());
|
|
623
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be set during the await above
|
|
624
|
+
if (this._closed) return;
|
|
255
625
|
}
|
|
256
626
|
|
|
257
|
-
|
|
627
|
+
const after = this._extractMessages(this._computeFlatNodes()).length;
|
|
628
|
+
// `after - before` whole-run messages were added at the oldest end; show
|
|
629
|
+
// `limit` of them (newest), hiding the overshoot plus what was already
|
|
630
|
+
// trimmed. `<= 0` when history is exhausted before `limit` is reached.
|
|
631
|
+
this._hiddenMessageCount = Math.max(0, this._hiddenMessageCount + (after - before) - limit);
|
|
632
|
+
this._recomputeAndEmit();
|
|
258
633
|
} catch (error) {
|
|
259
634
|
this._logger.error('DefaultView.loadOlder(); failed', { error });
|
|
260
635
|
throw error;
|
|
@@ -263,48 +638,363 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
263
638
|
}
|
|
264
639
|
}
|
|
265
640
|
|
|
641
|
+
/**
|
|
642
|
+
* Fetch older channel history covering at least `target` more codecMessages,
|
|
643
|
+
* buffering the older nodes and revealing whole runs. The withheld buffer is
|
|
644
|
+
* assumed already drained by the caller. Loads the first page when no history
|
|
645
|
+
* has been fetched yet, otherwise advances to the next older page; the
|
|
646
|
+
* page-walk inside {@link _revealFromPage} loops until `target` messages are
|
|
647
|
+
* covered or history runs out. No-op (leaving `_hasMoreHistory` false) once
|
|
648
|
+
* channel history is exhausted.
|
|
649
|
+
* @param target - Minimum additional codecMessages this fetch aims to cover.
|
|
650
|
+
*/
|
|
651
|
+
private async _fetchOlder(target: number): Promise<void> {
|
|
652
|
+
if (!this._hasMoreHistory && !this._lastHistoryPage) {
|
|
653
|
+
await this._loadFirstPage(target);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (!this._hasMoreHistory) return;
|
|
658
|
+
|
|
659
|
+
if (!this._lastHistoryPage?.hasNext()) {
|
|
660
|
+
this._hasMoreHistory = false;
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const nextPage = await this._lastHistoryPage.next();
|
|
665
|
+
if (this._closed || !nextPage) {
|
|
666
|
+
if (!nextPage) this._hasMoreHistory = false;
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
await this._revealFromPage(nextPage, target);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Find the index in `nodes` (chronological, oldest-first) at which the newest
|
|
675
|
+
* whole runs covering at least `target` codecMessages begin. Walks newest-first
|
|
676
|
+
* summing each node's `codec.getMessages(projection)` count; once the running
|
|
677
|
+
* total reaches `target`, the current node (and everything newer) is the
|
|
678
|
+
* revealed batch — so whole runs are revealed and the batch may overshoot
|
|
679
|
+
* `target` (the caller trims). Returns `0` when the nodes hold fewer than
|
|
680
|
+
* `target` messages — reveal everything.
|
|
681
|
+
*
|
|
682
|
+
* Shared by the buffer-drain and history-fetch reveal paths so they agree on
|
|
683
|
+
* "covering `target` messages".
|
|
684
|
+
* @param nodes - Candidate nodes, oldest-first.
|
|
685
|
+
* @param target - Minimum codecMessages the revealed batch must cover.
|
|
686
|
+
* @returns The split index; `nodes[splitIdx..]` is the revealed batch.
|
|
687
|
+
*/
|
|
688
|
+
private _messageTailSplitIndex(nodes: ConversationNode<TProjection>[], target: number): number {
|
|
689
|
+
let messages = 0;
|
|
690
|
+
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
691
|
+
const node = nodes[i];
|
|
692
|
+
if (!node) continue;
|
|
693
|
+
messages += this._codec.getMessages(node.projection).length;
|
|
694
|
+
if (messages >= target) return i; // reveal nodes[i..]
|
|
695
|
+
}
|
|
696
|
+
return 0; // fewer than `target` messages — reveal everything
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// -------------------------------------------------------------------------
|
|
700
|
+
// Run lookup
|
|
701
|
+
// -------------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
runOf(codecMessageId: string): RunInfo | undefined {
|
|
704
|
+
this._logger.trace('DefaultView.runOf();', { codecMessageId });
|
|
705
|
+
const node = this._tree.getNodeByCodecMessageId(codecMessageId);
|
|
706
|
+
if (!node) return undefined;
|
|
707
|
+
if (node.kind === 'run') return _toRunInfo(node);
|
|
708
|
+
// Input node: resolve to its selected reply run (undefined if none started).
|
|
709
|
+
const reply = this._selectedReplyRun(node.codecMessageId);
|
|
710
|
+
return reply ? _toRunInfo(reply) : undefined;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Resolve the reply run currently selected for an input node, honouring the
|
|
715
|
+
* View's regenerate selection. Falls back to the latest reply run when no
|
|
716
|
+
* selection has been recorded; undefined when no reply run has started.
|
|
717
|
+
* @param inputCodecMessageId - The input node's codec-message-id.
|
|
718
|
+
* @returns The selected reply RunNode, or undefined.
|
|
719
|
+
*/
|
|
720
|
+
private _selectedReplyRun(inputCodecMessageId: string): RunNode<TProjection> | undefined {
|
|
721
|
+
const replies = this._tree.getReplyRuns(inputCodecMessageId);
|
|
722
|
+
if (replies.length === 0) return undefined;
|
|
723
|
+
if (replies.length === 1) return replies[0];
|
|
724
|
+
// Multiple reply runs = a regenerate group. Honour the View's selection
|
|
725
|
+
// (keyed by group root) else default to the latest.
|
|
726
|
+
const groupRoot = this._tree.getGroupRoot(replies[0]?.runId ?? '');
|
|
727
|
+
const sel = this._regenSelections.get(groupRoot);
|
|
728
|
+
const selectedKey = sel && sel.kind !== 'pending' ? sel.selectedRunId : undefined;
|
|
729
|
+
if (selectedKey !== undefined) {
|
|
730
|
+
const chosen = replies.find((r) => r.runId === selectedKey);
|
|
731
|
+
if (chosen) return chosen;
|
|
732
|
+
}
|
|
733
|
+
// Latest by startSerial; getReplyRuns is set-ordered, so sort defensively.
|
|
734
|
+
return replies.toSorted((a, b) => (a.startSerial ?? '').localeCompare(b.startSerial ?? '')).at(-1);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
run(runId: string): RunInfo | undefined {
|
|
738
|
+
this._logger.trace('DefaultView.run();', { runId });
|
|
739
|
+
const run = this._tree.getRunNode(runId);
|
|
740
|
+
return run ? _toRunInfo(run) : undefined;
|
|
741
|
+
}
|
|
742
|
+
|
|
266
743
|
// -------------------------------------------------------------------------
|
|
267
|
-
// Branch navigation
|
|
744
|
+
// Branch navigation (msg-anchored)
|
|
268
745
|
// -------------------------------------------------------------------------
|
|
269
746
|
|
|
270
|
-
// Spec: AIT-CT13c
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
747
|
+
// Spec: AIT-CT13c, AIT-CT13d — branch points are codec-message-id
|
|
748
|
+
// anchored. The View resolves the anchor (the user prompt for edits,
|
|
749
|
+
// the assistant slot for regens) and routes the selection to the
|
|
750
|
+
// appropriate internal selection map. Tree-level introspection
|
|
751
|
+
// (RunNode access, runId-keyed queries) remains on the {@link Tree}.
|
|
752
|
+
|
|
753
|
+
branchSelection(codecMessageId: string): BranchSelection<TMessage> {
|
|
754
|
+
const branch = this._resolveMessageBranchPoint(codecMessageId);
|
|
755
|
+
if (branch) {
|
|
756
|
+
// Each member contributes its representative message as the branch-arrow
|
|
757
|
+
// slot: for an edit fork that is the alternate user prompt; for a
|
|
758
|
+
// whole-reply regenerate group the variant's first message; for a non-head
|
|
759
|
+
// regenerate group the regenerate target (original) or the regenerator's
|
|
760
|
+
// first message.
|
|
761
|
+
const siblings = branch.members.flatMap((member) => {
|
|
762
|
+
const owner = this._tree.getNodeByCodecMessageId(member.representativeCodecMessageId);
|
|
763
|
+
if (!owner) return [];
|
|
764
|
+
const found = this._codec
|
|
765
|
+
.getMessages(owner.projection)
|
|
766
|
+
.find((m) => m.codecMessageId === member.representativeCodecMessageId);
|
|
767
|
+
return found ? [found.message] : [];
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
if (siblings.length > 0) {
|
|
771
|
+
const index = this._resolveSelectedIndex(branch);
|
|
772
|
+
const clamped = Math.max(0, Math.min(index, siblings.length - 1));
|
|
773
|
+
const selected = siblings[clamped];
|
|
774
|
+
return {
|
|
775
|
+
hasSiblings: siblings.length > 1,
|
|
776
|
+
siblings,
|
|
777
|
+
index: clamped,
|
|
778
|
+
selected,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Known non-anchor message: the bundle's invariant is that
|
|
784
|
+
// `siblings` contains the rendered message itself for any known
|
|
785
|
+
// codec-message-id, so plain bubbles get `siblings.length === 1`
|
|
786
|
+
// (not `0`) and the indexing space matches between read and write.
|
|
787
|
+
// Resolve the owning node kind-blind — a plain user prompt is an input
|
|
788
|
+
// node, an assistant message lives in a reply run; both carry a projection.
|
|
789
|
+
const owner = this._tree.getNodeByCodecMessageId(codecMessageId);
|
|
790
|
+
if (owner) {
|
|
791
|
+
const found = this._codec.getMessages(owner.projection).find((m) => m.codecMessageId === codecMessageId);
|
|
792
|
+
if (found !== undefined) {
|
|
793
|
+
return { hasSiblings: false, siblings: [found.message], index: 0, selected: found.message };
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Unknown id, or the owner Run is known but the codec doesn't surface
|
|
798
|
+
// a message with this id from the projection (e.g. an event-only fold
|
|
799
|
+
// such as a tool result that mutates an assistant in-place without
|
|
800
|
+
// exposing its own TMessage). Treat both as "no rendered message",
|
|
801
|
+
// returning the safe empty bundle.
|
|
802
|
+
return { hasSiblings: false, siblings: [], index: 0, selected: undefined };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Spec: AIT-CT13c, AIT-CT13d
|
|
806
|
+
selectSibling(codecMessageId: string, index: number): void {
|
|
807
|
+
this._logger.trace('DefaultView.selectSibling();', { codecMessageId, index });
|
|
808
|
+
const branch = this._resolveMessageBranchPoint(codecMessageId);
|
|
809
|
+
if (!branch) return;
|
|
810
|
+
const clamped = Math.max(0, Math.min(index, branch.members.length - 1));
|
|
811
|
+
const selected = branch.members[clamped];
|
|
278
812
|
if (!selected) return; // unreachable: clamped is always in bounds
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
813
|
+
if (branch.kind === 'fork-of') {
|
|
814
|
+
this._branchSelections.set(branch.groupRoot, { kind: 'user', selectedKey: selected.memberNodeKey });
|
|
815
|
+
this._logger.debug('DefaultView.selectSibling(); fork-of', {
|
|
816
|
+
codecMessageId,
|
|
817
|
+
index: clamped,
|
|
818
|
+
selectedKey: selected.memberNodeKey,
|
|
819
|
+
});
|
|
820
|
+
} else if (branch.kind === 'non-head-regen') {
|
|
821
|
+
// Non-head groups live outside the visibleNodes sibling space — store in
|
|
822
|
+
// the dedicated map the message-extraction substitution reads.
|
|
823
|
+
this._nonHeadRegenSelections.set(branch.groupRoot, { kind: 'user', selectedRunId: selected.memberNodeKey });
|
|
824
|
+
this._logger.debug('DefaultView.selectSibling(); non-head-regen', {
|
|
825
|
+
codecMessageId,
|
|
826
|
+
index: clamped,
|
|
827
|
+
selectedRunId: selected.memberNodeKey,
|
|
828
|
+
anchor: branch.groupRoot,
|
|
829
|
+
});
|
|
830
|
+
} else {
|
|
831
|
+
this._regenSelections.set(branch.groupRoot, { kind: 'user', selectedRunId: selected.memberNodeKey });
|
|
832
|
+
this._logger.debug('DefaultView.selectSibling(); regenerate', {
|
|
833
|
+
codecMessageId,
|
|
834
|
+
index: clamped,
|
|
835
|
+
selectedRunId: selected.memberNodeKey,
|
|
836
|
+
groupRoot: branch.groupRoot,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
this._recomputeAndEmit();
|
|
284
840
|
}
|
|
285
841
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (
|
|
295
|
-
|
|
842
|
+
/**
|
|
843
|
+
* Resolve the currently selected sibling's index inside a branch group.
|
|
844
|
+
* Pending selections fall back to the latest sibling. The caller clamps
|
|
845
|
+
* the returned index against any post-extraction filtering.
|
|
846
|
+
* @param branch - Resolved branch-point descriptor from `_resolveMessageBranchPoint`.
|
|
847
|
+
* @returns The selected sibling's index within `branch.siblings`.
|
|
848
|
+
*/
|
|
849
|
+
private _resolveSelectedIndex(branch: MessageBranchPoint): number {
|
|
850
|
+
if (branch.kind === 'fork-of') {
|
|
851
|
+
const sel = this._branchSelections.get(branch.groupRoot);
|
|
852
|
+
if (!sel) return branch.members.length - 1;
|
|
853
|
+
const idx = branch.members.findIndex((m) => m.memberNodeKey === sel.selectedKey);
|
|
854
|
+
return idx === -1 ? branch.members.length - 1 : idx;
|
|
855
|
+
}
|
|
856
|
+
const sel =
|
|
857
|
+
branch.kind === 'non-head-regen'
|
|
858
|
+
? this._nonHeadRegenSelections.get(branch.groupRoot)
|
|
859
|
+
: this._regenSelections.get(branch.groupRoot);
|
|
860
|
+
if (!sel || sel.kind === 'pending') return branch.members.length - 1;
|
|
861
|
+
const idx = branch.members.findIndex((m) => m.memberNodeKey === sel.selectedRunId);
|
|
862
|
+
return idx === -1 ? branch.members.length - 1 : idx;
|
|
296
863
|
}
|
|
297
864
|
|
|
298
|
-
|
|
299
|
-
|
|
865
|
+
/**
|
|
866
|
+
* Resolve the branch point anchored at `codecMessageId`, if any, returning the
|
|
867
|
+
* group `kind` + members + groupRoot so the caller routes to the correct
|
|
868
|
+
* selection map directly (not via a runId dispatch that would mis-route when
|
|
869
|
+
* the owning Run is in both a fork-of and a regen group).
|
|
870
|
+
* @param codecMessageId - The codec-message-id to look up.
|
|
871
|
+
* @returns The resolved branch point, or undefined when `codecMessageId`
|
|
872
|
+
* anchors no group.
|
|
873
|
+
*/
|
|
874
|
+
private _resolveMessageBranchPoint(codecMessageId: string): MessageBranchPoint | undefined {
|
|
875
|
+
const node = this._tree.getNodeByCodecMessageId(codecMessageId);
|
|
876
|
+
if (!node) return undefined;
|
|
877
|
+
|
|
878
|
+
// Edit-fork branch point: `codecMessageId` is a user INPUT node that has
|
|
879
|
+
// sibling input nodes (alternate prompts via fork-of). The anchor is the
|
|
880
|
+
// input node's own codec-message-id.
|
|
881
|
+
if (node.kind === 'input') {
|
|
882
|
+
const siblings = this._tree.getSiblingNodes(node.codecMessageId);
|
|
883
|
+
if (siblings.length > 1) {
|
|
884
|
+
return {
|
|
885
|
+
kind: 'fork-of',
|
|
886
|
+
groupRoot: this._tree.getGroupRoot(node.codecMessageId),
|
|
887
|
+
members: this._nodeHeadMembers(siblings),
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
return undefined;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Non-head regenerate branch point: `codecMessageId` is the rendered slot for
|
|
894
|
+
// a regenerate that replaced a non-head message inside a multi-message reply
|
|
895
|
+
// run. Resolved BEFORE the same-parent `regen` group below: several non-head
|
|
896
|
+
// regenerators of one anchor share a parent (the anchor's predecessor), so
|
|
897
|
+
// the Tree files them as their own sibling group excluding the owner run; the
|
|
898
|
+
// non-head resolver instead gathers the owner plus every regenerator into one
|
|
899
|
+
// anchor-keyed group.
|
|
900
|
+
const ownMessages = this._codec.getMessages(node.projection);
|
|
901
|
+
const nonHead = this._resolveNonHeadBranchPoint(node, ownMessages, codecMessageId);
|
|
902
|
+
if (nonHead) return nonHead;
|
|
903
|
+
|
|
904
|
+
// Regenerate branch point: `codecMessageId` is owned by a reply run that has
|
|
905
|
+
// sibling reply runs (the original reply + its regenerators, all parented at
|
|
906
|
+
// the same input node). Anchor on the head message of the run so arrows
|
|
907
|
+
// appear once per variant, not on every follow-up message.
|
|
908
|
+
const siblings = this._tree.getSiblingNodes(node.runId);
|
|
909
|
+
if (siblings.length > 1 && ownMessages.at(0)?.codecMessageId === codecMessageId) {
|
|
910
|
+
return {
|
|
911
|
+
kind: 'regen',
|
|
912
|
+
groupRoot: this._tree.getGroupRoot(node.runId),
|
|
913
|
+
members: this._nodeHeadMembers(siblings),
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return undefined;
|
|
300
918
|
}
|
|
301
919
|
|
|
302
|
-
|
|
303
|
-
|
|
920
|
+
/**
|
|
921
|
+
* Resolve a non-head regenerate branch point from a reply-run message, if any.
|
|
922
|
+
* `codecMessageId` is either (a) a non-head message `M` of its owner run with
|
|
923
|
+
* regenerators, or (b) a regenerator run's head; both resolve to the same group
|
|
924
|
+
* anchored at `M` (key matching {@link _nonHeadRegenSelections}).
|
|
925
|
+
* @param node - The reply run owning `codecMessageId`.
|
|
926
|
+
* @param ownMessages - That run's projected messages (already extracted).
|
|
927
|
+
* @param codecMessageId - The slot's codec-message-id (an `M`, or a regenerator head).
|
|
928
|
+
* @returns The non-head branch point, or undefined when `codecMessageId` anchors none.
|
|
929
|
+
*/
|
|
930
|
+
private _resolveNonHeadBranchPoint(
|
|
931
|
+
node: RunNode<TProjection>,
|
|
932
|
+
ownMessages: CodecMessage<TMessage>[],
|
|
933
|
+
codecMessageId: string,
|
|
934
|
+
): MessageBranchPoint | undefined {
|
|
935
|
+
// Case (b): `codecMessageId` is a regenerator run's head. Re-anchor on the
|
|
936
|
+
// message it regenerates and resolve from the owner run's perspective.
|
|
937
|
+
const isHead = ownMessages.at(0)?.codecMessageId === codecMessageId;
|
|
938
|
+
if (isHead && node.regeneratesCodecMessageId !== undefined) {
|
|
939
|
+
const anchorId = node.regeneratesCodecMessageId;
|
|
940
|
+
const owner = this._runByCodecMessageId(anchorId);
|
|
941
|
+
if (owner) {
|
|
942
|
+
const ownerMsgs = this._codec.getMessages(owner.projection);
|
|
943
|
+
const idx = ownerMsgs.findIndex((mm) => mm.codecMessageId === anchorId);
|
|
944
|
+
const predecessor = idx > 0 ? ownerMsgs[idx - 1]?.codecMessageId : undefined;
|
|
945
|
+
if (predecessor !== undefined) {
|
|
946
|
+
return this._buildNonHeadGroup(anchorId, owner.runId, predecessor);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return undefined;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Case (a): `codecMessageId` is a non-head message of its owner run.
|
|
953
|
+
const idx = ownMessages.findIndex((mm) => mm.codecMessageId === codecMessageId);
|
|
954
|
+
const predecessor = idx > 0 ? ownMessages[idx - 1]?.codecMessageId : undefined;
|
|
955
|
+
if (predecessor === undefined) return undefined;
|
|
956
|
+
return this._buildNonHeadGroup(codecMessageId, node.runId, predecessor);
|
|
304
957
|
}
|
|
305
958
|
|
|
306
|
-
|
|
307
|
-
|
|
959
|
+
/**
|
|
960
|
+
* Build the {@link MessageBranchPoint} for a non-head regenerate group, or
|
|
961
|
+
* undefined when the anchor has no regenerators. The owner member's
|
|
962
|
+
* representative is the anchor message (the regenerate target); each
|
|
963
|
+
* regenerator's is its head message.
|
|
964
|
+
* @param anchorCodecMessageId - The regenerate target's (non-head) message id.
|
|
965
|
+
* @param ownerRunId - The runId owning the regenerate target.
|
|
966
|
+
* @param predecessorCodecMessageId - The codec-message-id immediately before the anchor in the owner run.
|
|
967
|
+
* @returns The non-head branch point, or undefined when there are no regenerators.
|
|
968
|
+
*/
|
|
969
|
+
private _buildNonHeadGroup(
|
|
970
|
+
anchorCodecMessageId: string,
|
|
971
|
+
ownerRunId: string,
|
|
972
|
+
predecessorCodecMessageId: string,
|
|
973
|
+
): MessageBranchPoint | undefined {
|
|
974
|
+
const regenerators = this._nonHeadRegenerators(anchorCodecMessageId, predecessorCodecMessageId);
|
|
975
|
+
if (regenerators.length === 0) return undefined;
|
|
976
|
+
const members: BranchMember[] = [{ memberNodeKey: ownerRunId, representativeCodecMessageId: anchorCodecMessageId }];
|
|
977
|
+
for (const r of regenerators) {
|
|
978
|
+
const head = this._codec.getMessages(r.projection).at(0);
|
|
979
|
+
if (head) members.push({ memberNodeKey: r.runId, representativeCodecMessageId: head.codecMessageId });
|
|
980
|
+
}
|
|
981
|
+
return { kind: 'non-head-regen', groupRoot: anchorCodecMessageId, members };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Project nodes to {@link BranchMember}s for fork-of / whole-reply regen
|
|
986
|
+
* groups, where each member's branch-arrow representative is its own head
|
|
987
|
+
* message and its memberNodeKey is its node key.
|
|
988
|
+
* @param nodes - The sibling nodes.
|
|
989
|
+
* @returns One member per node that has a head message.
|
|
990
|
+
*/
|
|
991
|
+
private _nodeHeadMembers(nodes: ConversationNode<TProjection>[]): BranchMember[] {
|
|
992
|
+
const members: BranchMember[] = [];
|
|
993
|
+
for (const n of nodes) {
|
|
994
|
+
const head = this._codec.getMessages(n.projection).at(0);
|
|
995
|
+
if (head) members.push({ memberNodeKey: nodeKey(n), representativeCodecMessageId: head.codecMessageId });
|
|
996
|
+
}
|
|
997
|
+
return members;
|
|
308
998
|
}
|
|
309
999
|
|
|
310
1000
|
// -------------------------------------------------------------------------
|
|
@@ -312,159 +1002,237 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
312
1002
|
// -------------------------------------------------------------------------
|
|
313
1003
|
|
|
314
1004
|
// Spec: AIT-CT3, AIT-CT4
|
|
315
|
-
async send(input:
|
|
1005
|
+
async send(input: TInput | TInput[], options?: SendOptions): Promise<ActiveRun> {
|
|
316
1006
|
this._logger.trace('DefaultView.send();');
|
|
317
1007
|
if (this._closed) {
|
|
318
1008
|
throw new Ably.ErrorInfo('unable to send; view is closed', ErrorCode.InvalidArgument, 400);
|
|
319
1009
|
}
|
|
320
1010
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
1011
|
+
const normalised = _normaliseSend<TInput>(input);
|
|
1012
|
+
|
|
1013
|
+
// The codec-message-id of the visible branch tail — the delegate uses it
|
|
1014
|
+
// for auto-parent routing on fresh user messages.
|
|
1015
|
+
const parentCodecMessageId = this._lastVisibleMessagePairs.at(-1)?.codecMessageId;
|
|
1016
|
+
|
|
1017
|
+
const result = await this._sendDelegate(normalised, options, parentCodecMessageId);
|
|
1018
|
+
this._applyForkAutoSelect(result, options);
|
|
1019
|
+
return result;
|
|
1020
|
+
}
|
|
325
1021
|
|
|
1022
|
+
/**
|
|
1023
|
+
* Auto-select / pin branch selections after a forking send.
|
|
1024
|
+
* @param result - The ActiveRun returned by the delegate.
|
|
1025
|
+
* @param options - The SendOptions passed by the caller.
|
|
1026
|
+
*/
|
|
1027
|
+
private _applyForkAutoSelect(result: ActiveRun, options: SendOptions | undefined): void {
|
|
326
1028
|
// Spec: AIT-CT13e
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
1029
|
+
if (!options?.forkOf) return;
|
|
1030
|
+
|
|
1031
|
+
// An edit inserts a NEW user input node optimistically; its codec-message-id
|
|
1032
|
+
// is the (only) optimistic id and IS its node key. Edit forks are input-node
|
|
1033
|
+
// sibling groups, so the selection is keyed by the input group root and the
|
|
1034
|
+
// selected member is the new input node's key.
|
|
1035
|
+
const editedInputKey = result.optimisticCodecMessageIds.at(0);
|
|
1036
|
+
if (editedInputKey === undefined) return;
|
|
1037
|
+
const groupRoot = this._tree.getGroupRoot(editedInputKey);
|
|
1038
|
+
|
|
1039
|
+
this._branchSelections.set(groupRoot, { kind: 'auto', selectedKey: editedInputKey });
|
|
1040
|
+
this._recomputeAndEmit();
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Auto-select / pin the regenerate group anchored at `anchorCodecMessageId` so
|
|
1045
|
+
* the new Run's content appears as soon as the agent's run-start lands.
|
|
1046
|
+
*
|
|
1047
|
+
* `View.regenerate()` calls this with the assistant codec-message-id being
|
|
1048
|
+
* regenerated. The Run doesn't exist yet on the channel (the regenerate
|
|
1049
|
+
* wire is wire-only); the selection is recorded as `pending` and
|
|
1050
|
+
* promoted to `auto` by `_pinRegenSelections` once the corresponding
|
|
1051
|
+
* Run is created in the tree.
|
|
1052
|
+
* @param result - The ActiveRun returned by the delegate (run-id is the new regenerator's).
|
|
1053
|
+
* @param anchorCodecMessageId - The codec-message-id of the assistant being regenerated.
|
|
1054
|
+
*/
|
|
1055
|
+
private _applyRegenerateAutoSelect(result: ActiveRun, anchorCodecMessageId: string): void {
|
|
1056
|
+
// A regenerate produces a new reply run parented at the SAME input node as
|
|
1057
|
+
// the original reply (the regenerate group). The agent mints the run-id, so
|
|
1058
|
+
// we cannot pin by it synchronously. Resolve the group root from the
|
|
1059
|
+
// original reply run owning the anchor, and pin a pending selection keyed by
|
|
1060
|
+
// that group root, carrying the regenerate carrier's codec-message-id
|
|
1061
|
+
// (`result.inputCodecMessageId`) so we can promote when the new reply run lands.
|
|
1062
|
+
const anchorRun = this._runByCodecMessageId(anchorCodecMessageId);
|
|
1063
|
+
if (!anchorRun) return;
|
|
1064
|
+
|
|
1065
|
+
// Non-head regenerate: the anchor is a non-head message of its owner run, so
|
|
1066
|
+
// the new run won't be a same-parent sibling — it parents at the anchor's
|
|
1067
|
+
// predecessor. Defer in the dedicated non-head map (keyed by the anchor
|
|
1068
|
+
// message), not the sibling-group regen map.
|
|
1069
|
+
const anchorMsgs = this._codec.getMessages(anchorRun.projection);
|
|
1070
|
+
if (anchorMsgs.at(0)?.codecMessageId !== anchorCodecMessageId) {
|
|
1071
|
+
this._nonHeadRegenSelections.set(anchorCodecMessageId, {
|
|
1072
|
+
kind: 'pending',
|
|
1073
|
+
carrierCodecMessageId: result.inputCodecMessageId,
|
|
1074
|
+
});
|
|
1075
|
+
this._logger.debug('DefaultView._applyRegenerateAutoSelect(); deferring non-head regenerate selection', {
|
|
1076
|
+
anchorCodecMessageId,
|
|
1077
|
+
carrier: result.inputCodecMessageId,
|
|
1078
|
+
});
|
|
1079
|
+
this._resolvePendingNonHeadRegenSelections();
|
|
1080
|
+
this._recomputeAndEmitIfChanged();
|
|
1081
|
+
return;
|
|
367
1082
|
}
|
|
368
1083
|
|
|
369
|
-
|
|
1084
|
+
const groupRoot = this._tree.getGroupRoot(anchorRun.runId);
|
|
1085
|
+
|
|
1086
|
+
this._regenSelections.set(groupRoot, {
|
|
1087
|
+
kind: 'pending',
|
|
1088
|
+
carrierCodecMessageId: result.inputCodecMessageId,
|
|
1089
|
+
});
|
|
1090
|
+
this._logger.debug('DefaultView._applyRegenerateAutoSelect(); deferring regenerate selection', {
|
|
1091
|
+
anchorCodecMessageId,
|
|
1092
|
+
groupRoot,
|
|
1093
|
+
carrier: result.inputCodecMessageId,
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// The new reply run may already be in the tree (run-start raced ahead of the
|
|
1097
|
+
// sendDelegate resolution). Promote now and recompute so the visible set
|
|
1098
|
+
// catches up without waiting for the next structural change.
|
|
1099
|
+
this._resolvePendingRegenSelections();
|
|
1100
|
+
this._recomputeAndEmitIfChanged();
|
|
370
1101
|
}
|
|
371
1102
|
|
|
372
|
-
// Spec: AIT-CT5
|
|
373
|
-
async regenerate(messageId: string, options?: SendOptions): Promise<
|
|
1103
|
+
// Spec: AIT-CT5, AIT-CT13d
|
|
1104
|
+
async regenerate(messageId: string, options?: SendOptions): Promise<ActiveRun> {
|
|
374
1105
|
this._logger.trace('DefaultView.regenerate();', { messageId });
|
|
375
1106
|
|
|
376
|
-
|
|
377
|
-
|
|
1107
|
+
if (this._closed) {
|
|
1108
|
+
throw new Ably.ErrorInfo('unable to regenerate; view is closed', ErrorCode.InvalidArgument, 400);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// `messageId` is the assistant being regenerated. The new Run is a
|
|
1112
|
+
// continuation of the regenerated message's Run, not a fork: the
|
|
1113
|
+
// message-level replacement (new assistant supersedes the original)
|
|
1114
|
+
// happens at projection extraction time. We still resolve the parent
|
|
1115
|
+
// user prompt so the new assistant's wire `parent` is correct,
|
|
1116
|
+
// and we send the truncated history (through the parent inclusive)
|
|
1117
|
+
// so the LLM re-answers the right message.
|
|
1118
|
+
const targetRun = this._runByCodecMessageId(messageId);
|
|
1119
|
+
if (!targetRun) {
|
|
378
1120
|
throw new Ably.ErrorInfo(
|
|
379
1121
|
`unable to regenerate; message not found in tree: ${messageId}`,
|
|
380
1122
|
ErrorCode.InvalidArgument,
|
|
381
1123
|
400,
|
|
382
1124
|
);
|
|
383
1125
|
}
|
|
384
|
-
const
|
|
1126
|
+
const parentCodecMessageId = this._findParentMsgId(targetRun, messageId);
|
|
1127
|
+
if (!parentCodecMessageId) {
|
|
1128
|
+
throw new Ably.ErrorInfo(
|
|
1129
|
+
`unable to regenerate; parent user message not found for ${messageId}`,
|
|
1130
|
+
ErrorCode.InvalidArgument,
|
|
1131
|
+
400,
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
385
1134
|
|
|
386
|
-
|
|
1135
|
+
// Canonical regen anchor: when the user clicks Regenerate on an
|
|
1136
|
+
// already-regenerated assistant, the new alternative SHOULD belong
|
|
1137
|
+
// to the SAME branch point as the previous regen — but ONLY when
|
|
1138
|
+
// the target is the position-equivalent of the group anchor (the
|
|
1139
|
+
// head message of the regenerator Run). For a trailing follow-up
|
|
1140
|
+
// message inside a regenerator Run (e.g. the LLM text after the
|
|
1141
|
+
// regenerated tool call), the user expects the regen to anchor at
|
|
1142
|
+
// the specific message they clicked, not roll up to the group root.
|
|
1143
|
+
// Rebasing trailing regens to the group root produces a confusing
|
|
1144
|
+
// "N+1 / N+1" counter on the tool-call bubble and runs the whole
|
|
1145
|
+
// turn from scratch instead of just regenerating the text.
|
|
1146
|
+
let regenAnchorMsgId = messageId;
|
|
1147
|
+
if (targetRun.regeneratesCodecMessageId !== undefined) {
|
|
1148
|
+
const firstMsg = this._codec.getMessages(targetRun.projection).at(0);
|
|
1149
|
+
if (firstMsg?.codecMessageId === messageId) {
|
|
1150
|
+
regenAnchorMsgId = targetRun.regeneratesCodecMessageId;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const sendOptions: SendOptions = {
|
|
387
1155
|
...options,
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
1156
|
+
parent: parentCodecMessageId,
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
// Mint a regenerate input via the codec. The codec's well-known
|
|
1160
|
+
// `Regenerate` carries `target: regenAnchorMsgId` and `parent:
|
|
1161
|
+
// parentCodecMessageId`; the session reads those fields off the input
|
|
1162
|
+
// directly when building transport headers (`fork-of` and
|
|
1163
|
+
// `parent`). The agent's input-event lookup catches the wire signal;
|
|
1164
|
+
// no tree-upsert / projection fold runs locally.
|
|
1165
|
+
const regenerate = this._codec.createRegenerate(regenAnchorMsgId, parentCodecMessageId);
|
|
1166
|
+
const result = await this._sendDelegate([regenerate], sendOptions, parentCodecMessageId);
|
|
1167
|
+
this._applyRegenerateAutoSelect(result, regenAnchorMsgId);
|
|
1168
|
+
return result;
|
|
395
1169
|
}
|
|
396
1170
|
|
|
397
1171
|
// Spec: AIT-CT6
|
|
398
|
-
async edit(
|
|
399
|
-
messageId: string,
|
|
400
|
-
newMessages: TMessage | TMessage[],
|
|
401
|
-
options?: SendOptions,
|
|
402
|
-
): Promise<ActiveTurn<TEvent>> {
|
|
1172
|
+
async edit(messageId: string, inputs: TInput | TInput[], options?: SendOptions): Promise<ActiveRun> {
|
|
403
1173
|
this._logger.trace('DefaultView.edit();', { messageId });
|
|
404
1174
|
|
|
405
|
-
|
|
406
|
-
|
|
1175
|
+
if (this._closed) {
|
|
1176
|
+
throw new Ably.ErrorInfo('unable to edit; view is closed', ErrorCode.InvalidArgument, 400);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// The edit target is a user prompt — a run-less INPUT node — so resolve
|
|
1180
|
+
// it kind-blind, not via the reply-run-only lookup.
|
|
1181
|
+
const targetNode = this._tree.getNodeByCodecMessageId(messageId);
|
|
1182
|
+
if (!targetNode) {
|
|
407
1183
|
throw new Ably.ErrorInfo(
|
|
408
1184
|
`unable to edit; message not found in tree: ${messageId}`,
|
|
409
1185
|
ErrorCode.InvalidArgument,
|
|
410
1186
|
400,
|
|
411
1187
|
);
|
|
412
1188
|
}
|
|
413
|
-
const
|
|
1189
|
+
const parentCodecMessageId = this._findParentMsgId(targetNode, messageId);
|
|
414
1190
|
|
|
415
|
-
return this.send(
|
|
1191
|
+
return this.send(inputs, {
|
|
416
1192
|
...options,
|
|
417
|
-
body: {
|
|
418
|
-
history: this._getHistoryBefore(messageId),
|
|
419
|
-
...options?.body,
|
|
420
|
-
},
|
|
421
1193
|
forkOf: messageId,
|
|
422
|
-
parent:
|
|
1194
|
+
parent: parentCodecMessageId,
|
|
423
1195
|
});
|
|
424
1196
|
}
|
|
425
1197
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
1198
|
+
/**
|
|
1199
|
+
* Find the codec-message-id of the message immediately preceding `targetMsgId` in
|
|
1200
|
+
* the visible conversation.
|
|
1201
|
+
*
|
|
1202
|
+
* Consults the View's visible message chain first so message-level
|
|
1203
|
+
* replacements (regenerate) are respected: regenerating an
|
|
1204
|
+
* already-regenerated assistant lands the predecessor on the user
|
|
1205
|
+
* prompt the regen is responding to, NOT on the hidden original
|
|
1206
|
+
* assistant that occupies the same conversation slot. Falls back to a
|
|
1207
|
+
* projection-walk for the rare case where `targetMsgId` isn't on the
|
|
1208
|
+
* visible chain (e.g. caller is operating on a Run that's selection-
|
|
1209
|
+
* hidden by the current branch).
|
|
1210
|
+
* @param targetNode - The node (input node or reply run) that owns `targetMsgId`.
|
|
1211
|
+
* @param targetMsgId - The codec-message-id to find the parent of.
|
|
1212
|
+
* @returns The parent codec-message-id, or undefined if no predecessor exists.
|
|
1213
|
+
*/
|
|
1214
|
+
private _findParentMsgId(targetNode: ConversationNode<TProjection>, targetMsgId: string): string | undefined {
|
|
1215
|
+
const visible = this._lastVisibleMessagePairs;
|
|
1216
|
+
const visIdx = visible.findIndex((m) => m.codecMessageId === targetMsgId);
|
|
1217
|
+
if (visIdx > 0) {
|
|
1218
|
+
return visible[visIdx - 1]?.codecMessageId;
|
|
429
1219
|
}
|
|
430
|
-
|
|
431
|
-
const eventNodes: EventsNode<TEvent>[] = [{ kind: 'event', msgId, events }];
|
|
432
|
-
return this._sendDelegate([], options, this.flattenNodes(), eventNodes);
|
|
433
|
-
}
|
|
1220
|
+
if (visIdx === 0) return undefined;
|
|
434
1221
|
|
|
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;
|
|
1222
|
+
const messages = this._codec.getMessages(targetNode.projection);
|
|
1223
|
+
const idx = messages.findIndex((m) => m.codecMessageId === targetMsgId);
|
|
1224
|
+
if (idx > 0) {
|
|
1225
|
+
return messages[idx - 1]?.codecMessageId;
|
|
444
1226
|
}
|
|
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);
|
|
1227
|
+
if (idx === 0 && targetNode.parentCodecMessageId !== undefined) {
|
|
1228
|
+
// The structural predecessor is the node owning parentCodecMessageId
|
|
1229
|
+
// (an input node, or a prior reply run). Its tail message is the parent.
|
|
1230
|
+
const parentNode = this._tree.getNodeByCodecMessageId(targetNode.parentCodecMessageId);
|
|
1231
|
+
if (parentNode) {
|
|
1232
|
+
return this._codec.getMessages(parentNode.projection).at(-1)?.codecMessageId;
|
|
464
1233
|
}
|
|
465
|
-
if (filtered.size > 0) result.set(clientId, filtered);
|
|
466
1234
|
}
|
|
467
|
-
return
|
|
1235
|
+
return undefined;
|
|
468
1236
|
}
|
|
469
1237
|
|
|
470
1238
|
// -------------------------------------------------------------------------
|
|
@@ -474,10 +1242,10 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
474
1242
|
// Spec: AIT-CT8a, AIT-CT8b, AIT-CT8e
|
|
475
1243
|
on(event: 'update', handler: () => void): () => void;
|
|
476
1244
|
on(event: 'ably-message', handler: (msg: Ably.InboundMessage) => void): () => void;
|
|
477
|
-
on(event: '
|
|
1245
|
+
on(event: 'run', handler: (event: RunLifecycleEvent) => void): () => void;
|
|
478
1246
|
on(
|
|
479
|
-
event: 'update' | 'ably-message' | '
|
|
480
|
-
handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event:
|
|
1247
|
+
event: 'update' | 'ably-message' | 'run',
|
|
1248
|
+
handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event: RunLifecycleEvent) => void),
|
|
481
1249
|
): () => void {
|
|
482
1250
|
// CAST: overload signatures enforce correct handler types per event name.
|
|
483
1251
|
const cb = handler as (arg: ViewEventsMap[keyof ViewEventsMap]) => void;
|
|
@@ -491,10 +1259,8 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
491
1259
|
// Lifecycle
|
|
492
1260
|
// -------------------------------------------------------------------------
|
|
493
1261
|
|
|
494
|
-
/**
|
|
495
|
-
* Tear down the view — unsubscribe from tree events.
|
|
496
|
-
*/
|
|
497
1262
|
close(): void {
|
|
1263
|
+
if (this._closed) return;
|
|
498
1264
|
this._logger.info('DefaultView.close();');
|
|
499
1265
|
this._closed = true;
|
|
500
1266
|
this._loadingOlder = false;
|
|
@@ -502,8 +1268,11 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
502
1268
|
this._unsubs.length = 0;
|
|
503
1269
|
this._emitter.off();
|
|
504
1270
|
this._branchSelections.clear();
|
|
505
|
-
this.
|
|
1271
|
+
this._regenSelections.clear();
|
|
1272
|
+
this._nonHeadRegenSelections.clear();
|
|
1273
|
+
this._withheldRunIds.clear();
|
|
506
1274
|
this._withheldBuffer.length = 0;
|
|
1275
|
+
this._hiddenMessageCount = 0;
|
|
507
1276
|
this._onClose?.();
|
|
508
1277
|
}
|
|
509
1278
|
|
|
@@ -511,63 +1280,73 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
511
1280
|
// Private: history loading
|
|
512
1281
|
// -------------------------------------------------------------------------
|
|
513
1282
|
|
|
514
|
-
private async _loadFirstPage(
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
const firstPage = await decodeHistory(this._channel, this._codec, { limit }, this._logger);
|
|
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
|
|
1283
|
+
private async _loadFirstPage(target: number): Promise<void> {
|
|
1284
|
+
// `loadHistory`'s limit and this view's reveal target both count complete
|
|
1285
|
+
// domain messages (codecMessages), so the target passes straight through.
|
|
1286
|
+
const firstPage = await loadHistory(this._channel, { limit: target }, this._logger);
|
|
522
1287
|
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);
|
|
1288
|
+
await this._revealFromPage(firstPage, target);
|
|
538
1289
|
}
|
|
539
1290
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
1291
|
+
/**
|
|
1292
|
+
* Walk channel history from `page` until the newly-observed nodes hold at
|
|
1293
|
+
* least `target` codecMessages (or the channel is exhausted), then reveal the
|
|
1294
|
+
* newest whole runs covering `target` and withhold the rest. Snapshots the
|
|
1295
|
+
* already-visible nodes up front so only newly-observed nodes count toward
|
|
1296
|
+
* `target`. No-op if the view closed during the page walk.
|
|
1297
|
+
* @param page - The decoded history page to start from.
|
|
1298
|
+
* @param target - Minimum codecMessages to reveal in this batch.
|
|
1299
|
+
*/
|
|
1300
|
+
private async _revealFromPage(page: HistoryPage, target: number): Promise<void> {
|
|
1301
|
+
// Snapshot before loading: every node already in the tree stays visible.
|
|
1302
|
+
const beforeRunIds = new Set(this._treeVisibleNodes().map((n) => nodeKey(n)));
|
|
543
1303
|
|
|
544
|
-
const { newVisible, lastPage } = await this._loadUntilVisible(page,
|
|
1304
|
+
const { newVisible, lastPage } = await this._loadUntilVisible(page, target, beforeRunIds);
|
|
545
1305
|
if (this._closed) return;
|
|
546
1306
|
this._lastHistoryPage = lastPage;
|
|
547
1307
|
this._hasMoreHistory = lastPage.hasNext();
|
|
1308
|
+
this._splitReveal(newVisible, target);
|
|
1309
|
+
}
|
|
548
1310
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
1311
|
+
/**
|
|
1312
|
+
* Reveal the newest whole runs covering `target` codecMessages from
|
|
1313
|
+
* `newVisible` and withhold the rest so subsequent `loadOlder` calls can
|
|
1314
|
+
* drain them. Reveal granularity is the whole run; the caller trims the flat
|
|
1315
|
+
* message list (via `_hiddenMessageCount`) to make the visible message count
|
|
1316
|
+
* exact. Called by {@link _revealFromPage}.
|
|
1317
|
+
* @param newVisible - Newly observed nodes (inputs + reply runs) from the history fetch, chronological.
|
|
1318
|
+
* @param target - Minimum codecMessages the revealed batch must cover.
|
|
1319
|
+
*/
|
|
1320
|
+
private _splitReveal(newVisible: ConversationNode<TProjection>[], target: number): void {
|
|
1321
|
+
const splitIdx = this._messageTailSplitIndex(newVisible, target);
|
|
1322
|
+
const batch = newVisible.slice(splitIdx);
|
|
1323
|
+
const withheld = newVisible.slice(0, splitIdx);
|
|
555
1324
|
for (const n of withheld) {
|
|
556
|
-
this.
|
|
1325
|
+
this._withheldRunIds.add(nodeKey(n));
|
|
557
1326
|
}
|
|
558
1327
|
this._withheldBuffer.push(...withheld);
|
|
559
1328
|
this._releaseWithheld(batch);
|
|
560
1329
|
}
|
|
561
1330
|
|
|
562
|
-
|
|
1331
|
+
/**
|
|
1332
|
+
* Replay a history page's raw messages into the Tree through the Tree's
|
|
1333
|
+
* single decode-and-apply engine — the same applier (and decoder instance)
|
|
1334
|
+
* the client's live loop uses, so history replay can't drift from it. The
|
|
1335
|
+
* shared decoder's version-guarded trackers make the overlap between the
|
|
1336
|
+
* two routes safe: an in-flight stream that spans the attach boundary is
|
|
1337
|
+
* continued rather than re-started, and content the live route already
|
|
1338
|
+
* incorporated decodes to nothing.
|
|
1339
|
+
* @param page - The history page returned by `loadHistory`.
|
|
1340
|
+
*/
|
|
1341
|
+
private _processHistoryPage(page: HistoryPage): void {
|
|
563
1342
|
this._processingHistory = true;
|
|
564
1343
|
try {
|
|
565
|
-
for (const
|
|
566
|
-
|
|
567
|
-
if (!msgId) continue;
|
|
568
|
-
this._tree.upsert(msgId, item.message, item.headers, item.serial);
|
|
1344
|
+
for (const rawMsg of page.rawMessages) {
|
|
1345
|
+
this._applier.apply(rawMsg);
|
|
569
1346
|
}
|
|
570
1347
|
|
|
1348
|
+
// Emit ably-message in a batch AFTER the whole page is applied, so a
|
|
1349
|
+
// subscriber resolving the owning Run sees the fully-rebuilt tree.
|
|
571
1350
|
for (const msg of page.rawMessages) {
|
|
572
1351
|
this._tree.emitAblyMessage(msg);
|
|
573
1352
|
}
|
|
@@ -577,17 +1356,19 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
577
1356
|
}
|
|
578
1357
|
|
|
579
1358
|
private async _loadUntilVisible(
|
|
580
|
-
firstPage: HistoryPage
|
|
1359
|
+
firstPage: HistoryPage,
|
|
581
1360
|
target: number,
|
|
582
|
-
|
|
583
|
-
): Promise<{ newVisible:
|
|
1361
|
+
beforeRunIds: Set<string>,
|
|
1362
|
+
): Promise<{ newVisible: ConversationNode<TProjection>[]; lastPage: HistoryPage }> {
|
|
584
1363
|
this._processHistoryPage(firstPage);
|
|
585
1364
|
let page = firstPage;
|
|
586
1365
|
|
|
587
1366
|
const newVisibleCount = (): number => {
|
|
588
1367
|
let count = 0;
|
|
589
|
-
for (const n of this.
|
|
590
|
-
|
|
1368
|
+
for (const n of this._treeVisibleNodes()) {
|
|
1369
|
+
// Count newly-visible codecMessages toward the target (whole runs are
|
|
1370
|
+
// revealed; the caller trims to the exact message count).
|
|
1371
|
+
if (!beforeRunIds.has(nodeKey(n))) count += this._codec.getMessages(n.projection).length;
|
|
591
1372
|
}
|
|
592
1373
|
return count;
|
|
593
1374
|
};
|
|
@@ -599,19 +1380,17 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
599
1380
|
page = nextPage;
|
|
600
1381
|
}
|
|
601
1382
|
|
|
602
|
-
const newVisible = this.
|
|
1383
|
+
const newVisible = this._treeVisibleNodes().filter((n) => !beforeRunIds.has(nodeKey(n)));
|
|
603
1384
|
return { newVisible, lastPage: page };
|
|
604
1385
|
}
|
|
605
1386
|
|
|
606
1387
|
// Spec: AIT-CT11a
|
|
607
|
-
private _releaseWithheld(nodes:
|
|
1388
|
+
private _releaseWithheld(nodes: ConversationNode<TProjection>[]): void {
|
|
608
1389
|
for (const n of nodes) {
|
|
609
|
-
this.
|
|
1390
|
+
this._withheldRunIds.delete(nodeKey(n));
|
|
610
1391
|
}
|
|
611
1392
|
if (nodes.length > 0) {
|
|
612
|
-
this.
|
|
613
|
-
this._updateVisibleSnapshot(this._cachedNodes);
|
|
614
|
-
this._emitter.emit('update');
|
|
1393
|
+
this._recomputeAndEmit();
|
|
615
1394
|
}
|
|
616
1395
|
}
|
|
617
1396
|
|
|
@@ -619,209 +1398,208 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
619
1398
|
// Private: scoped event forwarding
|
|
620
1399
|
// -------------------------------------------------------------------------
|
|
621
1400
|
|
|
622
|
-
private _updateVisibleSnapshot(nodes?:
|
|
623
|
-
const resolved = nodes ?? this.
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
this.
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
1401
|
+
private _updateVisibleSnapshot(nodes?: ConversationNode<TProjection>[]): void {
|
|
1402
|
+
const resolved = nodes ?? this._cachedNodes;
|
|
1403
|
+
// Identity key = nodeKey (runId for reply runs, codecMessageId for inputs),
|
|
1404
|
+
// so the visible set scopes events for both kinds and input-node parents.
|
|
1405
|
+
this._lastVisibleNodeKeys = resolved.map((n) => nodeKey(n));
|
|
1406
|
+
this._lastVisibleNodeKeySet = new Set(this._lastVisibleNodeKeys);
|
|
1407
|
+
this._lastVisibleProjections = resolved.map((n) => n.projection);
|
|
1408
|
+
// Run-level reveal, message-level trim: drop the oldest `_hiddenMessageCount`
|
|
1409
|
+
// messages so a `loadOlder` page lands on exactly `limit` messages even
|
|
1410
|
+
// though whole runs were revealed.
|
|
1411
|
+
this._lastVisibleMessagePairs = this._extractMessages(resolved).slice(this._hiddenMessageCount);
|
|
631
1412
|
}
|
|
632
1413
|
|
|
633
1414
|
private _onTreeUpdate(): void {
|
|
634
1415
|
// 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.
|
|
1416
|
+
// _processHistoryPage, each tree.applyMessage() fires this handler
|
|
1417
|
+
// synchronously — but _withheldRunIds hasn't been populated yet, so
|
|
1418
|
+
// _computeFlatNodes() would return unfiltered history. Without this guard,
|
|
1419
|
+
// subscribers briefly see all history Runs before the pagination window
|
|
1420
|
+
// is applied. The final update is emitted by _releaseWithheld after
|
|
1421
|
+
// withholding is set up.
|
|
642
1422
|
if (this._processingHistory) return;
|
|
643
1423
|
|
|
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;
|
|
1424
|
+
// The Tree emits `update` only on structural change (new/removed Run,
|
|
1425
|
+
// sort-reorder, startSerial promotion, run-start backfill), so every
|
|
1426
|
+
// update reaching here warrants a full re-walk. Content-only folds flow
|
|
1427
|
+
// through `output` (_onTreeOutput) instead.
|
|
664
1428
|
|
|
665
|
-
// Pin selections for previously-visible
|
|
1429
|
+
// Pin selections for previously-visible Runs that now have siblings.
|
|
666
1430
|
// This prevents new forks (from other views' edits/regenerates) from
|
|
667
1431
|
// shifting this view to a branch the user didn't navigate to.
|
|
668
1432
|
this._pinBranchSelections();
|
|
669
|
-
this.
|
|
1433
|
+
this._resolvePendingRegenSelections();
|
|
1434
|
+
this._resolvePendingNonHeadRegenSelections();
|
|
670
1435
|
|
|
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
|
-
}
|
|
1436
|
+
this._recomputeAndEmitIfChanged();
|
|
679
1437
|
}
|
|
680
1438
|
|
|
681
1439
|
/**
|
|
682
|
-
* Build
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
1440
|
+
* Build the unified selection map the Tree's `visibleNodes` consumes:
|
|
1441
|
+
* `groupRootKey -> selectedKey`, covering both edit forks (input-node groups,
|
|
1442
|
+
* keyed by the input group root) and regenerate groups (reply-run groups,
|
|
1443
|
+
* keyed by the original reply's group root). Pending entries (no chosen
|
|
1444
|
+
* member yet) are omitted so the Tree falls back to the latest sibling.
|
|
1445
|
+
* @returns The merged group-root → selected-key map.
|
|
686
1446
|
*/
|
|
687
1447
|
private _resolveSelections(): Map<string, string> {
|
|
688
1448
|
const resolved = new Map<string, string>();
|
|
689
1449
|
for (const [groupRoot, sel] of this._branchSelections) {
|
|
1450
|
+
resolved.set(groupRoot, sel.selectedKey);
|
|
1451
|
+
}
|
|
1452
|
+
for (const [groupRoot, sel] of this._regenSelections) {
|
|
690
1453
|
if (sel.kind === 'pending') continue;
|
|
691
|
-
resolved.set(groupRoot, sel.
|
|
1454
|
+
resolved.set(groupRoot, sel.selectedRunId);
|
|
692
1455
|
}
|
|
693
1456
|
return resolved;
|
|
694
1457
|
}
|
|
695
1458
|
|
|
696
1459
|
/**
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
1460
|
+
* The Tree's visible node chain under this view's current selections — the
|
|
1461
|
+
* reachable, sibling-resolved nodes before the View's pagination window is
|
|
1462
|
+
* applied.
|
|
1463
|
+
* @returns The selection-resolved visible node chain.
|
|
1464
|
+
*/
|
|
1465
|
+
private _treeVisibleNodes(): ConversationNode<TProjection>[] {
|
|
1466
|
+
return this._tree.visibleNodes(this._resolveSelections());
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* For each previously-visible Run that now has siblings but no explicit
|
|
1471
|
+
* selection, pin the selection to that Run's runId. This preserves the
|
|
1472
|
+
* current branch when new forks appear from other views or external
|
|
1473
|
+
* sources.
|
|
701
1474
|
*
|
|
702
1475
|
* 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.
|
|
1476
|
+
* `pending` BranchSelection), select the newest sibling (the awaited Run)
|
|
1477
|
+
* instead of pinning the old one.
|
|
706
1478
|
*/
|
|
707
1479
|
private _pinBranchSelections(): void {
|
|
708
|
-
for (const
|
|
709
|
-
|
|
710
|
-
|
|
1480
|
+
for (const key of this._lastVisibleNodeKeys) {
|
|
1481
|
+
const node = this._tree.getNode(key);
|
|
1482
|
+
// Edit forks are INPUT-node sibling groups; only input nodes pin here.
|
|
1483
|
+
// Regenerate (reply-run) groups roll forward via _resolvePendingRegenSelections.
|
|
1484
|
+
if (node?.kind !== 'input') continue;
|
|
1485
|
+
const siblings = this._tree.getSiblingNodes(key);
|
|
1486
|
+
if (siblings.length <= 1) continue;
|
|
1487
|
+
const groupRoot = this._tree.getGroupRoot(key);
|
|
711
1488
|
const existing = this._branchSelections.get(groupRoot);
|
|
712
1489
|
|
|
713
|
-
// Spec: AIT-
|
|
714
|
-
//
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
}
|
|
1490
|
+
// Spec: AIT-CT13f — external edit fork: pin to the currently-visible
|
|
1491
|
+
// sibling so a fork from another view doesn't drift this view's branch.
|
|
1492
|
+
if (existing) continue;
|
|
1493
|
+
this._branchSelections.set(groupRoot, { kind: 'pinned', selectedKey: key });
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
734
1496
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
1497
|
+
/**
|
|
1498
|
+
* Roll `pending` and `auto` regenerate selections forward to the newest
|
|
1499
|
+
* group member. A regenerate slot defaults to the latest member, so each
|
|
1500
|
+
* new regenerator (this view's awaited run, or an external one) auto-rolls
|
|
1501
|
+
* the slot forward — UNLESS the user explicitly selected an earlier member
|
|
1502
|
+
* (`user`), which pins and is left untouched. The agent mints the run-id, so
|
|
1503
|
+
* we can't match the awaited run by id — once the group grows we adopt the
|
|
1504
|
+
* newest as the selected member.
|
|
1505
|
+
*/
|
|
1506
|
+
private _resolvePendingRegenSelections(): void {
|
|
1507
|
+
for (const [groupRoot, sel] of this._regenSelections) {
|
|
1508
|
+
if (sel.kind === 'user') continue;
|
|
1509
|
+
const group = this._tree.getSiblingNodes(groupRoot).filter((n): n is RunNode<TProjection> => n.kind === 'run');
|
|
1510
|
+
if (group.length <= 1) continue;
|
|
1511
|
+
const newest = group.at(-1);
|
|
1512
|
+
if (!newest) continue;
|
|
1513
|
+
this._regenSelections.set(groupRoot, { kind: 'auto', selectedRunId: newest.runId });
|
|
739
1514
|
}
|
|
740
1515
|
}
|
|
741
1516
|
|
|
742
1517
|
/**
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
1518
|
+
* Roll `pending` and `auto` non-head regenerate selections forward to the
|
|
1519
|
+
* newest regenerator of their anchor message. Mirrors
|
|
1520
|
+
* {@link _resolvePendingRegenSelections} for the non-head group, which lives in
|
|
1521
|
+
* a separate selection map (anchored by the regenerate target rather than a
|
|
1522
|
+
* sibling-group root): a `user` selection pins and is left untouched; a
|
|
1523
|
+
* `pending`/`auto` slot adopts the newest regenerator once one lands. The
|
|
1524
|
+
* anchor's predecessor — the key the regenerators file under — is recovered
|
|
1525
|
+
* from the owning run's projection.
|
|
747
1526
|
*/
|
|
748
|
-
private
|
|
749
|
-
for (const [
|
|
750
|
-
if (sel.kind
|
|
751
|
-
const
|
|
752
|
-
if (
|
|
753
|
-
const
|
|
754
|
-
|
|
755
|
-
const
|
|
756
|
-
if (
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
turnId: sel.turnId,
|
|
761
|
-
});
|
|
762
|
-
this._branchSelections.set(groupRoot, { kind: 'auto', selectedId: newest.msgId });
|
|
763
|
-
}
|
|
1527
|
+
private _resolvePendingNonHeadRegenSelections(): void {
|
|
1528
|
+
for (const [anchorId, sel] of this._nonHeadRegenSelections) {
|
|
1529
|
+
if (sel.kind === 'user') continue;
|
|
1530
|
+
const owner = this._runByCodecMessageId(anchorId);
|
|
1531
|
+
if (!owner) continue;
|
|
1532
|
+
const ownerMsgs = this._codec.getMessages(owner.projection);
|
|
1533
|
+
const idx = ownerMsgs.findIndex((m) => m.codecMessageId === anchorId);
|
|
1534
|
+
const predecessor = idx > 0 ? ownerMsgs[idx - 1]?.codecMessageId : undefined;
|
|
1535
|
+
if (predecessor === undefined) continue;
|
|
1536
|
+
const newest = this._nonHeadRegenerators(anchorId, predecessor).at(-1);
|
|
1537
|
+
if (!newest) continue;
|
|
1538
|
+
this._nonHeadRegenSelections.set(anchorId, { kind: 'auto', selectedRunId: newest.runId });
|
|
764
1539
|
}
|
|
765
1540
|
}
|
|
766
1541
|
|
|
767
1542
|
private _onTreeAblyMessage(msg: Ably.InboundMessage): void {
|
|
768
|
-
// Re-emit only if the message corresponds to a visible
|
|
769
|
-
const headers =
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
1543
|
+
// Re-emit only if the message corresponds to a visible Run
|
|
1544
|
+
const headers = getTransportHeaders(msg);
|
|
1545
|
+
const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
|
|
1546
|
+
const runId = headers[HEADER_RUN_ID];
|
|
1547
|
+
|
|
1548
|
+
if (!codecMessageId && !runId) {
|
|
1549
|
+
// Lifecycle / control events with no run/message identity (cancel, error)
|
|
1550
|
+
// are always forwarded.
|
|
773
1551
|
this._emitter.emit('ably-message', msg);
|
|
774
1552
|
return;
|
|
775
1553
|
}
|
|
776
|
-
|
|
777
|
-
if (this.
|
|
1554
|
+
|
|
1555
|
+
if (runId && this._lastVisibleNodeKeySet.has(runId)) {
|
|
778
1556
|
this._emitter.emit('ably-message', msg);
|
|
779
1557
|
}
|
|
780
1558
|
}
|
|
781
1559
|
|
|
782
|
-
private
|
|
783
|
-
// Check if
|
|
784
|
-
if (this.
|
|
785
|
-
this._emitter.emit('
|
|
1560
|
+
private _onTreeRun(event: RunLifecycleEvent): void {
|
|
1561
|
+
// Check if the run is already on the visible branch.
|
|
1562
|
+
if (this._lastVisibleNodeKeySet.has(event.runId)) {
|
|
1563
|
+
this._emitter.emit('run', event);
|
|
786
1564
|
return;
|
|
787
1565
|
}
|
|
788
1566
|
|
|
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);
|
|
1567
|
+
// For run-start, use branch metadata to predict visibility before
|
|
1568
|
+
// messages arrive. Own runs have optimistic inserts (caught above).
|
|
1569
|
+
// Remote runs carry parent/forkOf from the agent.
|
|
1570
|
+
if (event.type === 'start' && this._isRunStartVisible(event)) {
|
|
1571
|
+
this._lastVisibleNodeKeySet.add(event.runId);
|
|
1572
|
+
this._emitter.emit('run', event);
|
|
797
1573
|
}
|
|
798
1574
|
}
|
|
799
1575
|
|
|
800
1576
|
/**
|
|
801
|
-
* Predict whether a
|
|
802
|
-
* using the parent/forkOf metadata from the event.
|
|
803
|
-
* @param event - The
|
|
804
|
-
* @returns True if the
|
|
1577
|
+
* Predict whether a run-start's messages will be visible on this view's
|
|
1578
|
+
* branch using the parent/forkOf metadata from the event.
|
|
1579
|
+
* @param event - The run-start lifecycle event.
|
|
1580
|
+
* @returns True if the run is expected to be visible on this view's branch.
|
|
805
1581
|
*/
|
|
806
|
-
private
|
|
1582
|
+
private _isRunStartVisible(event: RunLifecycleEvent & { type: 'start' }): boolean {
|
|
807
1583
|
const { parent } = event;
|
|
808
1584
|
|
|
809
1585
|
// No parent metadata — can't determine branch, forward as default.
|
|
810
|
-
// This covers root turns (parent omitted) and backward compat.
|
|
811
1586
|
if (parent === undefined) return true;
|
|
812
1587
|
|
|
813
|
-
//
|
|
814
|
-
|
|
1588
|
+
// The wire `parent` is a codec-message-id (the prior message). Resolve it
|
|
1589
|
+
// kind-blind to its owning NODE — an input node (the user prompt this run
|
|
1590
|
+
// replies to) or a prior reply run — and check that node's key against the
|
|
1591
|
+
// visible set. Input-node keys are populated into the set by
|
|
1592
|
+
// _updateVisibleSnapshot.
|
|
1593
|
+
const parentNode = this._tree.getNodeByCodecMessageId(parent);
|
|
1594
|
+
if (!parentNode) return true; // unknown parent: forward conservatively
|
|
1595
|
+
return this._lastVisibleNodeKeySet.has(nodeKey(parentNode));
|
|
815
1596
|
}
|
|
816
1597
|
|
|
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;
|
|
1598
|
+
private _visibleChanged(newNodes: ConversationNode<TProjection>[]): boolean {
|
|
1599
|
+
if (newNodes.length !== this._lastVisibleNodeKeys.length) return true;
|
|
1600
|
+
for (const [i, node] of newNodes.entries()) {
|
|
1601
|
+
if (nodeKey(node) !== this._lastVisibleNodeKeys[i]) return true;
|
|
1602
|
+
if (node.projection !== this._lastVisibleProjections[i]) return true;
|
|
825
1603
|
}
|
|
826
1604
|
return false;
|
|
827
1605
|
}
|
|
@@ -836,5 +1614,6 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
|
836
1614
|
* @param options - The tree, channel, codec, and logger to use.
|
|
837
1615
|
* @returns A new {@link DefaultView} instance.
|
|
838
1616
|
*/
|
|
839
|
-
export const createView = <
|
|
840
|
-
|
|
1617
|
+
export const createView = <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection, TMessage>(
|
|
1618
|
+
options: ViewOptions<TInput, TOutput, TProjection, TMessage>,
|
|
1619
|
+
): DefaultView<TInput, TOutput, TProjection, TMessage> => new DefaultView(options);
|