@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.
@@ -19,9 +19,25 @@
19
19
  * - inbound `{ op: "subscribed", stream, gap, nextOffset }` — the resume ack
20
20
  * (`gap: boolean` — the S5 wire flags whether chunks aged out),
21
21
  * - inbound {@link RelayPayload} `{ stream, offset, chunk }` — a data chunk.
22
+ *
23
+ * ## Structured (ACP) vs. raw streams
24
+ *
25
+ * A relay stream is either a **raw** byte stream (PTY output — arbitrary bytes) or
26
+ * a **structured** ACP stream whose chunks are {@link TRANSCRIPT_EVENT_MARKER}-tagged
27
+ * JSON envelopes (the transcript-event vocabulary) riding the *same*
28
+ * `{ stream, offset, chunk }` frames. The session classifies **each chunk** through
29
+ * the one canonical {@link parseTranscriptEvent} — detection is on the marker tag,
30
+ * never a guess — and routes a decoded structured event to the {@link StructuredSink}
31
+ * (the derived structured renderer) while writing a raw chunk verbatim to the byte
32
+ * {@link TerminalSink}. A mixed stream that starts raw and only later carries tagged
33
+ * chunks is handled per-chunk, so each chunk lands on the right surface. Routing does
34
+ * not touch the resume machinery: `nextOffset` advances identically whichever surface
35
+ * a chunk is applied to, so resume-from-offset neither loses nor double-applies
36
+ * structured events across a reconnect exactly as for raw output.
22
37
  */
23
38
  import type { RelayPayload } from "../protocol/index.ts";
24
39
  import { addSafeInt, isNonNegInt, isPosInt } from "../relay/index.ts";
40
+ import { type TranscriptEvent, parseTranscriptEvent } from "../transcript/index.ts";
25
41
 
26
42
  /** The terminal sink the session writes decoded output to (xterm.js satisfies this). */
