@nanobpm/agentic 0.6.0 → 0.8.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.
@@ -3,6 +3,7 @@ import { test } from "node:test";
3
3
 
4
4
  import { decodeFrame, encodeFrame, type Frame } from "../protocol/index.ts";
5
5
  import type { DemandSupplyReport } from "../demand/index.ts";
6
+ import { type TranscriptEvent, encodeTranscriptEvent } from "../transcript/index.ts";
6
7
 
7
8
  import { bootCockpit, type CockpitEnv } from "./boot.ts";
8
9
  import { FakeDocument, FakeElement } from "./fake-dom.ts";
@@ -64,8 +65,11 @@ interface Rig {
64
65
  readonly host: FakeElement;
65
66
  readonly sockets: FakeSocket[];
66
67
  readonly terminalWrites: string[];
68
+ readonly structuredEvents: TranscriptEvent[];
67
69
  terminalMounts: number;
68
70
  terminalDisposes: number;
71
+ structuredMounts: number;
72
+ structuredDisposes: number;
69
73
  readonly timers: Array<{ run: () => void; ms: number }>;
70
74
  reconnect: (() => void) | undefined;
71
75
  report: DemandSupplyReport;
@@ -76,13 +80,17 @@ function rig(): Rig {
76
80
  const host = new FakeElement("body");
77
81
  const sockets: FakeSocket[] = [];
78
82
  const terminalWrites: string[] = [];
83
+ const structuredEvents: TranscriptEvent[] = [];
79
84
  const timers: Array<{ run: () => void; ms: number }> = [];
80
85
  const state: Rig = {
81
86
  host,
82
87
  sockets,
83
88
  terminalWrites,
89
+ structuredEvents,
84
90
  terminalMounts: 0,
85
91
  terminalDisposes: 0,
92
+ structuredMounts: 0,
93
+ structuredDisposes: 0,
86
94
  timers,
87
95
  reconnect: undefined,
88
96
  report: served,
@@ -106,6 +114,16 @@ function rig(): Rig {
106
114
  },
107
115
  };
108
116
  },
117
+ createStructured: (structuredHost) => {
118
+ state.structuredMounts += 1;
119
+ structuredHost.appendChild(new FakeElement("div"));
120
+ return {
121
+ event: (event) => structuredEvents.push(event),
122
+ dispose: () => {
123
+ state.structuredDisposes += 1;
124
+ },
125
+ };
126
+ },
109
127
  schedule: (run) => {
110
128
  state.reconnect = run;
111
129
  },
@@ -172,6 +190,102 @@ test("relay output is written to the drilled worker's terminal", async () => {
172
190
  r.sockets[0]?.fireOpen();
173
191
  r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "ci-a", offset: 0, chunk: "boot\n" } });
174
192
  assert.deepEqual(r.terminalWrites, ["boot\n"]);
