@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.
- package/README.md +58 -1
- package/dist/cockpit/boot.d.ts +11 -1
- package/dist/cockpit/boot.js +28 -1
- package/dist/cockpit/index.d.ts +3 -2
- package/dist/cockpit/index.js +1 -0
- package/dist/cockpit/structured-view.d.ts +41 -0
- package/dist/cockpit/structured-view.js +106 -0
- package/dist/cockpit/terminal-session.d.ts +37 -1
- package/dist/cockpit/terminal-session.js +27 -3
- package/dist/transcript/events.d.ts +213 -0
- package/dist/transcript/events.js +322 -0
- package/dist/transcript/index.d.ts +8 -0
- package/dist/transcript/index.js +7 -0
- package/package.json +1 -1
- package/src/cockpit/boot.test.ts +114 -0
- package/src/cockpit/boot.ts +41 -2
- package/src/cockpit/index.ts +8 -0
- package/src/cockpit/structured-view.test.ts +70 -0
- package/src/cockpit/structured-view.ts +114 -0
- package/src/cockpit/terminal-session.test.ts +95 -2
- package/src/cockpit/terminal-session.ts +65 -4
- package/src/transcript/events.drift.test.ts +68 -0
- package/src/transcript/events.test.ts +308 -0
- package/src/transcript/events.ts +490 -0
- package/src/transcript/index.ts +38 -0
package/src/cockpit/boot.test.ts
CHANGED
|
@@ -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 () => {
|
package/src/cockpit/boot.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
package/src/cockpit/index.ts
CHANGED
|
@@ -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
|
+
|