27
43
  export interface TerminalSink {
@@ -31,6 +47,20 @@ export interface TerminalSink {
31
47
  dispose?(): void;
32
48
  }
33
49
 
50
+ /**
51
+ * The structured sink a session routes decoded transcript events to when the stream
52
+ * is a structured ACP stream (marker-tagged chunks). It receives the offset-keyed,
53
+ * immutable {@link TranscriptEvent} the one canonical {@link parseTranscriptEvent}
54
+ * derived from the chunk — never raw JSON — so the derived structured renderer folds
55
+ * over typed events rather than pretty-printing bytes.
56
+ */
57
+ export interface StructuredSink {
58
+ /** Apply one decoded structured transcript event (in offset order). */
59
+ event(event: TranscriptEvent): void;
60
+ /** Tear down the underlying structured widget and its listeners, if any. */
61
+ dispose?(): void;
62
+ }
63
+
34
64
  /** An outbound relay message the session asks its transport to send. */
35
65
  export type RelayOutbound =
36
66
  | { readonly op: "subscribe"; readonly stream: string; readonly from: number; readonly credit: number }
@@ -47,8 +77,15 @@ export type RelaySend = (message: RelayOutbound) => void;
47
77
  export interface TerminalSessionOptions {
48
78
  /** The relay stream id (one worker's terminal). */
49
79
  readonly stream: string;
50
- /** Where decoded output is written. */
80
+ /** Where decoded raw output is written. */
51
81
  readonly sink: TerminalSink;
82
+ /**
83
+ * Where decoded structured transcript events are routed when the stream is a
84
+ * structured ACP stream (marker-tagged chunks). Omit for a pure byte-terminal:
85
+ * without it every chunk — even a marker-tagged one — is written verbatim to
86
+ * {@link sink}, preserving the legacy raw-only behaviour.
87
+ */
88
+ readonly structured?: StructuredSink;
52
89
  /** Emits outbound relay messages. */
53
90
  readonly send: RelaySend;
54
91
  /** Bulk credit requested on each (re)subscribe. Default 1024. */
@@ -73,6 +110,7 @@ function isRelayData(message: RelayInbound): message is RelayPayload {
73
110
  export class TerminalSession {
74
111
  readonly #stream: string;
75
112
  readonly #sink: TerminalSink;
113
+ readonly #structured: StructuredSink | undefined;
76
114
  readonly #send: RelaySend;
77
115
  readonly #credit: number;
78
116
  readonly #onGap: TerminalSessionOptions["onGap"];
@@ -84,6 +122,7 @@ export class TerminalSession {
84
122
  constructor(options: TerminalSessionOptions) {
85
123
  this.#stream = options.stream;
86
124
  this.#sink = options.sink;
125
+ this.#structured = options.structured;
87
126
  this.#send = options.send;
88
127
  this.#credit = options.credit ?? DEFAULT_CREDIT;
89
128
  if (!isPosInt(this.#credit)) {
@@ -152,19 +191,41 @@ export class TerminalSession {
152
191
  // Idempotent apply: a reconnect resubscribes from nextOffset, so the hub may
153
192
  // re-deliver the boundary chunk; anything we have already applied is dropped.
154
193
  if (data.offset < this.#nextOffset) return;
155
- // Compute the next resume point BEFORE writing so the apply is atomic: a
194
+ // Compute the next resume point BEFORE applying so the apply is atomic: a
156
195
  // chunk at Number.MAX_SAFE_INTEGER makes addSafeInt throw, and it must throw
157
- // before we touch the sink — otherwise the chunk is written but #nextOffset
196
+ // before we touch either sink — otherwise the chunk is applied but #nextOffset
158
197
  // is not advanced, leaving a partially-applied state that re-delivers (and
159
198
  // so duplicates) the chunk on reconnect. Advancing via addSafeInt also fails
160
199
  // fast rather than overflowing into an unsafe nextOffset — that value would
161
200
  // later be echoed in subscribe.from and lose precision on any JSON
162
201
  // round-trip, silently corrupting resume semantics.
163
202
  const nextOffset = addSafeInt(data.offset, 1, "nextOffset");
164
- this.#sink.write(data.chunk);
203
+ this.#apply(data);
165
204
  this.#nextOffset = nextOffset;
166
205
  }
167
206
 
207
+ /**
208
+ * Route one already-de-duplicated data chunk to the right surface. Classify it
209
+ * through the ONE canonical {@link parseTranscriptEvent}: a marker-tagged, known
210
+ * envelope decodes to a typed event and (when a {@link StructuredSink} is wired)
211
+ * is routed there — the derived structured renderer — instead of dumping raw JSON
212
+ * into the byte-terminal; anything else (raw bytes, non-JSON, an untagged or
213
+ * malformed envelope) falls back to `stream-chunk` and is written verbatim to the
214
+ * byte {@link TerminalSink}, so a legacy/raw or mixed stream renders exactly as
215
+ * before. With no structured sink the parse is skipped entirely, keeping the
216
+ * raw-only path byte-identical to a pure byte-terminal.
217
+ */
218
+ #apply(data: RelayPayload): void {
219
+ if (this.#structured !== undefined) {
220
+ const event = parseTranscriptEvent({ offset: data.offset, chunk: data.chunk });
221
+ if (event.kind !== "stream-chunk") {
222
+ this.#structured.event(event);
223
+ return;
224
+ }
225
+ }
226
+ this.#sink.write(data.chunk);
227
+ }
228
+
168
229
  #onSubscribed(gap: boolean, nextOffset: number): void {
169
230
  // Validate the ack's resume point exactly as the constructor validates
170
231
  // `from`: `nextOffset` is echoed back into subscribe.from on the next
@@ -0,0 +1,68 @@
1
+ // Drift-guard: exactly ONE parser of the transcript log (ADR 0056, #251).
2
+ //
3
+ // This enforces structurally — by scanning the package source — that the raw-chunk → typed-event
4
+ // classification lives in exactly one module (`transcript/events.ts`), so a second, divergent parser of
5
+ // the same bytes cannot creep in. The whole point of the event-sourced model is "the log IS the state":
6
+ // every view derives from the one fold, none re-parses the bytes itself. A sibling cockpit task imports
7
+ // the `TRANSCRIPT_EVENT_MARKER` IDENTIFIER (never the string literal), so this guard stays satisfied as
8
+ // consumers grow.
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { readdirSync, readFileSync } from "node:fs";
12
+ import { dirname, join, relative } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { TRANSCRIPT_EVENT_MARKER } from "./events.ts";
15
+
16
+ const TRANSCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
17
+ const SRC_DIR = dirname(TRANSCRIPT_DIR);
18
+
19
+ /** Every non-test `.ts` source file under a directory, recursively. */
20
+ function sourceFiles(dir: string): string[] {
21
+ const out: string[] = [];
22
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
23
+ const path = join(dir, entry.name);
24
+ if (entry.isDirectory()) out.push(...sourceFiles(path));
25
+ else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) out.push(path);
26
+ }
27
+ return out;
28
+ }
29
+
30
+ const PARSER_MODULE = join(TRANSCRIPT_DIR, "events.ts");
31
+ const STORE_MODULE = join(TRANSCRIPT_DIR, "store.ts");
32
+
33
+ test("the transcript-event marker literal is DEFINED in exactly one module (no second parser)", () => {
34
+ // Consumers reference the marker via the imported `TRANSCRIPT_EVENT_MARKER` identifier; only the ONE
35
+ // parser embeds the marker's string literal. A second module hardcoding it would be a second parser.
36
+ // Match every quote form (double, single, backtick) so a second parser can't bypass the guard by
37
+ // hardcoding the marker in a different literal style. Scan the WHOLE package src so no module anywhere
38
+ // (cockpit, protocol, …) can inline a private copy of the marker.
39
+ const quotedMarkerForms = ['"', "'", "`"].map((q) => `${q}${TRANSCRIPT_EVENT_MARKER}${q}`);
40
+ const owners = sourceFiles(SRC_DIR).filter((path) => {
41
+ const src = readFileSync(path, "utf8");
42
+ return quotedMarkerForms.some((literal) => src.includes(literal));
43
+ });
44
+ assert.deepEqual(
45
+ owners,
46
+ [PARSER_MODULE],
47
+ `the marker literal must be defined only in ${relative(SRC_DIR, PARSER_MODULE)}; found in: ${owners
48
+ .map((p) => relative(SRC_DIR, p))
49
+ .join(", ")}`,
50
+ );
51
+ });
52
+
53
+ test("no transcript consumer re-parses raw chunks — JSON.parse of the log lives only in the parser", () => {
54
+ // Every projection must fold through the single parser, never JSON.parse a chunk itself. Scan every
55
+ // non-test transcript module EXCEPT the parser (which owns the one JSON.parse) and the store (whose
56
+ // JSON handling is DB rows, not the log), and assert none of them contains a raw JSON.parse. Scanning
57
+ // the whole plane (not a name pattern) means a future consumer module is guarded the moment it is added.
58
+ const consumers = sourceFiles(TRANSCRIPT_DIR).filter(
59
+ (path) => path !== PARSER_MODULE && path !== STORE_MODULE,
60
+ );
61
+ for (const path of consumers) {
62
+ const src = readFileSync(path, "utf8");
63
+ assert.ok(
64
+ !src.includes("JSON.parse"),
65
+ `${relative(SRC_DIR, path)} must derive through parseTranscriptEvent, not re-parse the log itself`,
66
+ );
67
+ }
68
+ });
@@ -0,0 +1,308 @@
1
+ // Unit tests for the typed transcript-event vocabulary + the single derive() fold (#251).
2
+ //
3
+ // Pins: the ONE parser classifies raw bytes vs typed envelopes (raw fidelity preserved), the core
4
+ // vocabulary decodes each kind, merge-extensibility adds/overrides kinds without a second parser
5
+ // (including the downstream `permission` extension point), encode↔parse round-trips, and deriveView
6
+ // folds the log into per-turn structure / message history / tool cards / raw-byte accounting /
7
+ // lifecycle — "the log IS the state".
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import {
11
+ CORE_TRANSCRIPT_EVENT_KINDS,
12
+ CORE_TRANSCRIPT_VOCAB,
13
+ deriveView,
14
+ deriveViewFromChunks,
15
+ encodeTranscriptEvent,
16
+ mergeTranscriptVocab,
17
+ parseTranscriptEvent,
18
+ type TranscriptEvent,
19
+ type TranscriptVocab,
20
+ TRANSCRIPT_EVENT_MARKER,
21
+ TRANSCRIPT_EVENT_VERSION,
22
+ utf8ByteLength,
23
+ } from "./events.ts";
24
+
25
+ function env(kind: string, extra: Record<string, unknown> = {}): string {
26
+ return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, kind, ...extra });
27
+ }
28
+
29
+ test("marker + version constants are the canonical values (single source of truth)", () => {
30
+ assert.equal(TRANSCRIPT_EVENT_MARKER, "nwfTranscriptEvent");
31
+ assert.equal(TRANSCRIPT_EVENT_VERSION, 1);
32
+ });
33
+
34
+ test("utf8ByteLength counts UTF-8 bytes without depending on Buffer", () => {
35
+ assert.equal(utf8ByteLength("abc"), 3);
36
+ assert.equal(utf8ByteLength("é"), 2);
37
+ assert.equal(utf8ByteLength("😀"), 4);
38
+ assert.equal(utf8ByteLength(""), 0);
39
+ });
40
+
41
+ test("parseTranscriptEvent: raw terminal bytes are retained verbatim as a stream-chunk", () => {
42
+ const event = parseTranscriptEvent({ offset: 3, chunk: "\u001b[32mok\u001b[0m\r\n" });
43
+ assert.deepEqual(event, { kind: "stream-chunk", offset: 3, chunk: "\u001b[32mok\u001b[0m\r\n" });
44
+ });
45
+
46
+ test("parseTranscriptEvent: JSON without the marker is NOT mis-classified — stays a raw chunk", () => {
47
+ const chunk = JSON.stringify({ kind: "message", text: "hi" }); // no marker → raw
48
+ const event = parseTranscriptEvent({ offset: 0, chunk });
49
+ assert.equal(event.kind, "stream-chunk");
50
+ });
51
+
52
+ test("parseTranscriptEvent: a marker envelope with an unknown kind falls back to raw", () => {
53
+ const event = parseTranscriptEvent({ offset: 0, chunk: env("no-such-kind", { foo: 1 }) });
54
+ assert.equal(event.kind, "stream-chunk");
55
+ });
56
+
57
+ test("parseTranscriptEvent: malformed JSON carrying the marker text falls back to raw", () => {
58
+ const event = parseTranscriptEvent({ offset: 0, chunk: `{"${TRANSCRIPT_EVENT_MARKER}":1, broken` });
59
+ assert.equal(event.kind, "stream-chunk");
60
+ });
61
+
62
+ test("parseTranscriptEvent: a marker at the wrong version falls back to raw", () => {
63
+ const chunk = JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: 999, kind: "message", text: "hi" });
64
+ assert.equal(parseTranscriptEvent({ offset: 0, chunk }).kind, "stream-chunk");
65
+ });
66
+
67
+ test("parseTranscriptEvent: an inherited-property kind never resolves a prototype decoder", () => {
68
+ // A hostile chunk whose `kind` names an Object.prototype member ("constructor", "toString",
69
+ // "__proto__", …) must NOT resolve `vocab[kind]` up the prototype chain to a non-decoder
70
+ // function and call it — that either throws (DoS) or returns a non-TranscriptEvent value. The
71
+ // vocab is a plain map, so only OWN kinds decode; every prototype key falls back to raw.
72
+ for (const kind of ["constructor", "toString", "hasOwnProperty", "valueOf", "__proto__"]) {
73
+ const event = parseTranscriptEvent({ offset: 0, chunk: env(kind, { foo: 1 }) });
74
+ assert.equal(event.kind, "stream-chunk", `kind=${kind} must fall back to a raw stream-chunk`);
75
+ }
76
+ });
77
+
78
+ test("core vocab decodes message with role (default assistant)", () => {
79
+ assert.deepEqual(parseTranscriptEvent({ offset: 1, chunk: env("message", { text: "hello" }) }), {
80
+ kind: "message",
81
+ offset: 1,
82
+ role: "assistant",
83
+ text: "hello",
84
+ });
85
+ assert.deepEqual(parseTranscriptEvent({ offset: 2, chunk: env("message", { role: "user", text: "hi" }) }), {
86
+ kind: "message",
87
+ offset: 2,
88
+ role: "user",
89
+ text: "hi",
90
+ });
91
+ });
92
+
93
+ test("core vocab: a message envelope missing text is rejected → raw fallback", () => {
94
+ assert.equal(parseTranscriptEvent({ offset: 0, chunk: env("message", { role: "user" }) }).kind, "stream-chunk");
95
+ });
96
+
97
+ test("core vocab decodes tool-call / tool-result / turn / step / lifecycle", () => {
98
+ assert.deepEqual(parseTranscriptEvent({ offset: 1, chunk: env("tool-call", { name: "grep", callId: "c1", args: { q: "x" } }) }), {
99
+ kind: "tool-call",
100
+ offset: 1,
101
+ name: "grep",
102
+ callId: "c1",
103
+ args: { q: "x" },
104
+ });
105
+ assert.deepEqual(parseTranscriptEvent({ offset: 2, chunk: env("tool-result", { callId: "c1", ok: true, content: "found" }) }), {
106
+ kind: "tool-result",
107
+ offset: 2,
108
+ ok: true,
109
+ callId: "c1",
110
+ content: "found",
111
+ });
112
+ assert.deepEqual(parseTranscriptEvent({ offset: 3, chunk: env("turn", { index: 4 }) }), { kind: "turn", offset: 3, index: 4 });
113
+ assert.deepEqual(parseTranscriptEvent({ offset: 4, chunk: env("step", { label: "loop" }) }), { kind: "step", offset: 4, label: "loop" });
114
+ assert.deepEqual(parseTranscriptEvent({ offset: 5, chunk: env("lifecycle", { phase: "completed" }) }), {
115
+ kind: "lifecycle",
116
+ offset: 5,
117
+ phase: "completed",
118
+ });
119
+ });
120
+
121
+ test("mergeTranscriptVocab: adds a new kind without forking the parser, and can override a core one", () => {
122
+ const vocab = mergeTranscriptVocab(CORE_TRANSCRIPT_VOCAB, {
123
+ // A brand new merge-extensible kind, decoded into a message so deriveView still folds it.
124
+ reasoning: (body, offset) => ({ kind: "message", offset, role: "system", text: String(body.text ?? "") }),
125
+ });
126
+ const event = parseTranscriptEvent({ offset: 7, chunk: env("reasoning", { text: "thinking" }) }, vocab);
127
+ assert.deepEqual(event, { kind: "message", offset: 7, role: "system", text: "thinking" });
128
+ // The core vocab is unchanged (merge returns a new object).
129
+ assert.equal(parseTranscriptEvent({ offset: 7, chunk: env("reasoning", { text: "thinking" }) }).kind, "stream-chunk");
130
+ });
131
+
132
+ test("vocab maps are null-prototype so inherited keys never leak into `in` / Object.keys", () => {
133
+ // A prototype-bearing vocab (`Object.assign({}, …)`) makes `"toString" in vocab` true and surfaces
134
+ // inherited Object.prototype keys to any consumer doing `kind in vocab` / `Object.keys(vocab)`,
135
+ // classifying a hostile `kind` off the prototype chain. Both the core vocab AND every merge result
136
+ // must be null-prototype so only OWN kinds exist — this guards the whole class, not one bad key.
137
+ const merged = mergeTranscriptVocab(CORE_TRANSCRIPT_VOCAB, {
138
+ custom: (_body, offset) => ({ kind: "step", offset }),
139
+ });
140
+ const vocabs: readonly (readonly [string, TranscriptVocab])[] = [
141
+ ["core", CORE_TRANSCRIPT_VOCAB],
142
+ ["merged", merged],
143
+ ];
144
+ for (const [name, vocab] of vocabs) {
145
+ assert.equal(Object.getPrototypeOf(vocab), null, `${name} vocab must have a null prototype`);
146
+ for (const inherited of ["toString", "constructor", "hasOwnProperty", "valueOf", "__proto__"]) {
147
+ assert.equal(inherited in vocab, false, `${name} vocab must not expose inherited key ${inherited}`);
148
+ }
149
+ }
150
+ });
151
+
152
+ test("EXTENSION POINT: a downstream app registers its own `permission` kind via mergeTranscriptVocab", () => {
153
+ // Mirrors nano-workforce#559: an app adds a `permission` kind + parse handler WITHOUT editing this
154
+ // package. The synthetic extra kind is decoded (here into a message the core fold understands) and
155
+ // parses through the one parser; the core vocab stays untouched.
156
+ const appVocab: TranscriptVocab = mergeTranscriptVocab(CORE_TRANSCRIPT_VOCAB, {
157
+ permission: (body, offset) => {
158
+ const requestId = typeof body.requestId === "string" ? body.requestId : undefined;
159
+ if (requestId === undefined) return undefined; // reject malformed → raw fallback
160
+ const granted = body.granted === true;
161
+ return { kind: "message", offset, role: "system", text: `permission(${requestId}):${granted ? "granted" : "denied"}` };
162
+ },
163
+ });
164
+
165
+ const granted = parseTranscriptEvent({ offset: 10, chunk: env("permission", { requestId: "req-1", granted: true }) }, appVocab);
166
+ assert.deepEqual(granted, { kind: "message", offset: 10, role: "system", text: "permission(req-1):granted" });
167
+
168
+ // A malformed extension envelope (missing requestId) still falls back to raw — no throw, no crash.
169
+ assert.equal(
170
+ parseTranscriptEvent({ offset: 11, chunk: env("permission", { granted: false }) }, appVocab).kind,
171
+ "stream-chunk",
172
+ );
173
+
174
+ // The package's core vocab never learned `permission` — the extension did not fork or mutate it.
175
+ assert.equal(parseTranscriptEvent({ offset: 10, chunk: env("permission", { requestId: "req-1", granted: true }) }).kind, "stream-chunk");
176
+ assert.equal(CORE_TRANSCRIPT_VOCAB.permission, undefined);
177
+ });
178
+
179
+ test("encodeTranscriptEvent round-trips every non-raw kind through the one parser", () => {
180
+ const events: TranscriptEvent[] = [
181
+ { kind: "message", offset: 0, role: "assistant", text: "hi" },
182
+ { kind: "tool-call", offset: 1, name: "ls", callId: "c1" },
183
+ { kind: "tool-result", offset: 2, ok: false, callId: "c1", content: "boom" },
184
+ { kind: "turn", offset: 3, index: 1 },
185
+ { kind: "step", offset: 4, label: "s" },
186
+ { kind: "lifecycle", offset: 5, phase: "exited" },
187
+ ];
188
+ for (const original of events) {
189
+ const chunk = encodeTranscriptEvent(original);
190
+ assert.deepEqual(parseTranscriptEvent({ offset: original.offset, chunk }), original);
191
+ }
192
+ });
193
+
194
+ test("encodeTranscriptEvent returns raw bytes verbatim for a stream-chunk", () => {
195
+ assert.equal(encodeTranscriptEvent({ kind: "stream-chunk", offset: 0, chunk: "raw" }), "raw");
196
+ });
197
+
198
+ test("deriveView: folds messages + tool cards into per-turn structure with lifecycle", () => {
199
+ const events: TranscriptEvent[] = [
200
+ { kind: "turn", offset: 0, index: 0 },
201
+ { kind: "message", offset: 1, role: "user", text: "do it" },
202
+ { kind: "step", offset: 2 },
203
+ { kind: "tool-call", offset: 3, name: "grep", callId: "c1" },
204
+ { kind: "tool-result", offset: 4, ok: true, callId: "c1", content: "hit" },
205
+ { kind: "message", offset: 5, role: "assistant", text: "done" },
206
+ { kind: "turn", offset: 6, index: 1 },
207
+ { kind: "message", offset: 7, role: "assistant", text: "next" },
208
+ { kind: "stream-chunk", offset: 8, chunk: "raw-bytes" },
209
+ { kind: "lifecycle", offset: 9, phase: "completed" },
210
+ ];
211
+ const view = deriveView(events);
212
+ assert.equal(view.turns.length, 2);
213
+ assert.deepEqual(view.turns[0]?.messages.map((m) => m.text), ["do it", "done"]);
214
+ assert.equal(view.turns[0]?.steps, 1);
215
+ assert.equal(view.turns[0]?.tools.length, 1);
216
+ assert.deepEqual(view.turns[0]?.tools[0]?.result, { ok: true, offset: 4, content: "hit" });
217
+ assert.deepEqual(view.turns[1]?.messages.map((m) => m.text), ["next"]);
218
+ assert.equal(view.messages.length, 3);
219
+ assert.equal(view.tools.length, 1);
220
+ assert.equal(view.lifecycle, "completed");
221
+ assert.equal(view.rawChunkCount, 1);
222
+ assert.equal(view.rawByteLength, utf8ByteLength("raw-bytes"));
223
+ assert.equal(view.eventCount, 10);
224
+ });
225
+
226
+ test("deriveView: content before any turn event opens an implicit turn 0", () => {
227
+ const view = deriveView([
228
+ { kind: "message", offset: 0, role: "assistant", text: "hello" },
229
+ { kind: "tool-call", offset: 1, name: "ls" },
230
+ ]);
231
+ assert.equal(view.turns.length, 1);
232
+ assert.equal(view.turns[0]?.index, 0);
233
+ assert.equal(view.turns[0]?.messages.length, 1);
234
+ assert.equal(view.turns[0]?.tools.length, 1);
235
+ });
236
+
237
+ test("deriveView: an anonymous tool-result pairs with the most recent open anonymous call", () => {
238
+ const view = deriveView([
239
+ { kind: "tool-call", offset: 0, name: "a" },
240
+ { kind: "tool-result", offset: 1, ok: false, content: "nope" },
241
+ ]);
242
+ assert.deepEqual(view.tools[0]?.result, { ok: false, offset: 1, content: "nope" });
243
+ });
244
+
245
+ test("deriveView: results pair into the correct turn's tool with interleaved, out-of-order calls across turns", () => {
246
+ // Two turns, each with two tools; results arrive interleaved and out of call order. Each result must
247
+ // land on its own call's card in BOTH the flat list and the owning turn — guarding the O(1) position
248
+ // tracking against pairing into the wrong turn/index.
249
+ const view = deriveView([
250
+ { kind: "turn", offset: 0, index: 0 },
251
+ { kind: "tool-call", offset: 1, name: "t0a", callId: "a" },
252
+ { kind: "tool-call", offset: 2, name: "t0b", callId: "b" },
253
+ { kind: "turn", offset: 3, index: 1 },
254
+ { kind: "tool-call", offset: 4, name: "t1c", callId: "c" },
255
+ { kind: "tool-call", offset: 5, name: "t1d", callId: "d" },
256
+ { kind: "tool-result", offset: 6, ok: true, callId: "c", content: "C" },
257
+ { kind: "tool-result", offset: 7, ok: false, callId: "a", content: "A" },
258
+ { kind: "tool-result", offset: 8, ok: true, callId: "d", content: "D" },
259
+ { kind: "tool-result", offset: 9, ok: false, callId: "b", content: "B" },
260
+ ]);
261
+ // Flat list keeps call order, each with its own result.
262
+ assert.deepEqual(
263
+ view.tools.map((t) => [t.name, t.result?.content]),
264
+ [["t0a", "A"], ["t0b", "B"], ["t1c", "C"], ["t1d", "D"]],
265
+ );
266
+ // Each result also lands on the matching card inside its OWN turn (not another turn's).
267
+ assert.deepEqual(
268
+ view.turns[0]?.tools.map((t) => [t.name, t.result?.ok]),
269
+ [["t0a", false], ["t0b", false]],
270
+ );
271
+ assert.deepEqual(
272
+ view.turns[1]?.tools.map((t) => [t.name, t.result?.ok]),
273
+ [["t1c", true], ["t1d", true]],
274
+ );
275
+ });
276
+
277
+ test("deriveViewFromChunks: an all-raw log derives no structure but full raw fidelity accounting", () => {
278
+ const view = deriveViewFromChunks([
279
+ { offset: 0, chunk: "line-1\n" },
280
+ { offset: 1, chunk: "line-2\n" },
281
+ ]);
282
+ assert.equal(view.turns.length, 0);
283
+ assert.equal(view.messages.length, 0);
284
+ assert.equal(view.rawChunkCount, 2);
285
+ assert.ok(view.rawByteLength > 0);
286
+ });
287
+
288
+ test("deriveViewFromChunks: a mixed log derives typed structure while retaining raw chunks", () => {
289
+ const view = deriveViewFromChunks([
290
+ { offset: 0, chunk: env("turn", { index: 0 }) },
291
+ { offset: 1, chunk: "\u001b[2Jraw frame" },
292
+ { offset: 2, chunk: env("message", { role: "assistant", text: "hi" }) },
293
+ ]);
294
+ assert.equal(view.turns.length, 1);
295
+ assert.deepEqual(view.messages.map((m) => m.text), ["hi"]);
296
+ assert.equal(view.rawChunkCount, 1);
297
+ });
298
+
299
+ test("ONE-PARSER guard: every declared core kind is handled by the single parseTranscriptEvent fold", () => {
300
+ // Port of the app-side drift guard: there is exactly one fold, and it decodes every core kind. If a
301
+ // kind were added to the union but not to CORE_TRANSCRIPT_VOCAB (or vice versa), this fails — no
302
+ // second parser and no undecoded kind can creep in.
303
+ const vocabKinds = Object.keys(CORE_TRANSCRIPT_VOCAB).sort();
304
+ assert.deepEqual(vocabKinds, [...CORE_TRANSCRIPT_EVENT_KINDS].sort());
305
+ for (const kind of CORE_TRANSCRIPT_EVENT_KINDS) {
306
+ assert.equal(typeof CORE_TRANSCRIPT_VOCAB[kind], "function", `core kind ${kind} must be handled by the one parser`);
307
+ }
308
+ });