193
+ assert.deepEqual(r.structuredEvents, [], "a raw stream routes nothing to the structured view");
194
+ });
195
+
196
+ function structuredFrame(seq: number, offset: number, event: TranscriptEvent): Frame {
197
+ return { lane: "bulk", family: "relay", seq, payload: { stream: "ci-a", offset, chunk: encodeTranscriptEvent(event) } };
198
+ }
199
+
200
+ test("a structured (marker-tagged) stream routes to the structured view, NOT the byte-terminal", async () => {
201
+ const r = rig();
202
+ const cockpit = bootCockpit(r.env);
203
+ await cockpit.refresh();
204
+ cockpit.drill("ci-a");
205
+ r.sockets[0]?.fireOpen();
206
+ r.sockets[0]?.deliver(structuredFrame(0, 0, { kind: "turn", offset: 0, index: 0 }));
207
+ r.sockets[0]?.deliver(structuredFrame(1, 1, { kind: "message", offset: 1, role: "assistant", text: "hi" }));
208
+ assert.deepEqual(r.terminalWrites, [], "structured chunks are not dumped into the byte-terminal");
209
+ assert.deepEqual(
210
+ r.structuredEvents.map((e) => e.kind),
211
+ ["turn", "message"],
212
+ "the decoded transcript events reach the structured view",
213
+ );
214
+ });
215
+
216
+ test("a mixed stream routes each chunk to the right surface (raw → terminal, tagged → structured)", async () => {
217
+ const r = rig();
218
+ const cockpit = bootCockpit(r.env);
219
+ await cockpit.refresh();
220
+ cockpit.drill("ci-a");
221
+ r.sockets[0]?.fireOpen();
222
+ r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "ci-a", offset: 0, chunk: "booting\n" } });
223
+ r.sockets[0]?.deliver(structuredFrame(1, 1, { kind: "message", offset: 1, role: "assistant", text: "ready" }));
224
+ r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 2, payload: { stream: "ci-a", offset: 2, chunk: "tail\n" } });
225
+ assert.deepEqual(r.terminalWrites, ["booting\n", "tail\n"]);
226
+ assert.deepEqual(
227
+ r.structuredEvents.map((e) => (e.kind === "message" ? e.text : e.kind)),
228
+ ["ready"],
229
+ );
230
+ });
231
+
232
+ test("the structured view survives a cockpit reconnect — resume-from-offset, no loss, no dup", async () => {
233
+ const r = rig();
234
+ const cockpit = bootCockpit(r.env);
235
+ await cockpit.refresh();
236
+ cockpit.drill("ci-a");
237
+
238
+ const s1 = r.sockets[0];
239
+ s1?.fireOpen();
240
+ s1?.deliver(structuredFrame(0, 0, { kind: "message", offset: 0, role: "assistant", text: "a" }));
241
+ s1?.deliver(structuredFrame(1, 1, { kind: "message", offset: 1, role: "assistant", text: "b" }));
242
+
243
+ // The cockpit's socket drops; the client reconnects.
244
+ s1?.fireClose();
245
+ assert.ok(r.reconnect !== undefined, "a reconnect was scheduled");
246
+ r.reconnect?.();
247
+ const s2 = r.sockets[1];
248
+ s2?.fireOpen(); // re-attach → resume from offset 2
249
+ const subs = s2?.subscribeFrames() ?? [];
250
+ assert.deepEqual(subs.at(-1)?.payload, { op: "subscribe", stream: "ci-a", from: 2, credit: 1024 });
251
+
252
+ // The hub replays the retained tail (re-sends offset 1) then continues.
253
+ s2?.deliver(structuredFrame(0, 1, { kind: "message", offset: 1, role: "assistant", text: "b" }));
254
+ s2?.deliver(structuredFrame(1, 2, { kind: "message", offset: 2, role: "assistant", text: "c" }));
255
+ assert.deepEqual(
256
+ r.structuredEvents.map((e) => (e.kind === "message" ? e.text : e.kind)),
257
+ ["a", "b", "c"],
258
+ "no dropped and no duplicated structured events across the reconnect",
259
+ );
260
+ });
261
+
262
+ test("the built-in structured renderer derives into the structured host when none is injected", async () => {
263
+ const r = rig();
264
+ // Drop the custom createStructured so the default DOM renderer is exercised end-to-end.
265
+ const { createStructured: _drop, ...envWithoutStructured } = r.env;
266
+ const cockpit = bootCockpit(envWithoutStructured);
267
+ await cockpit.refresh();
268
+ cockpit.drill("ci-a");
269
+ r.sockets[0]?.fireOpen();
270
+ r.sockets[0]?.deliver(structuredFrame(0, 0, { kind: "message", offset: 0, role: "assistant", text: "hello" }));
271
+ assert.deepEqual(r.terminalWrites, [], "structured chunk is not dumped as raw");
272
+ const rendered = r.host.byClass("cockpit-structured-message");
273
+ assert.equal(rendered.length, 1, "the built-in structured renderer rendered the derived message");
274
+ assert.match(rendered[0]?.text() ?? "", /hello/);
275
+ });
276
+
277
+ test("switching streams disposes the prior structured view", async () => {
278
+ const r = rig();
279
+ const cockpit = bootCockpit(r.env);
280
+ await cockpit.refresh();
281
+ cockpit.drill("ci-a");
282
+ assert.equal(r.structuredMounts, 1);
283
+ assert.equal(r.structuredDisposes, 0);
284
+ cockpit.drill("ci-b");
285
+ assert.equal(r.structuredMounts, 2);
286
+ assert.equal(r.structuredDisposes, 1, "prior structured view disposed on stream switch");
287
+ cockpit.dispose();
288
+ assert.equal(r.structuredDisposes, 2, "the live structured view is disposed on dispose()");
175
289
  });
