@ably/ai-transport 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -116
- package/dist/ably-ai-transport.js +1743 -961
- package/dist/ably-ai-transport.js.map +1 -1
- package/dist/ably-ai-transport.umd.cjs +1 -1
- package/dist/ably-ai-transport.umd.cjs.map +1 -1
- package/dist/constants.d.ts +117 -39
- package/dist/core/agent.d.ts +29 -0
- package/dist/core/codec/decoder.d.ts +20 -23
- package/dist/core/codec/encoder.d.ts +11 -8
- package/dist/core/codec/index.d.ts +1 -2
- package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
- package/dist/core/codec/types.d.ts +410 -101
- package/dist/core/transport/agent-session.d.ts +10 -0
- package/dist/core/transport/branch-chain.d.ts +43 -0
- package/dist/core/transport/client-session.d.ts +13 -0
- package/dist/core/transport/decode-fold.d.ts +47 -0
- package/dist/core/transport/headers.d.ts +97 -17
- package/dist/core/transport/index.d.ts +5 -3
- package/dist/core/transport/internal/bounded-map.d.ts +20 -0
- package/dist/core/transport/invocation.d.ts +74 -0
- package/dist/core/transport/load-conversation.d.ts +128 -0
- package/dist/core/transport/load-history.d.ts +39 -0
- package/dist/core/transport/pipe-stream.d.ts +9 -8
- package/dist/core/transport/run-manager.d.ts +78 -0
- package/dist/core/transport/tree.d.ts +435 -0
- package/dist/core/transport/types/agent.d.ts +353 -0
- package/dist/core/transport/types/client.d.ts +168 -0
- package/dist/core/transport/types/shared.d.ts +24 -0
- package/dist/core/transport/types/tree.d.ts +315 -0
- package/dist/core/transport/types/view.d.ts +222 -0
- package/dist/core/transport/types.d.ts +13 -402
- package/dist/core/transport/view.d.ts +354 -0
- package/dist/errors.d.ts +37 -9
- package/dist/index.d.ts +6 -6
- package/dist/logger.d.ts +12 -0
- package/dist/react/ably-ai-transport-react.js +1164 -645
- package/dist/react/ably-ai-transport-react.js.map +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
- package/dist/react/contexts/client-session-context.d.ts +36 -0
- package/dist/react/contexts/client-session-provider.d.ts +53 -0
- package/dist/react/create-session-hooks.d.ts +116 -0
- package/dist/react/index.d.ts +16 -10
- package/dist/react/internal/use-resolved-session.d.ts +36 -0
- package/dist/react/use-ably-messages.d.ts +20 -11
- package/dist/react/use-client-session.d.ts +81 -0
- package/dist/react/use-create-view.d.ts +23 -0
- package/dist/react/use-tree.d.ts +35 -0
- package/dist/react/use-view.d.ts +110 -0
- package/dist/utils.d.ts +32 -23
- package/dist/vercel/ably-ai-transport-vercel.js +2748 -1625
- package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
- package/dist/vercel/codec/decoder.d.ts +5 -18
- package/dist/vercel/codec/encoder.d.ts +6 -36
- package/dist/vercel/codec/events.d.ts +51 -0
- package/dist/vercel/codec/index.d.ts +24 -12
- package/dist/vercel/codec/reducer.d.ts +144 -0
- package/dist/vercel/codec/tool-transitions.d.ts +50 -0
- package/dist/vercel/index.d.ts +4 -2
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +10298 -1410
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +70 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +33 -0
- package/dist/vercel/react/contexts/chat-transport-provider.d.ts +96 -0
- package/dist/vercel/react/index.d.ts +4 -0
- package/dist/vercel/react/use-chat-transport.d.ts +66 -21
- package/dist/vercel/react/use-message-sync.d.ts +31 -12
- package/dist/vercel/run-end-reason.d.ts +29 -0
- package/dist/vercel/transport/chat-transport.d.ts +71 -30
- package/dist/vercel/transport/index.d.ts +25 -18
- package/dist/vercel/transport/run-output-stream.d.ts +56 -0
- package/dist/version.d.ts +2 -0
- package/package.json +47 -34
- package/src/constants.ts +126 -47
- package/src/core/agent.ts +68 -0
- package/src/core/codec/decoder.ts +71 -98
- package/src/core/codec/encoder.ts +115 -58
- package/src/core/codec/index.ts +13 -6
- package/src/core/codec/lifecycle-tracker.ts +10 -9
- package/src/core/codec/types.ts +438 -106
- package/src/core/transport/agent-session.ts +1344 -0
- package/src/core/transport/branch-chain.ts +58 -0
- package/src/core/transport/client-session.ts +775 -0
- package/src/core/transport/decode-fold.ts +91 -0
- package/src/core/transport/headers.ts +182 -19
- package/src/core/transport/index.ts +29 -22
- package/src/core/transport/internal/bounded-map.ts +27 -0
- package/src/core/transport/invocation.ts +98 -0
- package/src/core/transport/load-conversation.ts +355 -0
- package/src/core/transport/load-history.ts +269 -0
- package/src/core/transport/pipe-stream.ts +58 -40
- package/src/core/transport/run-manager.ts +249 -0
- package/src/core/transport/tree.ts +1167 -0
- package/src/core/transport/types/agent.ts +407 -0
- package/src/core/transport/types/client.ts +211 -0
- package/src/core/transport/types/shared.ts +27 -0
- package/src/core/transport/types/tree.ts +344 -0
- package/src/core/transport/types/view.ts +259 -0
- package/src/core/transport/types.ts +13 -527
- package/src/core/transport/view.ts +1271 -0
- package/src/errors.ts +42 -9
- package/src/event-emitter.ts +3 -2
- package/src/index.ts +55 -39
- package/src/logger.ts +14 -1
- package/src/react/contexts/client-session-context.ts +41 -0
- package/src/react/contexts/client-session-provider.tsx +186 -0
- package/src/react/create-session-hooks.ts +141 -0
- package/src/react/index.ts +27 -10
- package/src/react/internal/use-resolved-session.ts +63 -0
- package/src/react/use-ably-messages.ts +47 -19
- package/src/react/use-client-session.ts +201 -0
- package/src/react/use-create-view.ts +72 -0
- package/src/react/use-tree.ts +84 -0
- package/src/react/use-view.ts +275 -0
- package/src/react/vite.config.ts +4 -1
- package/src/utils.ts +63 -45
- package/src/vercel/codec/decoder.ts +336 -255
- package/src/vercel/codec/encoder.ts +348 -196
- package/src/vercel/codec/events.ts +87 -0
- package/src/vercel/codec/index.ts +59 -14
- package/src/vercel/codec/reducer.ts +977 -0
- package/src/vercel/codec/tool-transitions.ts +122 -0
- package/src/vercel/index.ts +7 -3
- package/src/vercel/react/contexts/chat-transport-context.ts +41 -0
- package/src/vercel/react/contexts/chat-transport-provider.tsx +150 -0
- package/src/vercel/react/index.ts +13 -1
- package/src/vercel/react/use-chat-transport.ts +162 -42
- package/src/vercel/react/use-message-sync.ts +121 -22
- package/src/vercel/react/vite.config.ts +4 -2
- package/src/vercel/run-end-reason.ts +78 -0
- package/src/vercel/transport/chat-transport.ts +553 -113
- package/src/vercel/transport/index.ts +40 -28
- package/src/vercel/transport/run-output-stream.ts +170 -0
- package/src/version.ts +2 -0
- package/dist/core/transport/client-transport.d.ts +0 -10
- package/dist/core/transport/conversation-tree.d.ts +0 -9
- package/dist/core/transport/decode-history.d.ts +0 -41
- package/dist/core/transport/server-transport.d.ts +0 -7
- package/dist/core/transport/stream-router.d.ts +0 -19
- package/dist/core/transport/turn-manager.d.ts +0 -34
- package/dist/react/use-active-turns.d.ts +0 -8
- package/dist/react/use-client-transport.d.ts +0 -7
- package/dist/react/use-conversation-tree.d.ts +0 -20
- package/dist/react/use-edit.d.ts +0 -7
- package/dist/react/use-history.d.ts +0 -19
- package/dist/react/use-messages.d.ts +0 -7
- package/dist/react/use-regenerate.d.ts +0 -7
- package/dist/react/use-send.d.ts +0 -7
- package/dist/vercel/codec/accumulator.d.ts +0 -21
- package/src/core/transport/client-transport.ts +0 -959
- package/src/core/transport/conversation-tree.ts +0 -434
- package/src/core/transport/decode-history.ts +0 -337
- package/src/core/transport/server-transport.ts +0 -458
- package/src/core/transport/stream-router.ts +0 -118
- package/src/core/transport/turn-manager.ts +0 -147
- package/src/react/use-active-turns.ts +0 -61
- package/src/react/use-client-transport.ts +0 -37
- package/src/react/use-conversation-tree.ts +0 -71
- package/src/react/use-edit.ts +0 -24
- package/src/react/use-history.ts +0 -111
- package/src/react/use-messages.ts +0 -32
- package/src/react/use-regenerate.ts +0 -24
- package/src/react/use-send.ts +0 -25
- package/src/vercel/codec/accumulator.ts +0 -603
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree — materializes a branching conversation as a forest of nodes. Each turn
|
|
3
|
+
* is two nodes: a user {@link InputNode} keyed by its client-owned
|
|
4
|
+
* codec-message-id and an agent {@link RunNode} keyed by the agent-minted
|
|
5
|
+
* run-id, parented to the input node.
|
|
6
|
+
*
|
|
7
|
+
* Each node holds a per-node codec {@link TProjection} which the Tree folds
|
|
8
|
+
* from inbound events. The Tree owns the complete conversation state across
|
|
9
|
+
* every observed node. The {@link View} walks the parent chain to extract a
|
|
10
|
+
* flat message list for rendering.
|
|
11
|
+
*
|
|
12
|
+
* `applyMessage()` is the entry point for inbound channel messages — it
|
|
13
|
+
* classifies a run-less user input into an input node (keyed by
|
|
14
|
+
* codec-message-id) or routes a run-bearing wire to its reply run (keyed by
|
|
15
|
+
* run-id), folds events into that node's projection, and maintains a secondary
|
|
16
|
+
* `codecMessageId -> nodeKey` index. `applyRunLifecycle()` handles run-start /
|
|
17
|
+
* run-suspend / run-resume / run-end events.
|
|
18
|
+
*
|
|
19
|
+
* Sibling structure: editing a prompt produces a sibling input node linked by
|
|
20
|
+
* {@link InputNode.forkOf}; regenerating a reply produces a sibling reply run
|
|
21
|
+
* sharing the same input-node parent (no fork-of).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type * as Ably from 'ably';
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
HEADER_CODEC_MESSAGE_ID,
|
|
28
|
+
HEADER_FORK_OF,
|
|
29
|
+
HEADER_INPUT_CODEC_MESSAGE_ID,
|
|
30
|
+
HEADER_INVOCATION_ID,
|
|
31
|
+
HEADER_MSG_REGENERATE,
|
|
32
|
+
HEADER_PARENT,
|
|
33
|
+
HEADER_ROLE,
|
|
34
|
+
HEADER_RUN_CLIENT_ID,
|
|
35
|
+
HEADER_RUN_ID,
|
|
36
|
+
} from '../../constants.js';
|
|
37
|
+
import { EventEmitter } from '../../event-emitter.js';
|
|
38
|
+
import type { Logger } from '../../logger.js';
|
|
39
|
+
import type { CodecInputEvent, CodecOutputEvent, Reducer } from '../codec/types.js';
|
|
40
|
+
import type { ConversationNode, InputNode, OutputEvent, RunLifecycleEvent, RunNode, Tree } from './types.js';
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Internal node type
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
interface InternalNode<TProjection> {
|
|
47
|
+
node: ConversationNode<TProjection>;
|
|
48
|
+
/** Insertion sequence — tiebreaker for nodes with no sort serial (optimistic). */
|
|
49
|
+
insertSeq: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The primary key a node is indexed under: a reply run's `runId`, or an input
|
|
54
|
+
* node's `codecMessageId` (the client owns it before the agent mints a runId).
|
|
55
|
+
* @param node - The node to key.
|
|
56
|
+
* @returns The node's primary key.
|
|
57
|
+
*/
|
|
58
|
+
export const nodeKey = <TProjection>(node: ConversationNode<TProjection>): string =>
|
|
59
|
+
node.kind === 'run' ? node.runId : node.codecMessageId;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The serial a node sorts by: a reply run's `startSerial`, an input node's
|
|
63
|
+
* `serial`. Undefined for an optimistic (not-yet-acked) node, which tail-sorts.
|
|
64
|
+
* @param node - The node to read.
|
|
65
|
+
* @returns The sort serial, or undefined for an optimistic node.
|
|
66
|
+
*/
|
|
67
|
+
const sortSerial = <TProjection>(node: ConversationNode<TProjection>): string | undefined =>
|
|
68
|
+
node.kind === 'run' ? node.startSerial : node.serial;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Add a value to a `Map<K, Set<V>>`, creating the bucket Set on first use.
|
|
72
|
+
* @param map - The Map to mutate.
|
|
73
|
+
* @param key - The bucket key.
|
|
74
|
+
* @param value - The value to add.
|
|
75
|
+
*/
|
|
76
|
+
const addToSetMap = <K, V>(map: Map<K, Set<V>>, key: K, value: V): void => {
|
|
77
|
+
let set = map.get(key);
|
|
78
|
+
if (!set) {
|
|
79
|
+
set = new Set();
|
|
80
|
+
map.set(key, set);
|
|
81
|
+
}
|
|
82
|
+
set.add(value);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Remove a value from a `Map<K, Set<V>>`, dropping the bucket when it empties.
|
|
87
|
+
* @param map - The Map to mutate.
|
|
88
|
+
* @param key - The bucket key.
|
|
89
|
+
* @param value - The value to remove.
|
|
90
|
+
*/
|
|
91
|
+
const deleteFromSetMap = <K, V>(map: Map<K, Set<V>>, key: K, value: V): void => {
|
|
92
|
+
const set = map.get(key);
|
|
93
|
+
if (!set) return;
|
|
94
|
+
set.delete(value);
|
|
95
|
+
if (set.size === 0) map.delete(key);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Internal interface — extended surface consumed by View / ClientSession
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/** Internal tree surface used by View and ClientSession — not part of the public Tree API. */
|
|
103
|
+
export interface TreeInternal<
|
|
104
|
+
TInput extends CodecInputEvent,
|
|
105
|
+
TOutput extends CodecOutputEvent,
|
|
106
|
+
TProjection,
|
|
107
|
+
> extends Tree<TOutput, TProjection> {
|
|
108
|
+
/**
|
|
109
|
+
* Walk the visible node chain (both input nodes and reply runs) along the
|
|
110
|
+
* selected branches, in chronological order. The View renders from this.
|
|
111
|
+
* @param selections - Per-group selected member key, keyed by group root.
|
|
112
|
+
* @returns The visible nodes in chronological order.
|
|
113
|
+
*/
|
|
114
|
+
visibleNodes(selections?: Map<string, string>): ConversationNode<TProjection>[];
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get the "group root" key for a sibling group — the stable key the
|
|
118
|
+
* selection map is keyed by (the earliest edit version for input nodes, the
|
|
119
|
+
* original reply for a regenerate group).
|
|
120
|
+
*/
|
|
121
|
+
getGroupRoot(key: string): string;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The reply runs parented at an input node (its codec-message-id), in
|
|
125
|
+
* iteration order. Empty when none have been observed. Used to resolve a
|
|
126
|
+
* user prompt to its reply run(s).
|
|
127
|
+
* @param inputCodecMessageId - The input node's codec-message-id.
|
|
128
|
+
* @returns The reply runs parented at that input.
|
|
129
|
+
*/
|
|
130
|
+
getReplyRuns(inputCodecMessageId: string): RunNode<TProjection>[];
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Apply an inbound channel message to the tree.
|
|
134
|
+
*
|
|
135
|
+
* Classifies the message and routes it to the owning node:
|
|
136
|
+
* 1. Run-less user input (no run-id, a `user`-role message carrying a
|
|
137
|
+
* codec-message-id and input events): creates or promotes the input node
|
|
138
|
+
* keyed by that codec-message-id, folds the input events.
|
|
139
|
+
* 2. Run-bearing wire (assistant output, continuation tool-resolution, or a
|
|
140
|
+
* fresh agent-minted run): routes to the reply run by run-id (reconciling
|
|
141
|
+
* an optimistic insert by codec-message-id), folds events.
|
|
142
|
+
* @param events - Decoded codec events, split by wire direction. Both are
|
|
143
|
+
* folded into the node's projection, inputs first.
|
|
144
|
+
* @param events.inputs - Client-published events (`ai-input` wire).
|
|
145
|
+
* @param events.outputs - Agent-published events (`ai-output` wire).
|
|
146
|
+
* @param headers - Transport headers from the inbound Ably message.
|
|
147
|
+
* @param serial - Ably channel serial; undefined for optimistic inserts.
|
|
148
|
+
*/
|
|
149
|
+
applyMessage(
|
|
150
|
+
events: { inputs: TInput[]; outputs: TOutput[] },
|
|
151
|
+
headers: Record<string, string>,
|
|
152
|
+
serial?: string,
|
|
153
|
+
): void;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Apply a run-lifecycle event.
|
|
157
|
+
*
|
|
158
|
+
* - `start`: creates the reply run (if missing) or, for an existing run,
|
|
159
|
+
* sets RunNode.status to 'active', promotes startSerial, and backfills
|
|
160
|
+
* structural metadata (parent / forkOf / regenerates / invocationId).
|
|
161
|
+
* - `suspend`: sets RunNode.status to 'suspended' and records `endSerial`.
|
|
162
|
+
* The run stays live so a resume under the same `runId` picks up where it
|
|
163
|
+
* left off.
|
|
164
|
+
* - `resume`: re-activates an existing suspended Run (status back to
|
|
165
|
+
* 'active') without touching its structure or serials — a pure re-entry
|
|
166
|
+
* signal. A no-op if the Run is not yet known.
|
|
167
|
+
* - `end`: sets RunNode.status to the terminal reason and records
|
|
168
|
+
* `endSerial`.
|
|
169
|
+
*
|
|
170
|
+
* Always emits a 'run' event to subscribers.
|
|
171
|
+
* @param event - Lifecycle event payload, including the channel serial.
|
|
172
|
+
*/
|
|
173
|
+
applyRunLifecycle(event: RunLifecycleEvent): void;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get the node keyed by `key`, or undefined if `key` names no node. The
|
|
177
|
+
* key is a {@link nodeKey} — a runId (reply run) or an input node's
|
|
178
|
+
* codec-message-id — so the result is a {@link ConversationNode} union:
|
|
179
|
+
* narrow on `kind` before reading kind-specific fields. Pairs with
|
|
180
|
+
* {@link getNodeByCodecMessageId}, which resolves an arbitrary owned
|
|
181
|
+
* codec-message-id (including an assistant message's) to its node.
|
|
182
|
+
* @param key - The node key to look up.
|
|
183
|
+
* @returns The node, or undefined if not found.
|
|
184
|
+
*/
|
|
185
|
+
getNode(key: string): ConversationNode<TProjection> | undefined;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Remove a node from the tree by its key ({@link nodeKey} — a runId or an
|
|
189
|
+
* input node's codec-message-id). Children become unreachable because their
|
|
190
|
+
* parent is no longer on the active path.
|
|
191
|
+
* @param key - The node key to remove.
|
|
192
|
+
*/
|
|
193
|
+
delete(key: string): void;
|
|
194
|
+
|
|
195
|
+
/** Forward a raw Ably message event to tree subscribers. */
|
|
196
|
+
emitAblyMessage(msg: Ably.InboundMessage): void;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Implementation
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
/** EventEmitter events map for the tree. */
|
|
204
|
+
interface TreeEventsMap<TOutput extends CodecOutputEvent> {
|
|
205
|
+
update: undefined;
|
|
206
|
+
'ably-message': Ably.InboundMessage;
|
|
207
|
+
run: RunLifecycleEvent;
|
|
208
|
+
output: OutputEvent<TOutput>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Spec: AIT-CT13
|
|
212
|
+
export class DefaultTree<
|
|
213
|
+
TInput extends CodecInputEvent,
|
|
214
|
+
TOutput extends CodecOutputEvent,
|
|
215
|
+
TProjection,
|
|
216
|
+
> implements TreeInternal<TInput, TOutput, TProjection> {
|
|
217
|
+
private readonly _codec: Reducer<TInput | TOutput, TProjection>;
|
|
218
|
+
private readonly _logger: Logger;
|
|
219
|
+
private readonly _emitter: EventEmitter<TreeEventsMap<TOutput>>;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* All nodes indexed by their primary key ({@link nodeKey}): a reply run's
|
|
223
|
+
* runId, or an input node's codec-message-id.
|
|
224
|
+
*/
|
|
225
|
+
private readonly _nodeIndex = new Map<string, InternalNode<TProjection>>();
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Maps every observed `codec-message-id` to its owning node's key
|
|
229
|
+
* ({@link nodeKey}). For a reply run that is the runId of every message the
|
|
230
|
+
* run published; for an input node it is the input's own codec-message-id.
|
|
231
|
+
* Resolves fork-of / parent codec-message-ids to node keys, routes
|
|
232
|
+
* continuation amend wires to existing nodes, and backs UI lookups that hold
|
|
233
|
+
* a codec-message-id.
|
|
234
|
+
*/
|
|
235
|
+
private readonly _codecMessageIdToNodeKey = new Map<string, string>();
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* All nodes sorted by their sort serial ({@link sortSerial}: `startSerial`
|
|
239
|
+
* for runs, `serial` for input nodes), lexicographically. Nodes with no sort
|
|
240
|
+
* serial (optimistic) sort after all serial-bearing nodes, ordered among
|
|
241
|
+
* themselves by insertion sequence.
|
|
242
|
+
*/
|
|
243
|
+
private readonly _sortedNodes: InternalNode<TProjection>[] = [];
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Parent index: parent node key (the key its children's
|
|
247
|
+
* `parentCodecMessageId` resolves to) to the set of child node keys. Root
|
|
248
|
+
* nodes (no parent) are indexed under the key `undefined`. Kind-blind — a
|
|
249
|
+
* reply run and an input node parent off each other through the same index.
|
|
250
|
+
*/
|
|
251
|
+
private readonly _parentIndex = new Map<string | undefined, Set<string>>();
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Reverse edge: an input node's codec-message-id to the set of reply-run ids
|
|
255
|
+
* parented at it. Lets the View resolve a user prompt to its (selected) reply
|
|
256
|
+
* run, and groups regenerate siblings (which all parent at the same input
|
|
257
|
+
* node).
|
|
258
|
+
*/
|
|
259
|
+
private readonly _replyRunsByInput = new Map<string, Set<string>>();
|
|
260
|
+
|
|
261
|
+
/** Monotonically increasing counter for insertion sequence. */
|
|
262
|
+
private _seqCounter = 0;
|
|
263
|
+
|
|
264
|
+
/** Incremented on structural changes; unchanged on projection-only updates. */
|
|
265
|
+
private _structuralVersion = 0;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Cached sibling-group lookups keyed by node key. The walk over forkOf
|
|
269
|
+
* chains and the per-parent fan-out are pure functions of the node
|
|
270
|
+
* graph, so the cache is keyed against {@link _structuralVersion}:
|
|
271
|
+
* any topology mutation drops the cache and the next lookup
|
|
272
|
+
* recomputes. Hits matter most during a single render pass where
|
|
273
|
+
* the View calls `getSiblingNodes` once per visible node plus extra
|
|
274
|
+
* per-message branch-anchor probes from React components.
|
|
275
|
+
*/
|
|
276
|
+
private _siblingCache = new Map<string, InternalNode<TProjection>[]>();
|
|
277
|
+
private _siblingCacheVersion = -1;
|
|
278
|
+
|
|
279
|
+
constructor(codec: Reducer<TInput | TOutput, TProjection>, logger: Logger) {
|
|
280
|
+
this._codec = codec;
|
|
281
|
+
this._logger = logger;
|
|
282
|
+
this._emitter = new EventEmitter<TreeEventsMap<TOutput>>(logger);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// -------------------------------------------------------------------------
|
|
286
|
+
// Sorted list maintenance
|
|
287
|
+
// -------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Compare two nodes (Run or input) for sorted list ordering.
|
|
291
|
+
* Serial-bearing nodes sort by their sort serial (`startSerial` for runs,
|
|
292
|
+
* `serial` for input nodes), lexicographically.
|
|
293
|
+
* Nodes with no sort serial sort after all serial-bearing nodes.
|
|
294
|
+
* Among them, sort by insertion sequence.
|
|
295
|
+
*
|
|
296
|
+
* Optimistic (null-serial) nodes intentionally tail-sort so they reorder
|
|
297
|
+
* into place when the server relay arrives and `applyMessage` promotes
|
|
298
|
+
* startSerial — see {@link applyMessage}'s `_removeSortedNode` /
|
|
299
|
+
* `_insertSortedNode` pair on the promotion path.
|
|
300
|
+
* @param a - First node to compare.
|
|
301
|
+
* @param b - Second node to compare.
|
|
302
|
+
* @returns Negative if a sorts before b, positive if after, zero if equal.
|
|
303
|
+
*/
|
|
304
|
+
// Spec: AIT-CT13a
|
|
305
|
+
private _compareNodes(a: InternalNode<TProjection>, b: InternalNode<TProjection>): number {
|
|
306
|
+
const sa = sortSerial(a.node);
|
|
307
|
+
const sb = sortSerial(b.node);
|
|
308
|
+
if (sa === undefined && sb === undefined) return a.insertSeq - b.insertSeq;
|
|
309
|
+
if (sa === undefined) return 1;
|
|
310
|
+
if (sb === undefined) return -1;
|
|
311
|
+
if (sa < sb) return -1;
|
|
312
|
+
if (sa > sb) return 1;
|
|
313
|
+
return a.insertSeq - b.insertSeq;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Insert a node into the sorted list at the correct position via binary search.
|
|
318
|
+
* @param internal - The node to insert.
|
|
319
|
+
*/
|
|
320
|
+
private _insertSortedNode(internal: InternalNode<TProjection>): void {
|
|
321
|
+
const startSerial = sortSerial(internal.node);
|
|
322
|
+
|
|
323
|
+
// Fast path: null-startSerial always appends to end.
|
|
324
|
+
if (startSerial === undefined) {
|
|
325
|
+
this._sortedNodes.push(internal);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
let lo = 0;
|
|
330
|
+
let hi = this._sortedNodes.length;
|
|
331
|
+
while (lo < hi) {
|
|
332
|
+
const mid = (lo + hi) >>> 1;
|
|
333
|
+
const midNode = this._sortedNodes[mid];
|
|
334
|
+
if (!midNode) break; // unreachable
|
|
335
|
+
if (this._compareNodes(midNode, internal) <= 0) {
|
|
336
|
+
lo = mid + 1;
|
|
337
|
+
} else {
|
|
338
|
+
hi = mid;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
this._sortedNodes.splice(lo, 0, internal);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Remove a node from the sorted list.
|
|
346
|
+
* @param internal - The node to remove.
|
|
347
|
+
*/
|
|
348
|
+
private _removeSortedNode(internal: InternalNode<TProjection>): void {
|
|
349
|
+
const idx = this._sortedNodes.indexOf(internal);
|
|
350
|
+
if (idx !== -1) this._sortedNodes.splice(idx, 1);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Insert a freshly-created node into the primary store, the parent index, and
|
|
355
|
+
* the sorted list, then bump the structural version. Kind-specific secondary
|
|
356
|
+
* indexing — the codec-message-id map for input nodes, the reply→input edge
|
|
357
|
+
* for reply runs — is the caller's responsibility.
|
|
358
|
+
* @param key - The node's primary key ({@link nodeKey}).
|
|
359
|
+
* @param entry - The internal node to insert.
|
|
360
|
+
* @param parentCodecMessageId - The node's structural parent, or undefined for a root.
|
|
361
|
+
*/
|
|
362
|
+
private _insertNode(key: string, entry: InternalNode<TProjection>, parentCodecMessageId: string | undefined): void {
|
|
363
|
+
this._nodeIndex.set(key, entry);
|
|
364
|
+
this._addToParentIndex(parentCodecMessageId, key);
|
|
365
|
+
this._insertSortedNode(entry);
|
|
366
|
+
this._structuralVersion++;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Re-sort a node whose sort key just changed and bump the structural version.
|
|
371
|
+
* The caller mutates the serial field (`serial` for input nodes, `startSerial`
|
|
372
|
+
* for runs); this keeps the sorted list and version in step. Used on the
|
|
373
|
+
* optimistic-serial promotion paths when the server relay/echo arrives.
|
|
374
|
+
* @param entry - The internal node whose serial was just promoted.
|
|
375
|
+
*/
|
|
376
|
+
private _promoteSerial(entry: InternalNode<TProjection>): void {
|
|
377
|
+
this._removeSortedNode(entry);
|
|
378
|
+
this._insertSortedNode(entry);
|
|
379
|
+
this._structuralVersion++;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Fold a batch of events into a node's projection in place, isolating each
|
|
384
|
+
* fold in a try/catch so a throwing reducer can't abort the rest of the batch
|
|
385
|
+
* or the surrounding apply.
|
|
386
|
+
* @param entry - The internal node whose projection is folded in place.
|
|
387
|
+
* @param events - The decoded events to fold, in wire order.
|
|
388
|
+
* @param serial - Ably channel serial; coerced to '' for an optimistic insert.
|
|
389
|
+
* @param messageId - The reducer routing key (codec-message-id), or undefined.
|
|
390
|
+
*/
|
|
391
|
+
private _foldInto(
|
|
392
|
+
entry: InternalNode<TProjection>,
|
|
393
|
+
events: (TInput | TOutput)[],
|
|
394
|
+
serial: string | undefined,
|
|
395
|
+
messageId: string | undefined,
|
|
396
|
+
): void {
|
|
397
|
+
for (const event of events) {
|
|
398
|
+
try {
|
|
399
|
+
entry.node.projection = this._codec.fold(entry.node.projection, event, { serial: serial ?? '', messageId });
|
|
400
|
+
} catch (error) {
|
|
401
|
+
this._logger.error('Tree._foldInto(); fold threw', { key: nodeKey(entry.node), messageId, err: error });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// -------------------------------------------------------------------------
|
|
407
|
+
// Parent index maintenance
|
|
408
|
+
// -------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
private _addToParentIndex(parentNodeKey: string | undefined, childKey: string): void {
|
|
411
|
+
addToSetMap(this._parentIndex, parentNodeKey, childKey);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private _removeFromParentIndex(parentNodeKey: string | undefined, childKey: string): void {
|
|
415
|
+
deleteFromSetMap(this._parentIndex, parentNodeKey, childKey);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Resolve a node's structural parent to the parent node's key
|
|
420
|
+
* ({@link nodeKey}), or undefined for a root. The parent is named by a
|
|
421
|
+
* codec-message-id (`parentCodecMessageId`); this maps it through the
|
|
422
|
+
* codec-message-id index to the owning node's key (a runId for a reply run,
|
|
423
|
+
* a codec-message-id for an input node). Returns undefined when the parent
|
|
424
|
+
* hasn't been observed yet (the node is treated as a root until it arrives).
|
|
425
|
+
* @param node - The node whose parent to resolve.
|
|
426
|
+
* @returns The parent node's key, or undefined.
|
|
427
|
+
*/
|
|
428
|
+
private _parentKeyOf(node: ConversationNode<TProjection>): string | undefined {
|
|
429
|
+
const parentCodecMessageId = node.parentCodecMessageId;
|
|
430
|
+
return parentCodecMessageId === undefined ? undefined : this._codecMessageIdToNodeKey.get(parentCodecMessageId);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// -------------------------------------------------------------------------
|
|
434
|
+
// Sibling grouping
|
|
435
|
+
// -------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Walk an input node's `forkOf` chain to the group root — the earliest edit
|
|
439
|
+
* version sharing the same structural parent. Stops at a missing target, a
|
|
440
|
+
* non-input target, a parent mismatch, or a cycle.
|
|
441
|
+
* @param node - The input node to walk from.
|
|
442
|
+
* @returns The group-root input node (the node itself when it is the root).
|
|
443
|
+
*/
|
|
444
|
+
private _inputGroupRoot(node: InputNode<TProjection>): InputNode<TProjection> {
|
|
445
|
+
let current = node;
|
|
446
|
+
const visited = new Set<string>([nodeKey(current)]);
|
|
447
|
+
while (current.forkOf !== undefined) {
|
|
448
|
+
if (visited.has(current.forkOf)) break;
|
|
449
|
+
const forkTarget = this._nodeIndex.get(current.forkOf);
|
|
450
|
+
if (forkTarget?.node.kind !== 'input' || forkTarget.node.parentCodecMessageId !== current.parentCodecMessageId) {
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
current = forkTarget.node;
|
|
454
|
+
visited.add(nodeKey(current));
|
|
455
|
+
}
|
|
456
|
+
return current;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Get the sibling group that the node keyed by `key` belongs to. Kind-split:
|
|
461
|
+
*
|
|
462
|
+
* - **Reply runs** — every reply run sharing the same input-node parent is a
|
|
463
|
+
* sibling (the original reply + its regenerators all parent at the same
|
|
464
|
+
* input node M_user). No fork-of involved.
|
|
465
|
+
* - **Input nodes** — edit versions: nodes sharing a parent AND linked by a
|
|
466
|
+
* `forkOf` chain to the group root.
|
|
467
|
+
*
|
|
468
|
+
* Returned ordered by startSerial (original/oldest first). A group of one is
|
|
469
|
+
* returned as a single-element array (no branching).
|
|
470
|
+
* @param key - The node key ({@link nodeKey}) to look up the group for.
|
|
471
|
+
* @returns The ordered list of sibling nodes.
|
|
472
|
+
*/
|
|
473
|
+
// Spec: AIT-CT13b
|
|
474
|
+
private _getSiblingGroup(key: string): InternalNode<TProjection>[] {
|
|
475
|
+
if (this._siblingCacheVersion !== this._structuralVersion) {
|
|
476
|
+
this._siblingCache.clear();
|
|
477
|
+
this._siblingCacheVersion = this._structuralVersion;
|
|
478
|
+
}
|
|
479
|
+
const cached = this._siblingCache.get(key);
|
|
480
|
+
if (cached) return cached;
|
|
481
|
+
|
|
482
|
+
const entry = this._nodeIndex.get(key);
|
|
483
|
+
if (!entry) return [];
|
|
484
|
+
|
|
485
|
+
// The "original" anchors the group's parent + kind. For an input node,
|
|
486
|
+
// walk the forkOf chain to the earliest version sharing the parent; for a
|
|
487
|
+
// reply run the node itself anchors (all same-parent runs are siblings).
|
|
488
|
+
let original = entry.node;
|
|
489
|
+
if (original.kind === 'input') {
|
|
490
|
+
original = this._inputGroupRoot(original);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// `_parentIndex` is keyed by the raw structural `parentCodecMessageId` (not
|
|
494
|
+
// the resolved parent node key) so a run observed before its input node
|
|
495
|
+
// still files/groups correctly — the parent codec-message-id is known at
|
|
496
|
+
// creation, the resolved key may not be.
|
|
497
|
+
const parentKey = original.parentCodecMessageId;
|
|
498
|
+
const siblings: InternalNode<TProjection>[] = [];
|
|
499
|
+
const candidateKeys = this._parentIndex.get(parentKey);
|
|
500
|
+
if (candidateKeys) {
|
|
501
|
+
for (const childKey of candidateKeys) {
|
|
502
|
+
const childEntry = this._nodeIndex.get(childKey);
|
|
503
|
+
if (childEntry && this._isSiblingOf(childEntry.node, original)) {
|
|
504
|
+
siblings.push(childEntry);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
siblings.sort((a, b) => this._compareNodes(a, b));
|
|
510
|
+
// Cache against the queried key AND every member of the group: a single
|
|
511
|
+
// group is the same array regardless of which member triggered the lookup,
|
|
512
|
+
// so subsequent queries against any member hit without recomputing.
|
|
513
|
+
for (const sib of siblings) {
|
|
514
|
+
this._siblingCache.set(nodeKey(sib.node), siblings);
|
|
515
|
+
}
|
|
516
|
+
this._siblingCache.set(key, siblings);
|
|
517
|
+
return siblings;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Whether `node` belongs to the sibling group anchored at `original`.
|
|
522
|
+
* Requires the same kind and the same structural parent; reply runs need
|
|
523
|
+
* nothing more (same-parent runs are regenerate siblings), input nodes must
|
|
524
|
+
* additionally be forkOf-linked to the original (edit versions).
|
|
525
|
+
* @param node - The candidate node.
|
|
526
|
+
* @param original - The group's anchor node.
|
|
527
|
+
* @returns True if `node` is a sibling of `original`.
|
|
528
|
+
*/
|
|
529
|
+
private _isSiblingOf(node: ConversationNode<TProjection>, original: ConversationNode<TProjection>): boolean {
|
|
530
|
+
if (node.kind !== original.kind) return false;
|
|
531
|
+
if (node.parentCodecMessageId !== original.parentCodecMessageId) return false;
|
|
532
|
+
// Same-parent reply runs are regenerate siblings — no fork-of needed.
|
|
533
|
+
if (node.kind === 'run') return true;
|
|
534
|
+
// Input nodes: must be forkOf-linked to the original (edit versions).
|
|
535
|
+
const originalKey = nodeKey(original);
|
|
536
|
+
if (nodeKey(node) === originalKey) return true;
|
|
537
|
+
let current: ConversationNode<TProjection> = node;
|
|
538
|
+
const visited = new Set<string>([nodeKey(current)]);
|
|
539
|
+
while (current.kind === 'input' && current.forkOf !== undefined) {
|
|
540
|
+
if (current.forkOf === originalKey) return true;
|
|
541
|
+
if (visited.has(current.forkOf)) break;
|
|
542
|
+
const target = this._nodeIndex.get(current.forkOf);
|
|
543
|
+
if (!target) break;
|
|
544
|
+
current = target.node;
|
|
545
|
+
visited.add(nodeKey(current));
|
|
546
|
+
}
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Get the "group root" key for a sibling group — the stable key the
|
|
552
|
+
* selection map is keyed by. For an input node (edit versions) that is the
|
|
553
|
+
* earliest fork-of ancestor; for a reply run (regenerate group) it is the
|
|
554
|
+
* oldest same-parent run (the original reply).
|
|
555
|
+
* @param key - Any node key in the sibling group.
|
|
556
|
+
* @returns The group root's key.
|
|
557
|
+
*/
|
|
558
|
+
getGroupRoot(key: string): string {
|
|
559
|
+
const entry = this._nodeIndex.get(key);
|
|
560
|
+
if (!entry) return key;
|
|
561
|
+
|
|
562
|
+
if (entry.node.kind === 'input') {
|
|
563
|
+
return nodeKey(this._inputGroupRoot(entry.node));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Reply run: the oldest same-parent run is the original reply.
|
|
567
|
+
const group = this._getSiblingGroup(key);
|
|
568
|
+
const root = group[0]?.node;
|
|
569
|
+
return root ? nodeKey(root) : key;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// -------------------------------------------------------------------------
|
|
573
|
+
// Public query methods
|
|
574
|
+
// -------------------------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Walk the visible node chain along the selected branches, kind-blind. An
|
|
578
|
+
* input node and a reply run reach each other through the same
|
|
579
|
+
* parent-membership check, so seed-only user→user chains and the
|
|
580
|
+
* input→reply→input weave both resolve here. Sibling groups (edit versions /
|
|
581
|
+
* regenerate runs) collapse to the selected member.
|
|
582
|
+
* @param selections - Per-group selected member key, keyed by group root.
|
|
583
|
+
* @returns The visible nodes (both kinds) in chronological order.
|
|
584
|
+
*/
|
|
585
|
+
visibleNodes(selections: Map<string, string> = new Map<string, string>()): ConversationNode<TProjection>[] {
|
|
586
|
+
this._logger.trace('DefaultTree.visibleNodes();');
|
|
587
|
+
const result: ConversationNode<TProjection>[] = [];
|
|
588
|
+
const currentPath = new Set<string>();
|
|
589
|
+
const resolvedGroups = new Map<string, string>(); // groupRootKey -> selected key
|
|
590
|
+
|
|
591
|
+
for (const internal of this._sortedNodes) {
|
|
592
|
+
const node = internal.node;
|
|
593
|
+
const key = nodeKey(node);
|
|
594
|
+
|
|
595
|
+
// Step 1: Parent reachability (kind-blind — the parent may be an input
|
|
596
|
+
// node or a reply run; resolve its key and check the active path).
|
|
597
|
+
const parentKey = this._parentKeyOf(node);
|
|
598
|
+
if (parentKey !== undefined && !currentPath.has(parentKey)) {
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Step 2: Sibling selection.
|
|
603
|
+
const group = this._getSiblingGroup(key);
|
|
604
|
+
if (group.length > 1) {
|
|
605
|
+
const groupRootKey = this.getGroupRoot(key);
|
|
606
|
+
let selectedKey = resolvedGroups.get(groupRootKey);
|
|
607
|
+
if (selectedKey === undefined) {
|
|
608
|
+
const preferredKey = selections.get(groupRootKey);
|
|
609
|
+
if (preferredKey !== undefined && group.some((n) => nodeKey(n.node) === preferredKey)) {
|
|
610
|
+
selectedKey = preferredKey;
|
|
611
|
+
} else {
|
|
612
|
+
const latest = group.at(-1);
|
|
613
|
+
if (!latest) break; // unreachable: group.length > 1
|
|
614
|
+
selectedKey = nodeKey(latest.node);
|
|
615
|
+
}
|
|
616
|
+
resolvedGroups.set(groupRootKey, selectedKey);
|
|
617
|
+
}
|
|
618
|
+
if (key !== selectedKey) {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
currentPath.add(key);
|
|
624
|
+
result.push(node);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return result;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
getRunNode(runId: string): RunNode<TProjection> | undefined {
|
|
631
|
+
this._logger.trace('DefaultTree.getRunNode();', { runId });
|
|
632
|
+
const node = this._nodeIndex.get(runId)?.node;
|
|
633
|
+
return node?.kind === 'run' ? node : undefined;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
getNode(key: string): ConversationNode<TProjection> | undefined {
|
|
637
|
+
this._logger.trace('DefaultTree.getNode();', { key });
|
|
638
|
+
return this._nodeIndex.get(key)?.node;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
getNodeByCodecMessageId(codecMessageId: string): ConversationNode<TProjection> | undefined {
|
|
642
|
+
this._logger.trace('DefaultTree.getNodeByCodecMessageId();', { codecMessageId });
|
|
643
|
+
const key = this._codecMessageIdToNodeKey.get(codecMessageId);
|
|
644
|
+
return key === undefined ? undefined : this._nodeIndex.get(key)?.node;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
getReplyRuns(inputCodecMessageId: string): RunNode<TProjection>[] {
|
|
648
|
+
const runIds = this._replyRunsByInput.get(inputCodecMessageId);
|
|
649
|
+
if (!runIds) return [];
|
|
650
|
+
const result: RunNode<TProjection>[] = [];
|
|
651
|
+
for (const runId of runIds) {
|
|
652
|
+
const node = this._nodeIndex.get(runId)?.node;
|
|
653
|
+
if (node?.kind === 'run') result.push(node);
|
|
654
|
+
}
|
|
655
|
+
return result;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
getSiblingNodes(key: string): ConversationNode<TProjection>[] {
|
|
659
|
+
this._logger.trace('DefaultTree.getSiblingNodes();', { key });
|
|
660
|
+
return this._getSiblingGroup(key).map((n) => n.node);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// -------------------------------------------------------------------------
|
|
664
|
+
// Mutation
|
|
665
|
+
// -------------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
applyMessage(
|
|
668
|
+
events: { inputs: TInput[]; outputs: TOutput[] },
|
|
669
|
+
headers: Record<string, string>,
|
|
670
|
+
serial?: string,
|
|
671
|
+
): void {
|
|
672
|
+
const wireRunId = headers[HEADER_RUN_ID];
|
|
673
|
+
const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
|
|
674
|
+
|
|
675
|
+
// Classify: with NO run-id, a user message carrying a codec-message-id and
|
|
676
|
+
// at least one input event forms an INPUT node keyed by that
|
|
677
|
+
// codec-message-id — the client owns it; the agent mints the reply run-id
|
|
678
|
+
// separately. Everything else needs a run-id to route to a reply run.
|
|
679
|
+
// Capturing the id (not a boolean) narrows it to `string` for the input path.
|
|
680
|
+
const inputNodeCodecMessageId =
|
|
681
|
+
wireRunId === undefined &&
|
|
682
|
+
codecMessageId !== undefined &&
|
|
683
|
+
headers[HEADER_ROLE] === 'user' &&
|
|
684
|
+
events.inputs.length > 0
|
|
685
|
+
? codecMessageId
|
|
686
|
+
: undefined;
|
|
687
|
+
|
|
688
|
+
if (wireRunId === undefined && inputNodeCodecMessageId === undefined) {
|
|
689
|
+
this._logger.warn('Tree.applyMessage(); message has no run-id and is not a user input; skipping');
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Fold inputs first, then outputs, preserving wire order.
|
|
694
|
+
const all: (TInput | TOutput)[] = [...events.inputs, ...events.outputs];
|
|
695
|
+
|
|
696
|
+
// Wire-only metadata-carrier messages (e.g. `ait-regenerate`) decode to
|
|
697
|
+
// zero events and don't need a node at the tree level — the eventual reply
|
|
698
|
+
// run is created later by run-start, and any regenerate / parent
|
|
699
|
+
// information the wire carried is reread from the run-start headers.
|
|
700
|
+
// Skipping here avoids a phantom node that would inflate sibling counts.
|
|
701
|
+
const existingKey = inputNodeCodecMessageId ?? wireRunId;
|
|
702
|
+
if (all.length === 0 && existingKey !== undefined && !this._nodeIndex.has(existingKey)) {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// `update` is the structural channel: emit it only when this apply
|
|
707
|
+
// actually changes the tree shape (new node, startSerial promotion).
|
|
708
|
+
// Content-only folds (streaming chunks into an existing node) flow through
|
|
709
|
+
// `output` instead, so they leave `_structuralVersion` untouched.
|
|
710
|
+
const structuralBefore = this._structuralVersion;
|
|
711
|
+
|
|
712
|
+
if (inputNodeCodecMessageId !== undefined) {
|
|
713
|
+
this._applyInputMessage(inputNodeCodecMessageId, headers, serial, all);
|
|
714
|
+
} else if (wireRunId !== undefined) {
|
|
715
|
+
this._applyRunMessage(wireRunId, events, headers, serial);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (this._structuralVersion !== structuralBefore) this._emitter.emit('update');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Apply a run-less user input wire: create (or promote the serial of) the
|
|
723
|
+
* input node keyed by its codec-message-id, fold the input events into its
|
|
724
|
+
* own projection, and emit an `output` event (with empty outputs — input
|
|
725
|
+
* folds carry none) so the View observes the optimistic insert.
|
|
726
|
+
* @param codecMessageId - The input node's codec-message-id (its primary key).
|
|
727
|
+
* @param headers - Transport headers from the inbound Ably message.
|
|
728
|
+
* @param serial - Ably channel serial; undefined for an optimistic insert.
|
|
729
|
+
* @param all - The decoded input events to fold, in wire order.
|
|
730
|
+
*/
|
|
731
|
+
private _applyInputMessage(
|
|
732
|
+
codecMessageId: string,
|
|
733
|
+
headers: Record<string, string>,
|
|
734
|
+
serial: string | undefined,
|
|
735
|
+
all: (TInput | TOutput)[],
|
|
736
|
+
): void {
|
|
737
|
+
let entry = this._nodeIndex.get(codecMessageId);
|
|
738
|
+
if (!entry) {
|
|
739
|
+
entry = this._createInputNodeFromHeaders(codecMessageId, headers, serial);
|
|
740
|
+
this._insertNode(codecMessageId, entry, entry.node.parentCodecMessageId);
|
|
741
|
+
this._codecMessageIdToNodeKey.set(codecMessageId, codecMessageId);
|
|
742
|
+
this._logger.debug('Tree.applyMessage(); created input node', { codecMessageId });
|
|
743
|
+
} else if (entry.node.kind === 'input' && serial && !entry.node.serial) {
|
|
744
|
+
// Promote optimistic serial when the relay/echo arrives.
|
|
745
|
+
this._logger.debug('Tree.applyMessage(); promoting input serial', { codecMessageId, serial });
|
|
746
|
+
entry.node.serial = serial;
|
|
747
|
+
this._promoteSerial(entry);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
this._foldInto(entry, all, serial, codecMessageId);
|
|
751
|
+
|
|
752
|
+
// An input node owns no agent outputs; the event still fires (empty
|
|
753
|
+
// outputs) so consumers observe the projection change. It has no run-id —
|
|
754
|
+
// the causal routing key is the input's own codec-message-id.
|
|
755
|
+
this._emitter.emit('output', {
|
|
756
|
+
runId: undefined,
|
|
757
|
+
inputCodecMessageId: codecMessageId,
|
|
758
|
+
codecMessageId,
|
|
759
|
+
serial,
|
|
760
|
+
events: [],
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Apply a reply-run wire (assistant output, continuation tool-resolution, or
|
|
766
|
+
* a fresh run keyed by the agent-minted run-id): create or reconcile the run
|
|
767
|
+
* node, fold its events, maintain the codec-message-id and reply→input
|
|
768
|
+
* indices, and emit the `output` event. Derives the codec-message-id,
|
|
769
|
+
* triggering-input id, fold list, and outputs from `events`/`headers`,
|
|
770
|
+
* mirroring `applyMessage`.
|
|
771
|
+
* @param wireRunId - The run-id from the inbound wire (the node's primary key).
|
|
772
|
+
* @param events - The decoded inputs and outputs from the wire.
|
|
773
|
+
* @param events.inputs - Client-published events (`ai-input` wire).
|
|
774
|
+
* @param events.outputs - Agent-published events (`ai-output` wire).
|
|
775
|
+
* @param headers - Transport headers from the inbound Ably message.
|
|
776
|
+
* @param serial - Ably channel serial; undefined for an optimistic insert.
|
|
777
|
+
*/
|
|
778
|
+
private _applyRunMessage(
|
|
779
|
+
wireRunId: string,
|
|
780
|
+
events: { inputs: TInput[]; outputs: TOutput[] },
|
|
781
|
+
headers: Record<string, string>,
|
|
782
|
+
serial: string | undefined,
|
|
783
|
+
): void {
|
|
784
|
+
const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
|
|
785
|
+
// The triggering input's codec-message-id (the agent's echo), surfaced on
|
|
786
|
+
// the `output` event as the stream's causal routing key.
|
|
787
|
+
const inputCodecMessageId = headers[HEADER_INPUT_CODEC_MESSAGE_ID];
|
|
788
|
+
// Fold inputs first, then outputs, preserving wire order.
|
|
789
|
+
const all: (TInput | TOutput)[] = [...events.inputs, ...events.outputs];
|
|
790
|
+
const outputs = events.outputs;
|
|
791
|
+
|
|
792
|
+
let run = this._nodeIndex.get(wireRunId);
|
|
793
|
+
|
|
794
|
+
// Reconcile an optimistic insert with its serial-bearing echo by
|
|
795
|
+
// codec-message-id rather than the wire run-id — covers assistant content
|
|
796
|
+
// that pins a codec-message-id before its run-id is indexed.
|
|
797
|
+
if (!run && codecMessageId !== undefined) {
|
|
798
|
+
const indexedKey = this._codecMessageIdToNodeKey.get(codecMessageId);
|
|
799
|
+
const indexed = indexedKey === undefined ? undefined : this._nodeIndex.get(indexedKey);
|
|
800
|
+
if (indexed?.node.kind === 'run' && indexed.node.startSerial === undefined) run = indexed;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (!run) {
|
|
804
|
+
run = this._createRunFromHeaders(wireRunId, headers, serial);
|
|
805
|
+
this._insertNode(wireRunId, run, run.node.parentCodecMessageId);
|
|
806
|
+
this._indexReplyRun(run.node, wireRunId);
|
|
807
|
+
this._logger.debug('Tree.applyMessage(); created new Run', { runId: wireRunId });
|
|
808
|
+
} else if (serial && run.node.kind === 'run' && !run.node.startSerial) {
|
|
809
|
+
// Promote optimistic startSerial when the relay/echo arrives.
|
|
810
|
+
this._logger.debug('Tree.applyMessage(); promoting startSerial', { runId: wireRunId, serial });
|
|
811
|
+
run.node.startSerial = serial;
|
|
812
|
+
this._promoteSerial(run);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Index the codec-message-id against the node that actually owns it.
|
|
816
|
+
const ownerKey = nodeKey(run.node);
|
|
817
|
+
if (codecMessageId) this._codecMessageIdToNodeKey.set(codecMessageId, ownerKey);
|
|
818
|
+
|
|
819
|
+
this._foldInto(run, all, serial, codecMessageId);
|
|
820
|
+
|
|
821
|
+
this._emitter.emit('output', { runId: ownerKey, inputCodecMessageId, codecMessageId, serial, events: outputs });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Record a reply run against its input-node parent (the reverse edge powering
|
|
826
|
+
* `getReplyRuns` and regenerate sibling grouping). A reply run's
|
|
827
|
+
* `parentCodecMessageId` is its input node's codec-message-id (the master
|
|
828
|
+
* invariant), so no resolution is needed.
|
|
829
|
+
* @param node - The reply run node.
|
|
830
|
+
* @param runId - The run's id.
|
|
831
|
+
*/
|
|
832
|
+
private _indexReplyRun(node: ConversationNode<TProjection>, runId: string): void {
|
|
833
|
+
if (node.parentCodecMessageId === undefined) return;
|
|
834
|
+
addToSetMap(this._replyRunsByInput, node.parentCodecMessageId, runId);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
applyRunLifecycle(event: RunLifecycleEvent): void {
|
|
838
|
+
this._logger.trace('DefaultTree.applyRunLifecycle();', { type: event.type, runId: event.runId });
|
|
839
|
+
// Structural channel: emit `update` only when the lifecycle event changes
|
|
840
|
+
// the tree shape. Only run-start can do that (a new Run, startSerial
|
|
841
|
+
// promotion, or structural-metadata backfill); suspend/resume/end mutate
|
|
842
|
+
// status/endSerial on an existing node — content, not structure — so the
|
|
843
|
+
// conditional naturally never fires for them.
|
|
844
|
+
const structuralBefore = this._structuralVersion;
|
|
845
|
+
switch (event.type) {
|
|
846
|
+
case 'start': {
|
|
847
|
+
this._applyRunStart(event);
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
case 'suspend': {
|
|
851
|
+
this._applyRunSuspend(event);
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
case 'resume': {
|
|
855
|
+
this._applyRunResume(event);
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
case 'end': {
|
|
859
|
+
this._applyRunEnd(event);
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
this._emitter.emit('run', event);
|
|
864
|
+
if (this._structuralVersion !== structuralBefore) this._emitter.emit('update');
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Apply a run-start lifecycle event's structural effect: create the reply
|
|
869
|
+
* run if it doesn't exist yet, or backfill an optimistic / wire-created
|
|
870
|
+
* node's structure and metadata from the canonical run-start. Mutates
|
|
871
|
+
* `_structuralVersion` when the tree shape changes; the caller owns the
|
|
872
|
+
* `run`/`update` emits.
|
|
873
|
+
* @param event - The run-start lifecycle event.
|
|
874
|
+
*/
|
|
875
|
+
private _applyRunStart(event: RunLifecycleEvent & { type: 'start' }): void {
|
|
876
|
+
const existing = this._nodeIndex.get(event.runId);
|
|
877
|
+
if (existing?.node.kind === 'run') {
|
|
878
|
+
const node = existing.node;
|
|
879
|
+
if (node.status !== 'active') {
|
|
880
|
+
node.status = 'active';
|
|
881
|
+
}
|
|
882
|
+
if (event.serial && !node.startSerial) {
|
|
883
|
+
node.startSerial = event.serial;
|
|
884
|
+
this._promoteSerial(existing);
|
|
885
|
+
}
|
|
886
|
+
// Backfill structural metadata if the Run was created from an
|
|
887
|
+
// assistant wire that arrived before run-start (history pagination
|
|
888
|
+
// boundary or out-of-order delivery). The run-start lifecycle event is
|
|
889
|
+
// the canonical source for parent/forkOf/regenerates; only fill in
|
|
890
|
+
// fields the wire didn't already populate. A run-start is always a
|
|
891
|
+
// first start (continuations re-enter via `ai-run-resume`, which
|
|
892
|
+
// carries no structural metadata), so it is unconditionally
|
|
893
|
+
// authoritative here. `parent` is the run's STRUCTURAL parent (its
|
|
894
|
+
// input node) — reachability and the reply→input edge read it.
|
|
895
|
+
if (node.parentCodecMessageId === undefined && event.parent !== undefined) {
|
|
896
|
+
node.parentCodecMessageId = event.parent;
|
|
897
|
+
this._removeFromParentIndex(undefined, event.runId);
|
|
898
|
+
this._addToParentIndex(node.parentCodecMessageId, event.runId);
|
|
899
|
+
this._indexReplyRun(node, event.runId);
|
|
900
|
+
this._structuralVersion++;
|
|
901
|
+
}
|
|
902
|
+
if (node.forkOf === undefined && event.forkOf !== undefined) {
|
|
903
|
+
const forkOfKey = this._codecMessageIdToNodeKey.get(event.forkOf);
|
|
904
|
+
if (forkOfKey !== undefined && forkOfKey !== event.runId) {
|
|
905
|
+
node.forkOf = forkOfKey;
|
|
906
|
+
this._structuralVersion++;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (node.regeneratesCodecMessageId === undefined && event.regenerates !== undefined) {
|
|
910
|
+
node.regeneratesCodecMessageId = event.regenerates;
|
|
911
|
+
this._structuralVersion++;
|
|
912
|
+
}
|
|
913
|
+
// Adopt the agent-minted invocation-id onto the optimistic node. The
|
|
914
|
+
// agent mints it, so a node created from an optimistic insert (or an
|
|
915
|
+
// assistant wire that arrived before run-start) carries an empty id
|
|
916
|
+
// until the agent's run-start delivers it. Metadata, not structure —
|
|
917
|
+
// consumers re-read it on the `run` emit, so no structural-version
|
|
918
|
+
// bump.
|
|
919
|
+
if (node.invocationId === '' && event.invocationId !== '') {
|
|
920
|
+
node.invocationId = event.invocationId;
|
|
921
|
+
}
|
|
922
|
+
} else if (!existing) {
|
|
923
|
+
const run = this._createRunFromLifecycle(event);
|
|
924
|
+
this._insertNode(event.runId, run, run.node.parentCodecMessageId);
|
|
925
|
+
this._indexReplyRun(run.node, event.runId);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Apply a run-suspend lifecycle event: pause the run without ending it —
|
|
931
|
+
* mark the node 'suspended' and record the serial it paused at, but keep the
|
|
932
|
+
* Run live so a resume under the same runId resumes it. Status/endSerial are
|
|
933
|
+
* content, not structure, so this never mutates `_structuralVersion`; the
|
|
934
|
+
* caller owns the emits.
|
|
935
|
+
* @param event - The run-suspend lifecycle event.
|
|
936
|
+
*/
|
|
937
|
+
private _applyRunSuspend(event: RunLifecycleEvent & { type: 'suspend' }): void {
|
|
938
|
+
const run = this._nodeIndex.get(event.runId);
|
|
939
|
+
if (run?.node.kind === 'run') {
|
|
940
|
+
run.node.status = 'suspended';
|
|
941
|
+
run.node.endSerial = event.serial;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Apply a run-resume lifecycle event: re-enter an already-started run by
|
|
947
|
+
* flipping a suspended run back to 'active'. Pure re-entry — it carries no
|
|
948
|
+
* parent/forkOf and does not promote startSerial (the original run-start owns
|
|
949
|
+
* the run's structure). Only a suspended run resumes: a no-op when the run
|
|
950
|
+
* isn't known (e.g. a resume replayed from a newer history page before its
|
|
951
|
+
* run-start) and a no-op for an already-active or terminal
|
|
952
|
+
* (complete/cancelled/error) run — a stray resume must never resurrect a run
|
|
953
|
+
* that has ended. The caller owns the emits.
|
|
954
|
+
* @param event - The run-resume lifecycle event.
|
|
955
|
+
*/
|
|
956
|
+
private _applyRunResume(event: RunLifecycleEvent & { type: 'resume' }): void {
|
|
957
|
+
const run = this._nodeIndex.get(event.runId);
|
|
958
|
+
if (run?.node.kind === 'run' && run.node.status === 'suspended') {
|
|
959
|
+
run.node.status = 'active';
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Apply a run-end lifecycle event: record the terminal reason as the node's
|
|
965
|
+
* status and the serial it ended at. Status/endSerial are content, not
|
|
966
|
+
* structure, so this never mutates `_structuralVersion`; the caller owns the
|
|
967
|
+
* emits.
|
|
968
|
+
* @param event - The run-end lifecycle event.
|
|
969
|
+
*/
|
|
970
|
+
private _applyRunEnd(event: RunLifecycleEvent & { type: 'end' }): void {
|
|
971
|
+
const run = this._nodeIndex.get(event.runId);
|
|
972
|
+
if (run?.node.kind === 'run') {
|
|
973
|
+
run.node.status = event.reason;
|
|
974
|
+
run.node.endSerial = event.serial;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
delete(key: string): void {
|
|
979
|
+
const entry = this._nodeIndex.get(key);
|
|
980
|
+
if (!entry) return;
|
|
981
|
+
|
|
982
|
+
this._logger.debug('Tree.delete();', { key });
|
|
983
|
+
|
|
984
|
+
this._removeFromParentIndex(entry.node.parentCodecMessageId, key);
|
|
985
|
+
this._removeSortedNode(entry);
|
|
986
|
+
this._nodeIndex.delete(key);
|
|
987
|
+
// Drop the reply→input reverse edge.
|
|
988
|
+
if (entry.node.kind === 'run' && entry.node.parentCodecMessageId !== undefined) {
|
|
989
|
+
deleteFromSetMap(this._replyRunsByInput, entry.node.parentCodecMessageId, key);
|
|
990
|
+
}
|
|
991
|
+
// _codecMessageIdToNodeKey entries pointing at this node linger but are
|
|
992
|
+
// harmless; they'll be overwritten if the node is re-created and remain
|
|
993
|
+
// dangling otherwise. Cleanup not worth the index walk.
|
|
994
|
+
|
|
995
|
+
this._structuralVersion++;
|
|
996
|
+
this._emitter.emit('update');
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// -------------------------------------------------------------------------
|
|
1000
|
+
// Internal helpers
|
|
1001
|
+
// -------------------------------------------------------------------------
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Build a fresh RunNode from a wire message's headers. Used when an
|
|
1005
|
+
* inbound message arrives before any run-start event for its runId.
|
|
1006
|
+
* @param runId - The run-id from the inbound wire.
|
|
1007
|
+
* @param headers - Transport headers from the inbound Ably message.
|
|
1008
|
+
* @param serial - Ably channel serial; undefined for optimistic inserts.
|
|
1009
|
+
* @returns A newly-allocated internal run node ready for insertion.
|
|
1010
|
+
*/
|
|
1011
|
+
private _createRunFromHeaders(
|
|
1012
|
+
runId: string,
|
|
1013
|
+
headers: Record<string, string>,
|
|
1014
|
+
serial: string | undefined,
|
|
1015
|
+
): InternalNode<TProjection> {
|
|
1016
|
+
const forkOfMsgId = headers[HEADER_FORK_OF];
|
|
1017
|
+
return this._buildRunNode({
|
|
1018
|
+
runId,
|
|
1019
|
+
parentCodecMessageId: headers[HEADER_PARENT],
|
|
1020
|
+
// forkOf is resolved to the fork target's node key (an input node's
|
|
1021
|
+
// codec-message-id, or a run's id) — the same space `_isSiblingOf` walks.
|
|
1022
|
+
forkOf: forkOfMsgId ? this._codecMessageIdToNodeKey.get(forkOfMsgId) : undefined,
|
|
1023
|
+
regeneratesCodecMessageId: headers[HEADER_MSG_REGENERATE],
|
|
1024
|
+
clientId: headers[HEADER_RUN_CLIENT_ID] ?? '',
|
|
1025
|
+
invocationId: headers[HEADER_INVOCATION_ID] ?? '',
|
|
1026
|
+
startSerial: serial,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Allocate a RunNode from already-resolved fields. Shared by the
|
|
1032
|
+
* header-driven and lifecycle-driven run creators: both build the identical
|
|
1033
|
+
* RunNode literal and stamp an insert sequence.
|
|
1034
|
+
* @param params - The resolved run fields.
|
|
1035
|
+
* @param params.runId - The run's id (its primary key).
|
|
1036
|
+
* @param params.parentCodecMessageId - Structural parent codec-message-id, or undefined for a root.
|
|
1037
|
+
* @param params.forkOf - The resolved fork target's node key (already mapped through the codec-message-id index), or undefined.
|
|
1038
|
+
* @param params.regeneratesCodecMessageId - The codec-message-id this run regenerates, or undefined.
|
|
1039
|
+
* @param params.clientId - The publishing client's id.
|
|
1040
|
+
* @param params.invocationId - The agent invocation id.
|
|
1041
|
+
* @param params.startSerial - Ably channel serial; undefined for optimistic inserts.
|
|
1042
|
+
* @returns A newly-allocated internal run node ready for insertion.
|
|
1043
|
+
*/
|
|
1044
|
+
private _buildRunNode(params: {
|
|
1045
|
+
runId: string;
|
|
1046
|
+
parentCodecMessageId: string | undefined;
|
|
1047
|
+
forkOf: string | undefined;
|
|
1048
|
+
regeneratesCodecMessageId: string | undefined;
|
|
1049
|
+
clientId: string;
|
|
1050
|
+
invocationId: string;
|
|
1051
|
+
startSerial: string | undefined;
|
|
1052
|
+
}): InternalNode<TProjection> {
|
|
1053
|
+
const node: RunNode<TProjection> = {
|
|
1054
|
+
kind: 'run',
|
|
1055
|
+
runId: params.runId,
|
|
1056
|
+
parentCodecMessageId: params.parentCodecMessageId,
|
|
1057
|
+
forkOf: params.forkOf,
|
|
1058
|
+
regeneratesCodecMessageId: params.regeneratesCodecMessageId,
|
|
1059
|
+
clientId: params.clientId,
|
|
1060
|
+
invocationId: params.invocationId,
|
|
1061
|
+
status: 'active',
|
|
1062
|
+
projection: this._codec.init(),
|
|
1063
|
+
startSerial: params.startSerial,
|
|
1064
|
+
endSerial: undefined,
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
return { node, insertSeq: this._seqCounter++ };
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Build a fresh InputNode from a run-less user input wire's headers.
|
|
1072
|
+
* @param codecMessageId - The input's codec-message-id (its primary key).
|
|
1073
|
+
* @param headers - Transport headers from the inbound Ably message.
|
|
1074
|
+
* @param serial - Ably channel serial; undefined for optimistic inserts.
|
|
1075
|
+
* @returns A newly-allocated internal input node ready for insertion.
|
|
1076
|
+
*/
|
|
1077
|
+
private _createInputNodeFromHeaders(
|
|
1078
|
+
codecMessageId: string,
|
|
1079
|
+
headers: Record<string, string>,
|
|
1080
|
+
serial: string | undefined,
|
|
1081
|
+
): InternalNode<TProjection> {
|
|
1082
|
+
const forkOfMsgId = headers[HEADER_FORK_OF];
|
|
1083
|
+
const node: InputNode<TProjection> = {
|
|
1084
|
+
kind: 'input',
|
|
1085
|
+
codecMessageId,
|
|
1086
|
+
parentCodecMessageId: headers[HEADER_PARENT],
|
|
1087
|
+
// An edit's fork-of names the original prompt's codec-message-id, which
|
|
1088
|
+
// IS that input node's key — no resolution needed.
|
|
1089
|
+
forkOf: forkOfMsgId,
|
|
1090
|
+
projection: this._codec.init(),
|
|
1091
|
+
serial,
|
|
1092
|
+
};
|
|
1093
|
+
return { node, insertSeq: this._seqCounter++ };
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Build a fresh RunNode from a run-start lifecycle event. Used when a
|
|
1098
|
+
* run-start event arrives before any message for its runId.
|
|
1099
|
+
* @param event - The run-start lifecycle event from the agent, including
|
|
1100
|
+
* its channel serial.
|
|
1101
|
+
* @returns A newly-allocated internal run node ready for insertion.
|
|
1102
|
+
*/
|
|
1103
|
+
private _createRunFromLifecycle(event: RunLifecycleEvent & { type: 'start' }): InternalNode<TProjection> {
|
|
1104
|
+
const forkOfMsgId = event.forkOf;
|
|
1105
|
+
return this._buildRunNode({
|
|
1106
|
+
runId: event.runId,
|
|
1107
|
+
parentCodecMessageId: event.parent,
|
|
1108
|
+
forkOf: forkOfMsgId ? this._codecMessageIdToNodeKey.get(forkOfMsgId) : undefined,
|
|
1109
|
+
regeneratesCodecMessageId: event.regenerates,
|
|
1110
|
+
clientId: event.clientId,
|
|
1111
|
+
invocationId: event.invocationId,
|
|
1112
|
+
startSerial: event.serial,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// -------------------------------------------------------------------------
|
|
1117
|
+
// Events
|
|
1118
|
+
// -------------------------------------------------------------------------
|
|
1119
|
+
|
|
1120
|
+
// Spec: AIT-CT8b, AIT-CT8e
|
|
1121
|
+
on(event: 'update', handler: () => void): () => void;
|
|
1122
|
+
on(event: 'ably-message', handler: (msg: Ably.InboundMessage) => void): () => void;
|
|
1123
|
+
on(event: 'run', handler: (event: RunLifecycleEvent) => void): () => void;
|
|
1124
|
+
on(event: 'output', handler: (event: OutputEvent<TOutput>) => void): () => void;
|
|
1125
|
+
on(
|
|
1126
|
+
event: 'update' | 'ably-message' | 'run' | 'output',
|
|
1127
|
+
handler:
|
|
1128
|
+
| (() => void)
|
|
1129
|
+
| ((msg: Ably.InboundMessage) => void)
|
|
1130
|
+
| ((event: RunLifecycleEvent) => void)
|
|
1131
|
+
| ((event: OutputEvent<TOutput>) => void),
|
|
1132
|
+
): () => void {
|
|
1133
|
+
// CAST: overload signatures enforce correct handler types per event name.
|
|
1134
|
+
const cb = handler as (arg: TreeEventsMap<TOutput>[keyof TreeEventsMap<TOutput>]) => void;
|
|
1135
|
+
this._emitter.on(event, cb);
|
|
1136
|
+
return () => {
|
|
1137
|
+
this._emitter.off(event, cb);
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Forward a raw Ably message event to tree subscribers.
|
|
1143
|
+
* @param msg - The raw Ably message to emit.
|
|
1144
|
+
*/
|
|
1145
|
+
emitAblyMessage(msg: Ably.InboundMessage): void {
|
|
1146
|
+
this._logger.trace('DefaultTree.emitAblyMessage();');
|
|
1147
|
+
this._emitter.emit('ably-message', msg);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// ---------------------------------------------------------------------------
|
|
1152
|
+
// Factory
|
|
1153
|
+
// ---------------------------------------------------------------------------
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Create a Tree that materializes branching conversation history from a flat
|
|
1157
|
+
* oplog of Ably messages as a two-node-per-turn forest (input node + reply run).
|
|
1158
|
+
* @param codec - Codec used to fold inbound events into per-Run projections.
|
|
1159
|
+
* @param logger - Logger for diagnostic output.
|
|
1160
|
+
* @returns A new {@link DefaultTree} instance. The session uses DefaultTree
|
|
1161
|
+
* directly for internal methods (applyMessage, applyRunLifecycle,
|
|
1162
|
+
* emitAblyMessage). Public consumers see the narrower {@link Tree} interface.
|
|
1163
|
+
*/
|
|
1164
|
+
export const createTree = <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection>(
|
|
1165
|
+
codec: Reducer<TInput | TOutput, TProjection>,
|
|
1166
|
+
logger: Logger,
|
|
1167
|
+
): DefaultTree<TInput, TOutput, TProjection> => new DefaultTree<TInput, TOutput, TProjection>(codec, logger);
|