@nanobpm/nano-workforce 0.185.1 → 0.186.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/CHANGELOG.md +6 -0
- package/app/agentic/cockpit/cockpit-browser-bundle.test.ts +14 -5
- package/app/agentic/cockpit/transcript-derive.display.test.ts +231 -0
- package/app/agentic/cockpit/transcript-derive.ts +297 -63
- package/app/agentic/transcript-display.ts +17 -0
- package/package.json +2 -2
- package/pages/cockpit/generated/transcript-derive.js +213 -50
- package/pages/cockpit/generated/transcript-display.js +316 -0
- package/pages/cockpit/generated/transcript-events.js +35 -1
- package/pages/cockpit/mount.js +83 -29
- package/scripts/build-cockpit-browser.ts +11 -1
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
// renders the agentic transcript from ONE source of truth (#660). Regenerate with:
|
|
5
5
|
// node --experimental-strip-types scripts/build-cockpit-browser.ts
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { createDisplayProjection } from "./transcript-display.js";
|
|
8
|
+
import { deriveViewFromChunks, optionKindAllows, parseTranscriptEvent, utf8ByteLength, } from "./transcript-events.js";
|
|
8
9
|
/**
|
|
9
|
-
* Derive the structured view of a fetched transcript page
|
|
10
|
-
*
|
|
10
|
+
* Derive the structured (event-fold) view of a fetched transcript page — the flat message/tool/permission
|
|
11
|
+
* history, per-turn structure and raw-byte accounting. This is the {@link DerivedView} fold, kept for the
|
|
12
|
+
* raw-fidelity footer and summary counts; the ordered, human-facing block SEQUENCE is the separate
|
|
13
|
+
* display projection {@link renderDerivedTranscript} draws. Pure: the cockpit reads THESE instead of
|
|
14
|
+
* re-parsing raw frame bytes.
|
|
11
15
|
*/
|
|
12
16
|
export function deriveTranscript(data) {
|
|
13
17
|
return deriveViewFromChunks(data.entries);
|
|
@@ -116,9 +120,11 @@ function detectDiff(tool) {
|
|
|
116
120
|
return { lines: structured, source: "args" };
|
|
117
121
|
return undefined;
|
|
118
122
|
}
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
/** Fill an EXISTING tool card node with one tool's content (name, status, args/result, diff block). Clears
|
|
124
|
+
* the node first so it is safe to re-invoke in place when the tool's result later arrives (bounded to
|
|
125
|
+
* this one card — no sibling block is touched). */
|
|
126
|
+
function applyTool(card, doc, tool) {
|
|
127
|
+
card.replaceChildren();
|
|
122
128
|
card.setAttribute("data-tool", tool.name);
|
|
123
129
|
card.setAttribute("data-offset", String(tool.offset));
|
|
124
130
|
card.setAttribute("data-status", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
|
|
@@ -152,6 +158,11 @@ function renderTool(doc, tool) {
|
|
|
152
158
|
resEl.setAttribute("data-tool-result", "true");
|
|
153
159
|
card.appendChild(resEl);
|
|
154
160
|
}
|
|
161
|
+
}
|
|
162
|
+
/** Render one tool card: name, status, args + result content, and a distinguishable diff block. */
|
|
163
|
+
function renderTool(doc, tool) {
|
|
164
|
+
const card = el(doc, "div", "cockpit-transcript-tool");
|
|
165
|
+
applyTool(card, doc, tool);
|
|
155
166
|
return card;
|
|
156
167
|
}
|
|
157
168
|
/**
|
|
@@ -160,8 +171,8 @@ function renderTool(doc, tool) {
|
|
|
160
171
|
* - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
|
|
161
172
|
* - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
|
|
162
173
|
*/
|
|
163
|
-
function
|
|
164
|
-
|
|
174
|
+
function applyPermission(card, doc, perm, options) {
|
|
175
|
+
card.replaceChildren();
|
|
165
176
|
card.setAttribute("data-permission", "request");
|
|
166
177
|
card.setAttribute("data-policy", perm.policy);
|
|
167
178
|
card.setAttribute("data-call-id", perm.callId);
|
|
@@ -181,13 +192,13 @@ function renderPermission(doc, perm, options) {
|
|
|
181
192
|
if (perm.resolved.by !== undefined)
|
|
182
193
|
settled.setAttribute("data-by", perm.resolved.by);
|
|
183
194
|
card.appendChild(settled);
|
|
184
|
-
return
|
|
195
|
+
return;
|
|
185
196
|
}
|
|
186
197
|
if (perm.policy === "yolo") {
|
|
187
198
|
// Informational: yolo auto-allows and never prompts a human, so no Allow/Deny buttons.
|
|
188
199
|
card.setAttribute("data-status", "auto");
|
|
189
200
|
card.appendChild(el(doc, "div", "cockpit-transcript-permission-note", "Auto-allowed (yolo) — no operator prompt."));
|
|
190
|
-
return
|
|
201
|
+
return;
|
|
191
202
|
}
|
|
192
203
|
// Pending escalate: one interactive button per offered option, wired to the resolve seam.
|
|
193
204
|
card.setAttribute("data-status", "pending");
|
|
@@ -206,55 +217,207 @@ function renderPermission(doc, perm, options) {
|
|
|
206
217
|
actions.appendChild(button);
|
|
207
218
|
}
|
|
208
219
|
card.appendChild(actions);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Render one permission prompt card from a {@link DerivedPermission}:
|
|
223
|
+
* - a pending `escalate` request → interactive Allow/Deny buttons wired to `onPermissionResolve`;
|
|
224
|
+
* - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
|
|
225
|
+
* - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
|
|
226
|
+
*/
|
|
227
|
+
function renderPermission(doc, perm, options) {
|
|
228
|
+
const card = el(doc, "div", "cockpit-transcript-permission");
|
|
229
|
+
applyPermission(card, doc, perm, options);
|
|
209
230
|
return card;
|
|
210
231
|
}
|
|
232
|
+
/** Fill an EXISTING text-block node with a coalesced message's text + offsets. The block is ONE growing
|
|
233
|
+
* node per logical message — a delta patches this node's `textContent` in place (never a new card per
|
|
234
|
+
* fragment), so a split word reconstructs into exactly that word. */
|
|
235
|
+
function applyText(node, block) {
|
|
236
|
+
node.setAttribute("data-role", block.role);
|
|
237
|
+
node.setAttribute("data-offset", String(block.startOffset));
|
|
238
|
+
node.setAttribute("data-end-offset", String(block.endOffset));
|
|
239
|
+
node.setAttribute("data-block-id", block.id);
|
|
240
|
+
if (block.messageId !== undefined)
|
|
241
|
+
node.setAttribute("data-message-id", block.messageId);
|
|
242
|
+
node.setAttribute("data-complete", String(block.complete));
|
|
243
|
+
node.textContent = block.text;
|
|
244
|
+
}
|
|
245
|
+
/** Render one coalesced-message text block (a single growing node). */
|
|
246
|
+
function renderText(doc, block) {
|
|
247
|
+
const node = el(doc, "div", "cockpit-transcript-message");
|
|
248
|
+
applyText(node, block);
|
|
249
|
+
return node;
|
|
250
|
+
}
|
|
251
|
+
/** Fill an EXISTING retention-gap node. A gap is a first-class visible break so a reattach that dropped
|
|
252
|
+
* chunks never implies the surrounding text is continuous; its `beforeOffset` is anchored once the first
|
|
253
|
+
* post-gap block opens. */
|
|
254
|
+
function applyGap(node, block) {
|
|
255
|
+
node.setAttribute("data-gap", "true");
|
|
256
|
+
node.setAttribute("data-block-id", block.id);
|
|
257
|
+
if (block.beforeOffset !== undefined)
|
|
258
|
+
node.setAttribute("data-before-offset", String(block.beforeOffset));
|
|
259
|
+
node.textContent = "⋯ retained-data gap — earlier output was evicted ⋯";
|
|
260
|
+
}
|
|
261
|
+
/** Render one retention-gap block. */
|
|
262
|
+
function renderGap(doc, block) {
|
|
263
|
+
const node = el(doc, "div", "cockpit-transcript-gap");
|
|
264
|
+
applyGap(node, block);
|
|
265
|
+
return node;
|
|
266
|
+
}
|
|
267
|
+
/** Build a fresh DOM node for any display block kind. */
|
|
268
|
+
function renderBlock(doc, block, options) {
|
|
269
|
+
switch (block.kind) {
|
|
270
|
+
case "text":
|
|
271
|
+
return renderText(doc, block);
|
|
272
|
+
case "tool":
|
|
273
|
+
return renderTool(doc, block.tool);
|
|
274
|
+
case "permission":
|
|
275
|
+
return renderPermission(doc, block.permission, options);
|
|
276
|
+
case "gap":
|
|
277
|
+
return renderGap(doc, block);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** Patch an EXISTING block node in place (bounded to that one block — no sibling node is touched). */
|
|
281
|
+
function patchBlock(node, doc, block, options) {
|
|
282
|
+
switch (block.kind) {
|
|
283
|
+
case "text":
|
|
284
|
+
applyText(node, block);
|
|
285
|
+
return;
|
|
286
|
+
case "tool":
|
|
287
|
+
applyTool(node, doc, block.tool);
|
|
288
|
+
return;
|
|
289
|
+
case "permission":
|
|
290
|
+
applyPermission(node, doc, block.permission, options);
|
|
291
|
+
return;
|
|
292
|
+
case "gap":
|
|
293
|
+
applyGap(node, block);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
211
297
|
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* alongside the structure. Idempotent — call again on each refresh. Everything it shows is a derivation
|
|
216
|
-
* of the one event log. `options.onPermissionResolve`, when provided, is invoked by a pending
|
|
217
|
-
* escalate-permission prompt's Allow/Deny buttons.
|
|
298
|
+
* Build the incremental renderer's stable DOM scaffold under `host` and return the mutable render state.
|
|
299
|
+
* Shared by {@link createIncrementalTranscript} (live) and {@link renderDerivedTranscript} (batch) so
|
|
300
|
+
* historical replay renders IDENTICALLY to the final live rendering.
|
|
218
301
|
*/
|
|
219
|
-
export function
|
|
220
|
-
const view = deriveTranscript(data);
|
|
302
|
+
export function createIncrementalTranscript(host, doc, stream, options = {}) {
|
|
221
303
|
host.replaceChildren();
|
|
304
|
+
const projection = createDisplayProjection();
|
|
305
|
+
const nodes = new Map();
|
|
306
|
+
const tallies = { text: 0, tool: 0, permission: 0, gap: 0, turns: 0, rawBytes: 0, rawChunks: 0, lifecycle: "open" };
|
|
307
|
+
// A turn opens implicitly before the first structured block even without an explicit `turn` event
|
|
308
|
+
// (mirrors deriveView's implicit turn 0), so any structured content means at least one turn.
|
|
309
|
+
let structured = false;
|
|
222
310
|
const root = el(doc, "div", "cockpit-transcript-derived");
|
|
223
|
-
root.setAttribute("data-stream",
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
root.
|
|
228
|
-
root.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
311
|
+
root.setAttribute("data-stream", stream);
|
|
312
|
+
const blocksHost = el(doc, "div", "cockpit-transcript-blocks");
|
|
313
|
+
const empty = el(doc, "div", "cockpit-transcript-empty");
|
|
314
|
+
const footer = el(doc, "footer", "cockpit-transcript-raw");
|
|
315
|
+
root.appendChild(blocksHost);
|
|
316
|
+
root.appendChild(empty);
|
|
317
|
+
root.appendChild(footer);
|
|
318
|
+
host.appendChild(root);
|
|
319
|
+
function refreshSummary() {
|
|
320
|
+
const turnCount = tallies.turns > 0 ? tallies.turns : structured ? 1 : 0;
|
|
321
|
+
root.setAttribute("data-lifecycle", tallies.lifecycle);
|
|
322
|
+
root.setAttribute("data-turn-count", String(turnCount));
|
|
323
|
+
root.setAttribute("data-message-count", String(tallies.text));
|
|
324
|
+
root.setAttribute("data-tool-count", String(tallies.tool));
|
|
325
|
+
root.setAttribute("data-permission-count", String(tallies.permission));
|
|
326
|
+
root.setAttribute("data-gap-count", String(tallies.gap));
|
|
327
|
+
root.setAttribute("data-block-count", String(nodes.size));
|
|
328
|
+
const hasBlocks = tallies.text + tallies.tool + tallies.permission > 0;
|
|
329
|
+
// Toggle (never remove — ElementLike has no removeChild) so a live first block clears the empty note
|
|
330
|
+
// without rebuilding, and an all-raw page still shows exactly one data-empty="true" element.
|
|
331
|
+
empty.setAttribute("data-empty", String(!hasBlocks));
|
|
332
|
+
empty.textContent = hasBlocks ? "" : "No structured events derived — raw replay only.";
|
|
333
|
+
footer.setAttribute("data-raw-bytes", String(tallies.rawBytes));
|
|
334
|
+
footer.setAttribute("data-raw-chunks", String(tallies.rawChunks));
|
|
335
|
+
footer.textContent = `${tallies.rawChunks} raw chunk(s) · ${tallies.rawBytes} B retained for replay`;
|
|
336
|
+
}
|
|
337
|
+
function countAppended(block) {
|
|
338
|
+
structured = structured || block.kind !== "gap";
|
|
339
|
+
if (block.kind === "text")
|
|
340
|
+
tallies.text++;
|
|
341
|
+
else if (block.kind === "tool")
|
|
342
|
+
tallies.tool++;
|
|
343
|
+
else if (block.kind === "permission")
|
|
344
|
+
tallies.permission++;
|
|
345
|
+
else
|
|
346
|
+
tallies.gap++;
|
|
233
347
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
348
|
+
/** Reconcile ONE projection apply-result into the DOM: append a new node, patch an existing one, and/or
|
|
349
|
+
* patch a secondary now-anchored gap. Bounded to the touched block(s) — no unaffected node is replaced. */
|
|
350
|
+
function reconcile(changed, appended, anchored) {
|
|
351
|
+
if (changed !== undefined) {
|
|
352
|
+
if (appended) {
|
|
353
|
+
const node = renderBlock(doc, changed, options);
|
|
354
|
+
nodes.set(changed.id, node);
|
|
355
|
+
blocksHost.appendChild(node);
|
|
356
|
+
countAppended(changed);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
const node = nodes.get(changed.id);
|
|
360
|
+
if (node !== undefined)
|
|
361
|
+
patchBlock(node, doc, changed, options);
|
|
362
|
+
}
|
|
244
363
|
}
|
|
245
|
-
|
|
246
|
-
|
|
364
|
+
if (anchored !== undefined) {
|
|
365
|
+
const node = nodes.get(anchored.id);
|
|
366
|
+
if (node !== undefined)
|
|
367
|
+
patchBlock(node, doc, anchored, options);
|
|
247
368
|
}
|
|
248
|
-
|
|
249
|
-
|
|
369
|
+
}
|
|
370
|
+
function applyEvent(event) {
|
|
371
|
+
// Raw bytes feed the byte-replay footer but produce no display block (the projection ignores them).
|
|
372
|
+
if (event.kind === "stream-chunk") {
|
|
373
|
+
tallies.rawChunks++;
|
|
374
|
+
tallies.rawBytes += utf8ByteLength(event.chunk);
|
|
375
|
+
}
|
|
376
|
+
else if (event.kind === "turn") {
|
|
377
|
+
tallies.turns++;
|
|
378
|
+
}
|
|
379
|
+
else if (event.kind === "lifecycle") {
|
|
380
|
+
tallies.lifecycle = event.phase;
|
|
250
381
|
}
|
|
251
|
-
|
|
382
|
+
const result = projection.apply(event);
|
|
383
|
+
reconcile(result.changed, result.appended, result.anchored);
|
|
252
384
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
385
|
+
refreshSummary();
|
|
386
|
+
return {
|
|
387
|
+
root,
|
|
388
|
+
applyChunk(chunk) {
|
|
389
|
+
applyEvent(parseTranscriptEvent(chunk));
|
|
390
|
+
refreshSummary();
|
|
391
|
+
},
|
|
392
|
+
noteGap() {
|
|
393
|
+
const result = projection.noteGap();
|
|
394
|
+
reconcile(result.changed, result.appended, result.anchored);
|
|
395
|
+
refreshSummary();
|
|
396
|
+
},
|
|
397
|
+
blocks() {
|
|
398
|
+
return projection.blocks();
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Render the DERIVED, ORDERED display of a fetched transcript page into `host`, replacing whatever was
|
|
404
|
+
* there — the pure BATCH convenience over {@link createIncrementalTranscript}. Draws one growing text
|
|
405
|
+
* block per logical message, with tool/diff cards and permission prompts interleaved in chronological
|
|
406
|
+
* (offset) order, plus a raw-fidelity footer (retained bytes/chunks) so the byte-replay stays visibly
|
|
407
|
+
* preserved. A retention `gap` on the page (`data.gap`) renders a leading visible break. Idempotent —
|
|
408
|
+
* call again on each refresh. Everything it shows is a derivation of the one event log, and it renders
|
|
409
|
+
* IDENTICALLY to the final live rendering (same incremental fold). `options.onPermissionResolve`, when
|
|
410
|
+
* provided, is invoked by a pending escalate-permission prompt's Allow/Deny buttons.
|
|
411
|
+
*/
|
|
412
|
+
export function renderDerivedTranscript(host, doc, data, options = {}) {
|
|
413
|
+
const incremental = createIncrementalTranscript(host, doc, data.stream, options);
|
|
414
|
+
// A page-level retention gap precedes the page's first chunk: note it before folding so a leading
|
|
415
|
+
// visible break renders (a reattach that dropped chunks never implies false continuity).
|
|
416
|
+
if (data.gap)
|
|
417
|
+
incremental.noteGap();
|
|
418
|
+
// The batch path folds the SAME chunks the live path does, in offset order, through the ONE projection,
|
|
419
|
+
// so historical and live rendering are byte-for-byte the same tree.
|
|
420
|
+
for (const chunk of [...data.entries].sort((a, b) => a.offset - b.offset))
|
|
421
|
+
incremental.applyChunk(chunk);
|
|
422
|
+
return { root: incremental.root };
|
|
260
423
|
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// @generated from node_modules/@nanobpm/agentic/dist/transcript/display.js by scripts/build-cockpit-browser.ts — DO NOT EDIT.
|
|
2
|
+
//
|
|
3
|
+
// Browser ESM derived (type-strip only) from the typed transcript core so pages/cockpit/mount.js
|
|
4
|
+
// renders the agentic transcript from ONE source of truth (#660). Regenerate with:
|
|
5
|
+
// node --experimental-strip-types scripts/build-cockpit-browser.ts
|
|
6
|
+
|
|
7
|
+
const NOOP = Object.freeze({ appended: false });
|
|
8
|
+
/** Recursively freeze a value already owned exclusively by the caller (a fresh clone), so no consumer
|
|
9
|
+
* can mutate it at any depth. Idempotent, and a no-op for primitives and already-frozen objects. A
|
|
10
|
+
* `seen` set guards against cyclic references so an arbitrarily-shaped `args` payload with a cycle
|
|
11
|
+
* freezes without recursing forever. */
|
|
12
|
+
function deepFreeze(value, seen = new WeakSet()) {
|
|
13
|
+
if (value === null || typeof value !== "object" || Object.isFrozen(value) || seen.has(value))
|
|
14
|
+
return;
|
|
15
|
+
seen.add(value);
|
|
16
|
+
Object.freeze(value);
|
|
17
|
+
for (const nested of Object.values(value))
|
|
18
|
+
deepFreeze(nested, seen);
|
|
19
|
+
}
|
|
20
|
+
/** Decouple a producer-owned, arbitrarily-shaped `args` value from projection state: deep clone it (so
|
|
21
|
+
* the returned snapshot shares no mutable reference) then deep-freeze the clone. When `structuredClone`
|
|
22
|
+
* is unavailable or `args` is non-cloneable (e.g. it contains functions), fall back to deep-freezing
|
|
23
|
+
* `args` *in place* rather than returning it unfrozen: a shared-by-reference fallback would let a
|
|
24
|
+
* consumer mutate `tool.args` back into the projection's internals, so freezing the shared object is
|
|
25
|
+
* what preserves the "cannot reach back" guarantee even when cloning fails. */
|
|
26
|
+
function freezeArgs(args) {
|
|
27
|
+
if (args === null || typeof args !== "object")
|
|
28
|
+
return args;
|
|
29
|
+
let cloned;
|
|
30
|
+
try {
|
|
31
|
+
cloned = structuredClone(args);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
deepFreeze(args);
|
|
35
|
+
return args;
|
|
36
|
+
}
|
|
37
|
+
deepFreeze(cloned);
|
|
38
|
+
return cloned;
|
|
39
|
+
}
|
|
40
|
+
/** Deep-freeze a {@link DerivedTool} into a snapshot decoupled from the projection's mutable internals:
|
|
41
|
+
* a shallow clone whose nested `result` is itself cloned + frozen and whose producer-owned `args` is
|
|
42
|
+
* deep cloned + frozen ({@link freezeArgs}), so a consumer that mutates the returned `tool` (or
|
|
43
|
+
* `tool.result` / `tool.args`) cannot reach back into projection state. */
|
|
44
|
+
function freezeTool(tool) {
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
...tool,
|
|
47
|
+
...(tool.args !== undefined ? { args: freezeArgs(tool.args) } : {}),
|
|
48
|
+
...(tool.result !== undefined ? { result: Object.freeze({ ...tool.result }) } : {}),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** Deep-freeze a {@link DerivedPermission} into a snapshot decoupled from the projection's mutable
|
|
52
|
+
* internals: a shallow clone whose nested `options` (and each option) and `resolved` are cloned +
|
|
53
|
+
* frozen, so a consumer cannot mutate projection state through the returned `permission`. */
|
|
54
|
+
function freezePermission(permission) {
|
|
55
|
+
return Object.freeze({
|
|
56
|
+
...permission,
|
|
57
|
+
options: Object.freeze(permission.options.map((option) => Object.freeze({ ...option }))),
|
|
58
|
+
...(permission.resolved !== undefined ? { resolved: Object.freeze({ ...permission.resolved }) } : {}),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function freezeBlock(block) {
|
|
62
|
+
switch (block.kind) {
|
|
63
|
+
case "text":
|
|
64
|
+
return Object.freeze({
|
|
65
|
+
kind: "text",
|
|
66
|
+
id: `text:${block.startOffset}`,
|
|
67
|
+
role: block.role,
|
|
68
|
+
...(block.messageId !== undefined ? { messageId: block.messageId } : {}),
|
|
69
|
+
text: block.text,
|
|
70
|
+
startOffset: block.startOffset,
|
|
71
|
+
endOffset: block.endOffset,
|
|
72
|
+
complete: block.complete,
|
|
73
|
+
});
|
|
74
|
+
case "tool":
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
kind: "tool",
|
|
77
|
+
id: `tool:${block.startOffset}`,
|
|
78
|
+
tool: freezeTool(block.tool),
|
|
79
|
+
startOffset: block.startOffset,
|
|
80
|
+
endOffset: block.endOffset,
|
|
81
|
+
});
|
|
82
|
+
case "permission":
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
kind: "permission",
|
|
85
|
+
id: `permission:${block.startOffset}`,
|
|
86
|
+
permission: freezePermission(block.permission),
|
|
87
|
+
startOffset: block.startOffset,
|
|
88
|
+
endOffset: block.endOffset,
|
|
89
|
+
});
|
|
90
|
+
case "gap":
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
kind: "gap",
|
|
93
|
+
id: `gap:${block.ordinal}`,
|
|
94
|
+
...(block.beforeOffset !== undefined ? { beforeOffset: block.beforeOffset } : {}),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function toolFromCall(event) {
|
|
99
|
+
return {
|
|
100
|
+
name: event.name,
|
|
101
|
+
offset: event.offset,
|
|
102
|
+
...(event.callId !== undefined ? { callId: event.callId } : {}),
|
|
103
|
+
...(event.args !== undefined ? { args: event.args } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function toolWithResult(tool, result) {
|
|
107
|
+
return {
|
|
108
|
+
...tool,
|
|
109
|
+
result: {
|
|
110
|
+
ok: result.ok,
|
|
111
|
+
offset: result.offset,
|
|
112
|
+
...(result.content !== undefined ? { content: result.content } : {}),
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function permissionFromRequest(event) {
|
|
117
|
+
return {
|
|
118
|
+
callId: event.callId,
|
|
119
|
+
policy: event.policy,
|
|
120
|
+
options: event.options,
|
|
121
|
+
offset: event.offset,
|
|
122
|
+
...(event.toolName !== undefined ? { toolName: event.toolName } : {}),
|
|
123
|
+
...(event.title !== undefined ? { title: event.title } : {}),
|
|
124
|
+
...(event.reason !== undefined ? { reason: event.reason } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function permissionWithResolution(permission, resolution) {
|
|
128
|
+
return {
|
|
129
|
+
...permission,
|
|
130
|
+
resolved: {
|
|
131
|
+
allowed: resolution.allowed,
|
|
132
|
+
optionId: resolution.optionId,
|
|
133
|
+
offset: resolution.offset,
|
|
134
|
+
...(resolution.by !== undefined ? { by: resolution.by } : {}),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export function createDisplayProjection() {
|
|
139
|
+
const blocks = [];
|
|
140
|
+
const openTools = new Map();
|
|
141
|
+
let anonymousTool;
|
|
142
|
+
const openPermissions = new Map();
|
|
143
|
+
// The append-order idempotency key: the highest offset ever folded. Any event at or below it was
|
|
144
|
+
// already applied (a replayed/duplicated chunk), so it is a no-op — this is what keeps replay,
|
|
145
|
+
// reconnect and pagination overlap from doubling text. Starts at -1 so offset 0 applies.
|
|
146
|
+
let lastOffset = -1;
|
|
147
|
+
// A pending gap awaiting the offset of the next block, so a consumer can anchor the break.
|
|
148
|
+
let pendingGapBlock;
|
|
149
|
+
let gapOrdinal = 0;
|
|
150
|
+
/** The active text block a delta may extend: the LAST block, iff it is an open text block. Any other
|
|
151
|
+
* trailing block (a tool card, a permission, a gap) means there is no open text run to coalesce into. */
|
|
152
|
+
const activeText = () => {
|
|
153
|
+
const tail = blocks[blocks.length - 1];
|
|
154
|
+
return tail !== undefined && tail.kind === "text" && !tail.complete ? tail : undefined;
|
|
155
|
+
};
|
|
156
|
+
const closeActiveText = () => {
|
|
157
|
+
const active = activeText();
|
|
158
|
+
if (active !== undefined)
|
|
159
|
+
active.complete = true;
|
|
160
|
+
};
|
|
161
|
+
/** Anchor a not-yet-anchored gap to the first block that opens after it, returning the now-anchored gap
|
|
162
|
+
* (frozen) so the triggering {@link apply} can surface it as {@link DisplayApplyResult.anchored} — else
|
|
163
|
+
* `undefined` when there is no pending gap. */
|
|
164
|
+
const anchorGap = (offset) => {
|
|
165
|
+
if (pendingGapBlock === undefined)
|
|
166
|
+
return undefined;
|
|
167
|
+
pendingGapBlock.beforeOffset = offset;
|
|
168
|
+
const anchored = freezeBlock(pendingGapBlock);
|
|
169
|
+
pendingGapBlock = undefined;
|
|
170
|
+
return anchored;
|
|
171
|
+
};
|
|
172
|
+
const applyMessage = (event) => {
|
|
173
|
+
const active = activeText();
|
|
174
|
+
// A delta may extend the active block only when it is the SAME speaker AND the SAME logical message
|
|
175
|
+
// AND the producer did not force a new block with `start`. Message identity: if both sides carry a
|
|
176
|
+
// `messageId` they must match; a changed id (or one side having an id the other lacks) is a distinct
|
|
177
|
+
// message. With no ids on either side, the fallback is purely structural — adjacent + same speaker.
|
|
178
|
+
const idsMatch = event.messageId !== undefined || active?.messageId !== undefined
|
|
179
|
+
? event.messageId === active?.messageId
|
|
180
|
+
: true;
|
|
181
|
+
const canExtend = active !== undefined && event.start !== true && active.role === event.role && idsMatch;
|
|
182
|
+
if (canExtend && active !== undefined) {
|
|
183
|
+
// Snapshot REPLACES the accumulated text; a delta APPENDS exactly (no separator).
|
|
184
|
+
active.text = event.mode === "snapshot" ? event.text : active.text + event.text;
|
|
185
|
+
active.endOffset = event.offset;
|
|
186
|
+
if (event.final === true)
|
|
187
|
+
active.complete = true;
|
|
188
|
+
return { changed: freezeBlock(active), appended: false };
|
|
189
|
+
}
|
|
190
|
+
// Open a fresh block. (A `start`/id-change/role-change also closes any still-open predecessor so the
|
|
191
|
+
// next unrelated delta cannot re-open it.)
|
|
192
|
+
closeActiveText();
|
|
193
|
+
const anchored = anchorGap(event.offset);
|
|
194
|
+
const block = {
|
|
195
|
+
kind: "text",
|
|
196
|
+
role: event.role,
|
|
197
|
+
text: event.text,
|
|
198
|
+
startOffset: event.offset,
|
|
199
|
+
endOffset: event.offset,
|
|
200
|
+
complete: event.final === true,
|
|
201
|
+
...(event.messageId !== undefined ? { messageId: event.messageId } : {}),
|
|
202
|
+
};
|
|
203
|
+
blocks.push(block);
|
|
204
|
+
return { changed: freezeBlock(block), appended: true, ...(anchored !== undefined ? { anchored } : {}) };
|
|
205
|
+
};
|
|
206
|
+
const applyToolCall = (event) => {
|
|
207
|
+
// A tool call interrupts any running text: it becomes the trailing block, so a later delta opens a
|
|
208
|
+
// new text block rather than coalescing across the tool.
|
|
209
|
+
closeActiveText();
|
|
210
|
+
const anchored = anchorGap(event.offset);
|
|
211
|
+
const block = {
|
|
212
|
+
kind: "tool",
|
|
213
|
+
tool: toolFromCall(event),
|
|
214
|
+
startOffset: event.offset,
|
|
215
|
+
endOffset: event.offset,
|
|
216
|
+
};
|
|
217
|
+
blocks.push(block);
|
|
218
|
+
const pending = { block };
|
|
219
|
+
if (event.callId !== undefined)
|
|
220
|
+
openTools.set(event.callId, pending);
|
|
221
|
+
else
|
|
222
|
+
anonymousTool = pending;
|
|
223
|
+
return { changed: freezeBlock(block), appended: true, ...(anchored !== undefined ? { anchored } : {}) };
|
|
224
|
+
};
|
|
225
|
+
const applyToolResult = (event) => {
|
|
226
|
+
const pending = event.callId !== undefined ? openTools.get(event.callId) : anonymousTool;
|
|
227
|
+
if (pending === undefined)
|
|
228
|
+
return NOOP; // A result with no open call — nothing to pair (never invents a card).
|
|
229
|
+
pending.block.tool = toolWithResult(pending.block.tool, event);
|
|
230
|
+
pending.block.endOffset = event.offset;
|
|
231
|
+
if (event.callId !== undefined)
|
|
232
|
+
openTools.delete(event.callId);
|
|
233
|
+
else
|
|
234
|
+
anonymousTool = undefined;
|
|
235
|
+
return { changed: freezeBlock(pending.block), appended: false };
|
|
236
|
+
};
|
|
237
|
+
const applyPermissionRequest = (event) => {
|
|
238
|
+
closeActiveText();
|
|
239
|
+
const anchored = anchorGap(event.offset);
|
|
240
|
+
const block = {
|
|
241
|
+
kind: "permission",
|
|
242
|
+
permission: permissionFromRequest(event),
|
|
243
|
+
startOffset: event.offset,
|
|
244
|
+
endOffset: event.offset,
|
|
245
|
+
};
|
|
246
|
+
blocks.push(block);
|
|
247
|
+
openPermissions.set(event.callId, { block });
|
|
248
|
+
return { changed: freezeBlock(block), appended: true, ...(anchored !== undefined ? { anchored } : {}) };
|
|
249
|
+
};
|
|
250
|
+
const applyPermissionResolution = (event) => {
|
|
251
|
+
const pending = openPermissions.get(event.callId);
|
|
252
|
+
if (pending === undefined)
|
|
253
|
+
return NOOP;
|
|
254
|
+
pending.block.permission = permissionWithResolution(pending.block.permission, event);
|
|
255
|
+
pending.block.endOffset = event.offset;
|
|
256
|
+
openPermissions.delete(event.callId);
|
|
257
|
+
return { changed: freezeBlock(pending.block), appended: false };
|
|
258
|
+
};
|
|
259
|
+
const apply = (event) => {
|
|
260
|
+
// Idempotency gate: this projection requires events in strictly increasing `offset` order, so any
|
|
261
|
+
// offset at or below the high-water mark is treated as already folded (replay / reconnect /
|
|
262
|
+
// pagination overlap / a duplicated chunk) and re-applying it must not change anything. A genuinely
|
|
263
|
+
// out-of-order event (offset <= lastOffset arriving late) is likewise dropped here, not merged — a
|
|
264
|
+
// caller seeing a "missing" block must re-feed the stream in order rather than read it as deduped.
|
|
265
|
+
if (event.offset <= lastOffset)
|
|
266
|
+
return NOOP;
|
|
267
|
+
lastOffset = event.offset;
|
|
268
|
+
switch (event.kind) {
|
|
269
|
+
case "message":
|
|
270
|
+
return applyMessage(event);
|
|
271
|
+
case "tool-call":
|
|
272
|
+
return applyToolCall(event);
|
|
273
|
+
case "tool-result":
|
|
274
|
+
return applyToolResult(event);
|
|
275
|
+
case "permission":
|
|
276
|
+
return event.phase === "request" ? applyPermissionRequest(event) : applyPermissionResolution(event);
|
|
277
|
+
case "turn":
|
|
278
|
+
// An explicit turn boundary closes the running message so the next turn's text starts fresh.
|
|
279
|
+
closeActiveText();
|
|
280
|
+
return NOOP;
|
|
281
|
+
// A `step`, a `lifecycle` transition, and a raw `stream-chunk` do not themselves produce a display
|
|
282
|
+
// block and do not break text coalescing (raw bytes render on the separate byte-terminal plane).
|
|
283
|
+
case "step":
|
|
284
|
+
case "lifecycle":
|
|
285
|
+
case "stream-chunk":
|
|
286
|
+
return NOOP;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
return {
|
|
290
|
+
apply,
|
|
291
|
+
applyAll(events) {
|
|
292
|
+
for (const event of events)
|
|
293
|
+
apply(event);
|
|
294
|
+
},
|
|
295
|
+
noteGap() {
|
|
296
|
+
closeActiveText();
|
|
297
|
+
const block = { kind: "gap", ordinal: gapOrdinal++ };
|
|
298
|
+
blocks.push(block);
|
|
299
|
+
pendingGapBlock = block;
|
|
300
|
+
return { changed: freezeBlock(block), appended: true };
|
|
301
|
+
},
|
|
302
|
+
blocks() {
|
|
303
|
+
return blocks.map(freezeBlock);
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The pure batch convenience: fold a whole run of offset-ordered events into the ordered display blocks
|
|
309
|
+
* in one call, over a fresh {@link createDisplayProjection}. Duplicate offsets in the input are deduped
|
|
310
|
+
* by the same idempotency gate, so a replayed slice folds to the same result as a gap-free one.
|
|
311
|
+
*/
|
|
312
|
+
export function deriveDisplay(events) {
|
|
313
|
+
const projection = createDisplayProjection();
|
|
314
|
+
projection.applyAll(events);
|
|
315
|
+
return projection.blocks();
|
|
316
|
+
}
|