176
290
 
177
291
  test("the terminal survives a matrix refresh — it is not re-mounted and keeps streaming", async () => {
@@ -21,12 +21,16 @@ import type { DemandSupplyReport } from "../demand/index.ts";
21
21
  import { isPosInt } from "../relay/index.ts";
22
22
  import { RelayChannelClient, type Scheduler, type SocketFactory } from "./relay-client.ts";
23
23
  import { type DocumentLike, type ElementLike, renderCockpit } from "./render.ts";
24
- import { TerminalSession, type TerminalSink } from "./terminal-session.ts";
24
+ import { createStructuredSink } from "./structured-view.ts";
25
+ import { type StructuredSink, TerminalSession, type TerminalSink } from "./terminal-session.ts";
25
26
  import { cockpitView } from "./view.ts";
26
27
 
27
28
  /** Mounts a terminal into `host` and returns the sink relay output is written to. */
28
29
  export type CreateTerminal = (host: ElementLike) => TerminalSink;
29
30
 
31
+ /** Mounts a structured (ACP) view into `host` and returns the sink decoded transcript events are routed to. */
32
+ export type CreateStructured = (host: ElementLike) => StructuredSink;
33
+
30
34
  /** An opaque poll-timer handle (a Node `Timeout` or a browser timer id). */
31
35
  export type TimerHandle = unknown;
32
36
 
@@ -41,6 +45,14 @@ export interface CockpitEnv {
41
45
  readonly connectRelay: SocketFactory;
42
46
  /** Mounts the terminal widget (xterm.js in the browser) and returns its write sink. */
43
47
  readonly createTerminal: CreateTerminal;
48
+ /**
49
+ * Mounts the structured (ACP) view widget and returns its event sink. Optional:
50
+ * when omitted the drill-in uses the built-in {@link createStructuredSink} DOM
51
+ * renderer over {@link doc}, so marker-tagged chunks are decoded and routed to
52
+ * that structured surface while raw bytes still flow to the {@link createTerminal}
53
+ * sink. Provide your own to override the built-in renderer.
54
+ */
55
+ readonly createStructured?: CreateStructured;
44
56
  /** Reconnect scheduler for the relay client. Default `setTimeout(run, 0)`. */
45
57
  readonly schedule?: Scheduler;
46
58
  /** Poll scheduler. Default `setTimeout`. Injected so tests drive it by hand. Must be paired with {@link clearTimer}. */
@@ -82,6 +94,8 @@ class Cockpit implements CockpitHandle {
82
94
  readonly #env: CockpitEnv;
83
95
  readonly #matrixRegion: ElementLike;
84
96
  readonly #terminalHost: ElementLike;
97
+ readonly #structuredHost: ElementLike;
98
+ readonly #createStructured: CreateStructured;
85
99
  readonly #refreshMs: number;
86
100
  readonly #setTimer: (run: () => void, ms: number) => TimerHandle;
87
101
  readonly #clearTimer: (handle: TimerHandle) => void;
@@ -94,6 +108,9 @@ class Cockpit implements CockpitHandle {
94
108
  // The currently mounted terminal, tracked so switching streams (and dispose)
95
109
  // tears down the prior xterm instance instead of leaking it + its listeners.
96
110
  #terminal: TerminalSink | undefined;
111
+ // The currently mounted structured view, torn down alongside #terminal so a
112
+ // stream switch / dispose never leaks the prior worker's structured widget.
113
+ #structured: StructuredSink | undefined;
97
114
  // Bumped by every start()/stop() so an in-flight #tick() from a previous
98
115
  // start cycle can't reschedule after a stop→start race and leave two
99
116
  // overlapping poll chains running against the same cockpit.
@@ -142,6 +159,11 @@ class Cockpit implements CockpitHandle {
142
159
  }
143
160
  });
144
161
 
162
+ // The structured (ACP) view mounter defaults to the built-in DOM renderer over
163
+ // the injected document, so a structured stream derives + renders without any
164
+ // extra wiring; a browser caller may override it (e.g. a richer widget).
165
+ this.#createStructured = env.createStructured ?? ((host) => createStructuredSink(host, env.doc));
166
+
145
167
  // Build the stable skeleton once: a volatile matrix region the poll
146
168
  // re-renders, and a PERSISTENT terminal region a refresh never touches.
147
169
  env.host.replaceChildren();
@@ -159,6 +181,14 @@ class Cockpit implements CockpitHandle {
159
181
  this.#terminalHost.className = "cockpit-terminal-host";
160
182
  this.#terminalHost.setAttribute("data-terminal", "host");
161
183
  terminalPanel.appendChild(this.#terminalHost);
184
+ // A sibling PERSISTENT region for the derived structured (ACP) view. A raw
185
+ // stream keeps it in its initial/empty structured state; a structured stream
186
+ // renders here instead of dumping JSON into the byte-terminal; a mixed stream
187
+ // feeds both.
188
+ this.#structuredHost = env.doc.createElement("div");
189
+ this.#structuredHost.className = "cockpit-structured-host";
190
+ this.#structuredHost.setAttribute("data-structured", "host");
191
+ terminalPanel.appendChild(this.#structuredHost);
162
192
  shell.appendChild(this.#matrixRegion);
163
193
  shell.appendChild(terminalPanel);
164
194
  env.host.appendChild(shell);
@@ -233,12 +263,18 @@ class Cockpit implements CockpitHandle {
233
263
  // still cleans it up on the next drill or on dispose().
234
264
  this.#terminal?.dispose?.();
235
265
  this.#terminal = undefined;
266
+ // The structured view is torn down in lockstep with the terminal.
267
+ this.#structured?.dispose?.();
268
+ this.#structured = undefined;
236
269
 
237
270
  try {
238
- // Fresh terminal for the newly selected worker.
271
+ // Fresh terminal + structured view for the newly selected worker.
239
272
  this.#terminalHost.replaceChildren();
240
273
  const sink = this.#env.createTerminal(this.#terminalHost);
241
274
  this.#terminal = sink;
275
+ this.#structuredHost.replaceChildren();
276
+ const structured = this.#createStructured(this.#structuredHost);
277
+ this.#structured = structured;
242
278
 
243
279
  let session: TerminalSession | undefined;
244
280
  const client = new RelayChannelClient({
@@ -253,6 +289,7 @@ class Cockpit implements CockpitHandle {
253
289
  session = new TerminalSession({
254
290
  stream,
255
291
  sink,
292
+ structured,
256
293
  send: (message) => client.sendRelay(message),
257
294
  credit: this.#env.credit,
258
295
  });
@@ -271,6 +308,8 @@ class Cockpit implements CockpitHandle {
271
308
  this.#drill = undefined;
272
309
  this.#terminal?.dispose?.();
273
310
  this.#terminal = undefined;
311
+ this.#structured?.dispose?.();
312
+ this.#structured = undefined;
274
313
  }
275
314
  }
276
315
 
@@ -34,6 +34,7 @@ export {
34
34
  type RelayInbound,
35
35
  type RelayOutbound,
36
36
  type RelaySend,
37
+ type StructuredSink,
37
38
  type TerminalSessionOptions,
38
39
  type TerminalSink,
39
40
  } from "./terminal-session.ts";
@@ -54,10 +55,17 @@ export {
54
55
  type RenderOptions,
55
56
  } from "./render.ts";
56
57
 
58
+ export {
59
+ createStructuredSink,
60
+ renderStructured,
61
+ type StructuredTerminal,
62
+ } from "./structured-view.ts";
63
+
57
64
  export {
58
65
  bootCockpit,
59
66
  type CockpitEnv,
60
67
  type CockpitHandle,
68
+ type CreateStructured,
61
69
  type CreateTerminal,
62
70
  type TimerHandle,
63
71
  } from "./boot.ts";
@@ -0,0 +1,70 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { type TranscriptEvent, deriveView } from "../transcript/index.ts";
5
+
6
+ import { FakeDocument, FakeElement } from "./fake-dom.ts";
7
+ import { createStructuredSink, renderStructured } from "./structured-view.ts";
8
+
9
+ function fixture(): { host: FakeElement; doc: FakeDocument } {
10
+ return { host: new FakeElement("div"), doc: new FakeDocument() };
11
+ }
12
+
13
+ test("renderStructured renders derived turns, messages and tool cards (not raw JSON)", () => {
14
+ const { host, doc } = fixture();
15
+ const events: TranscriptEvent[] = [
16
+ { kind: "turn", offset: 0, index: 0 },
17
+ { kind: "message", offset: 1, role: "user", text: "run the build" },
18
+ { kind: "message", offset: 2, role: "assistant", text: "on it" },
19
+ { kind: "tool-call", offset: 3, name: "shell", callId: "c1", args: { cmd: "build" } },
20
+ { kind: "tool-result", offset: 4, callId: "c1", ok: true, content: "done" },
21
+ { kind: "lifecycle", offset: 5, phase: "completed" },
22
+ ];
23
+ renderStructured(host, doc, deriveView(events));
24
+
25
+ const root = host.byClass("cockpit-structured")[0];
26
+ assert.ok(root, "a structured root is rendered");
27
+ assert.equal(root.getAttribute("data-lifecycle"), "completed");
28
+
29
+ const messages = host.byClass("cockpit-structured-message");
30
+ assert.equal(messages.length, 2);
31
+ assert.equal(messages[0]?.getAttribute("data-role"), "user");
32
+ assert.match(messages[0]?.text() ?? "", /run the build/);
33
+
34
+ const tool = host.byClass("cockpit-structured-tool")[0];
35
+ assert.ok(tool, "the tool card is rendered");
36
+ assert.equal(tool.getAttribute("data-tool"), "shell");
37
+ assert.equal(tool.getAttribute("data-state"), "ok");
38
+ assert.match(tool.text(), /done/);
39
+ });
40
+
41
+ test("renderStructured replaces prior content (idempotent re-render)", () => {
42
+ const { host, doc } = fixture();
43
+ renderStructured(host, doc, deriveView([{ kind: "message", offset: 0, role: "assistant", text: "one" }]));
44
+ renderStructured(host, doc, deriveView([{ kind: "message", offset: 0, role: "assistant", text: "two" }]));
45
+ assert.equal(host.byClass("cockpit-structured").length, 1, "no duplicated roots after a re-render");
46
+ assert.equal(host.byClass("cockpit-structured-message").length, 1);
47
+ assert.match(host.byClass("cockpit-structured-message")[0]?.text() ?? "", /two/);
48
+ });
49
+
50
+ test("createStructuredSink accumulates events and re-derives the view on each one", () => {
51
+ const { host, doc } = fixture();
52
+ const sink = createStructuredSink(host, doc);
53
+ // An empty view is present up-front, before any event.
54
+ assert.equal(host.byClass("cockpit-structured").length, 1);
55
+ assert.equal(host.byClass("cockpit-structured-message").length, 0);
56
+
57
+ sink.event({ kind: "message", offset: 0, role: "assistant", text: "first" });
58
+ sink.event({ kind: "message", offset: 1, role: "assistant", text: "second" });
59
+ const messages = host.byClass("cockpit-structured-message");
60
+ assert.equal(messages.length, 2, "both accumulated events are folded into the view");
61
+ assert.match(messages[1]?.text() ?? "", /second/);
62
+ });
63
+
64
+ test("createStructuredSink.dispose clears the accumulated log and the DOM", () => {
65
+ const { host, doc } = fixture();
66
+ const sink = createStructuredSink(host, doc);
67
+ sink.event({ kind: "message", offset: 0, role: "assistant", text: "hi" });
68
+ sink.dispose();
69
+ assert.equal(host.children.length, 0, "the structured host is emptied on dispose");
70
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The cockpit's structured-stream renderer — S8's drill-in for an ACP stream.
3
+ *
4
+ * When a drilled worker's relay stream is a **structured** ACP stream (its chunks
5
+ * are {@link TRANSCRIPT_EVENT_MARKER}-tagged transcript-event envelopes rather than
6
+ * raw PTY bytes), the {@link TerminalSession} routes each decoded event here instead
7
+ * of the byte-terminal. This renderer does **not** re-parse or pretty-print JSON: it
8
+ * feeds the accumulated typed events straight through the ONE canonical
9
+ * {@link deriveView} fold from `@nanobpm/agentic/transcript` and renders the resulting
10
+ * {@link DerivedView} (turns → messages + tool cards) into the DOM.
11
+ *
12
+ * Like {@link renderCockpit} it builds against the structural {@link ElementLike} /
13
+ * {@link DocumentLike} subset (not lib.dom), so it renders identically embedded and
14
+ * standalone, is unit-tested on Node with the in-memory fake and no `as` cast, and is
15
+ * browser-safe — it relies only on the browser-safe transcript vocab (no `Buffer`).
16
+ *
17
+ * Events arrive offset-keyed and immutable in offset order, so re-deriving the whole
18
+ * (idempotent) log on each event is correct across a resume-from-offset reconnect: a
19
+ * replayed chunk below the resume point never reaches this sink, so no event is
20
+ * dropped or double-applied.
21
+ */
22
+ import { type DerivedView, type TranscriptEvent, deriveView } from "../transcript/index.ts";
23
+ import type { DocumentLike, ElementLike } from "./render.ts";
24
+ import type { StructuredSink } from "./terminal-session.ts";
25
+
26
+ function el(doc: DocumentLike, tag: string, className?: string, text?: string): ElementLike {
27
+ const node = doc.createElement(tag);
28
+ if (className !== undefined) node.className = className;
29
+ if (text !== undefined) node.textContent = text;
30
+ return node;
31
+ }
32
+
33
+ function toolCard(doc: DocumentLike, tool: DerivedView["tools"][number]): ElementLike {
34
+ const card = el(doc, "div", "cockpit-structured-tool");
35
+ card.setAttribute("data-tool", tool.name);
36
+ card.setAttribute("data-offset", String(tool.offset));
37
+ card.setAttribute("data-state", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
38
+ const head = el(doc, "div", "cockpit-structured-tool-head");
39
+ head.appendChild(el(doc, "span", "cockpit-structured-tool-name", tool.name));
40
+ if (tool.callId !== undefined) head.appendChild(el(doc, "span", "cockpit-structured-tool-id", tool.callId));
41
+ card.appendChild(head);
42
+ if (tool.args !== undefined) {
43
+ card.appendChild(el(doc, "pre", "cockpit-structured-tool-args", JSON.stringify(tool.args)));
44
+ }
45
+ if (tool.result !== undefined) {
46
+ const result = el(doc, "div", "cockpit-structured-tool-result");
47
+ result.setAttribute("data-ok", tool.result.ok ? "true" : "false");
48
+ if (tool.result.content !== undefined) result.textContent = tool.result.content;
49
+ card.appendChild(result);
50
+ }
51
+ return card;
52
+ }
53
+
54
+ function turnSection(doc: DocumentLike, turn: DerivedView["turns"][number]): ElementLike {
55
+ const section = el(doc, "section", "cockpit-structured-turn");
56
+ section.setAttribute("data-turn", String(turn.index));
57
+ section.setAttribute("data-steps", String(turn.steps));
58
+ for (const message of turn.messages) {
59
+ const row = el(doc, "div", "cockpit-structured-message");
60
+ row.setAttribute("data-role", message.role);
61
+ row.setAttribute("data-offset", String(message.offset));
62
+ row.appendChild(el(doc, "span", "cockpit-structured-role", message.role));
63
+ row.appendChild(el(doc, "span", "cockpit-structured-text", message.text));
64
+ section.appendChild(row);
65
+ }
66
+ for (const tool of turn.tools) {
67
+ section.appendChild(toolCard(doc, tool));
68
+ }
69
+ return section;
70
+ }
71
+
72
+ /**
73
+ * Render a derived structured view into `host`, replacing whatever was there.
74
+ * Idempotent: re-call it with the latest {@link DerivedView} on every new event.
75
+ */
76
+ export function renderStructured(host: ElementLike, doc: DocumentLike, view: DerivedView): void {
77
+ host.replaceChildren();
78
+ const root = el(doc, "div", "cockpit-structured");
79
+ root.setAttribute("data-lifecycle", view.lifecycle);
80
+ root.setAttribute("data-events", String(view.eventCount));
81
+ for (const turn of view.turns) {
82
+ root.appendChild(turnSection(doc, turn));
83
+ }
84
+ host.appendChild(root);
85
+ }
86
+
87
+ /** A structured sink with a `dispose` teardown (mirrors {@link TerminalSink}). */
88
+ export interface StructuredTerminal extends StructuredSink {
89
+ dispose(): void;
90
+ }
91
+
92
+ /**
93
+ * Build a {@link StructuredSink} that accumulates the offset-ordered transcript
94
+ * events a structured stream delivers, folds them through the canonical
95
+ * {@link deriveView}, and renders the derived view into `host` on each event. The
96
+ * accumulated log is this sink's own state, so constructing one per drill-in gives
97
+ * each worker its own structured view.
98
+ */
99
+ export function createStructuredSink(host: ElementLike, doc: DocumentLike): StructuredTerminal {
100
+ const events: TranscriptEvent[] = [];
101
+ // Render an empty derived view up-front so the structured region is present and
102
+ // consistent before the first event lands.
103
+ renderStructured(host, doc, deriveView(events));
104
+ return {
105
+ event(event: TranscriptEvent): void {
106
+ events.push(event);
107
+ renderStructured(host, doc, deriveView(events));
108
+ },
109
+ dispose(): void {
110
+ events.length = 0;
111
+ host.replaceChildren();
112
+ },
113
+ };
114
+ }
@@ -2,23 +2,30 @@ import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
 
4
4
  import type { RelayPayload } from "../protocol/index.ts";
5
+ import { TRANSCRIPT_EVENT_MARKER, type TranscriptEvent, encodeTranscriptEvent } from "../transcript/index.ts";
5
6
 
6
- import { type RelayOutbound, TerminalSession } from "./terminal-session.ts";
7
+ import { type RelayOutbound, type StructuredSink, TerminalSession } from "./terminal-session.ts";
7
8
 
8
9
  interface Harness {
9
10
  readonly session: TerminalSession;
10
11
  readonly sent: RelayOutbound[];
11
12
  readonly writes: string[];
13
+ readonly events: TranscriptEvent[];
12
14
  data(offset: number, chunk: string, stream?: string): RelayPayload;
13
15
  }
14
16
 
15
- function harness(options: { from?: number; credit?: number; stream?: string } = {}): Harness {
17
+ function harness(options: { from?: number; credit?: number; stream?: string; structured?: boolean } = {}): Harness {
16
18
  const stream = options.stream ?? "worker-1";
17
19
  const sent: RelayOutbound[] = [];
18
20
  const writes: string[] = [];
21
+ const events: TranscriptEvent[] = [];
22
+ const structured: StructuredSink | undefined = options.structured
23
+ ? { event: (event) => events.push(event) }
24
+ : undefined;
19
25
  const session = new TerminalSession({
20
26
  stream,
21
27
  sink: { write: (chunk) => writes.push(chunk) },
28
+ structured,
22
29
  send: (message) => sent.push(message),
23
30
  from: options.from,
24
31
  credit: options.credit,
@@ -27,6 +34,7 @@ function harness(options: { from?: number; credit?: number; stream?: string } =
27
34
  session,
28
35
  sent,
29
36
  writes,
37
+ events,
30
38
  data: (offset, chunk, s = stream) => ({ stream: s, offset, chunk }),
31
39
  };
32
40
  }
@@ -250,3 +258,88 @@ test("grant rejects a non-positive or unsafe credit", () => {
250
258
  }
251
259
  });
252
260
 
261
+ // A marker-tagged chunk (encoded via the ONE canonical grammar, never a hand-rolled marker literal).
262
+ function structuredChunk(event: TranscriptEvent): string {
263
+ return encodeTranscriptEvent(event);
264
+ }
265
+
266
+ test("a structured (marker-tagged) chunk routes to the structured sink, NOT the byte-terminal", () => {
267
+ const h = harness({ structured: true });
268
+ h.session.attach();
269
+ h.session.handle(h.data(0, structuredChunk({ kind: "message", offset: 0, role: "assistant", text: "hi" })));
270
+ assert.deepEqual(h.writes, [], "a structured chunk must not be dumped into the byte-terminal");
271
+ assert.equal(h.events.length, 1);
272
+ assert.deepEqual(h.events[0], { kind: "message", offset: 0, role: "assistant", text: "hi" });
273
+ assert.equal(h.session.nextOffset, 1, "the resume offset advances for a structured chunk too");
274
+ });
275
+
276
+ test("a raw (untagged) chunk still renders on the byte-terminal even when a structured sink is wired", () => {
277
+ const h = harness({ structured: true });
278
+ h.session.attach();
279
+ h.session.handle(h.data(0, "plain bytes\n"));
280
+ assert.deepEqual(h.writes, ["plain bytes\n"]);
281
+ assert.deepEqual(h.events, [], "raw bytes never reach the structured sink");
282
+ assert.equal(h.session.nextOffset, 1);
283
+ });
284
+
285
+ test("with no structured sink a marker-tagged chunk is written verbatim (legacy raw-only behaviour)", () => {
286
+ const h = harness();
287
+ h.session.attach();
288
+ const chunk = structuredChunk({ kind: "message", offset: 0, role: "assistant", text: "hi" });
289
+ h.session.handle(h.data(0, chunk));
290
+ assert.deepEqual(h.writes, [chunk], "without a structured sink the envelope falls through to the byte-terminal");
291
+ assert.equal(h.session.nextOffset, 1);
292
+ });
293
+
294
+ test("a mixed stream that starts raw and only later carries tagged chunks routes each chunk correctly", () => {
295
+ const h = harness({ structured: true });
296
+ h.session.attach();
297
+ h.session.handle(h.data(0, "booting...\n"));
298
+ h.session.handle(h.data(1, structuredChunk({ kind: "turn", offset: 1, index: 0 })));
299
+ h.session.handle(h.data(2, structuredChunk({ kind: "message", offset: 2, role: "assistant", text: "done" })));
300
+ h.session.handle(h.data(3, "trailing raw\n"));
301
+ assert.deepEqual(h.writes, ["booting...\n", "trailing raw\n"], "raw chunks land on the terminal");
302
+ assert.deepEqual(
303
+ h.events.map((e) => e.kind),
304
+ ["turn", "message"],
305
+ "only the tagged chunks reach the structured sink",
306
+ );
307
+ assert.equal(h.session.nextOffset, 4);
308
+ });
309
+
310
+ test("a marker-tagged but malformed envelope falls back to raw bytes (byte-terminal), never the structured sink", () => {
311
+ // A chunk mentioning the marker but with an unknown/rejected body must be
312
+ // retained verbatim for byte-replay fidelity, not routed as a structured event.
313
+ const h = harness({ structured: true });
314
+ h.session.attach();
315
+ const malformed = JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: 1, kind: "message" }); // no text → decoder rejects
316
+ h.session.handle(h.data(0, malformed));
317
+ assert.deepEqual(h.writes, [malformed]);
318
+ assert.deepEqual(h.events, []);
319
+ assert.equal(h.session.nextOffset, 1);
320
+ });
321
+
322
+ test("a structured reconnect resumes from nextOffset — no dropped and no double-applied events", () => {
323
+ const h = harness({ structured: true });
324
+ h.session.attach();
325
+ h.session.handle(h.data(0, structuredChunk({ kind: "turn", offset: 0, index: 0 })));
326
+ h.session.handle(h.data(1, structuredChunk({ kind: "message", offset: 1, role: "assistant", text: "a" })));
327
+ assert.equal(h.session.nextOffset, 2);
328
+
329
+ // Socket drops; the client reconnects → re-attach resumes from offset 2.
330
+ h.session.attach();
331
+ assert.deepEqual(h.sent[1], { op: "subscribe", stream: "worker-1", from: 2, credit: 1024 });
332
+
333
+ // The hub replays the retained tail (re-sends offset 1) then continues. The
334
+ // replayed event is below nextOffset and must be dropped (no double-apply);
335
+ // the fresh event applies exactly once (no loss).
336
+ h.session.handle(h.data(1, structuredChunk({ kind: "message", offset: 1, role: "assistant", text: "a" })));
337
+ h.session.handle(h.data(2, structuredChunk({ kind: "message", offset: 2, role: "assistant", text: "b" })));
338
+ assert.deepEqual(
339
+ h.events.map((e) => (e.kind === "message" ? e.text : e.kind)),
340
+ ["turn", "a", "b"],
341
+ "no dropped and no duplicated structured events across the reconnect",
342
+ );
343
+ assert.equal(h.session.nextOffset, 3);
344
+ });
345
+