@nanobpm/nano-workforce 0.77.0 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.79.0](https://github.com/nanobpm/nano-workforce/compare/v0.78.0...v0.79.0) (2026-08-17)
2
+
3
+
4
+ ### Features
5
+
6
+ * **agentic:** event-sourced transcripts — typed vocabulary + derive fold + replay-by-fork ([#251](https://github.com/nanobpm/nano-workforce/issues/251)) ([#252](https://github.com/nanobpm/nano-workforce/issues/252)) ([afa7190](https://github.com/nanobpm/nano-workforce/commit/afa7190db2c19e088ae3fb058a2b43d72f54a8df)), closes [146/#222](https://github.com/nanobpm/nano-workforce/issues/222)
7
+
8
+ # [0.78.0](https://github.com/nanobpm/nano-workforce/compare/v0.77.0...v0.78.0) (2026-08-17)
9
+
10
+
11
+ ### Features
12
+
13
+ * converge the merge-loop escalation onto one native user-task pathway ([#256](https://github.com/nanobpm/nano-workforce/issues/256)) ([#257](https://github.com/nanobpm/nano-workforce/issues/257)) ([16551ee](https://github.com/nanobpm/nano-workforce/commit/16551ee59015081dcf20294ffdd05a078564b775))
14
+
1
15
  # [0.77.0](https://github.com/nanobpm/nano-workforce/compare/v0.76.0...v0.77.0) (2026-08-17)
2
16
 
3
17
 
package/SPEC.md CHANGED
@@ -103,19 +103,22 @@ known at submit time, carried as a process variable and stored on the DB row.
103
103
  │ │ ├── review-ready (msg catch, key = prKey) → round++ ─────┐
104
104
  │ │ └── =reviewWaitTimeout (timer catch) │
105
105
  │ │ → [Escalate: review stalled] (blocked) │
106
- │ │ → [Wait: escalation-answered] ──────────────────────┤
106
+ │ │ → [Wait: wait-answer userTask] ─────────────────────┤
107
107
  │ │ │
108
108
  │ └── needs_input [Record escalation] │ │
109
109
  │ or blocked → (kind = question | blocker) │ │
110
- │ → [Wait: escalation-answered] (msg catch) │ │
110
+ │ → [Wait: wait-answer userTask] │ │
111
+ │ → [record-answer: pr.answer-escalation] │ │
111
112
  │ → set answer ──────────────────────────────┤ │
112
113
  │ │ │
113
114
  └────────────────────────────────────────────────────────────────────┴───────────────┘
114
115
 
115
116
  Both `needs_input` (the agent has a question) and `blocked` (the agent is stuck
116
117
  on something external — auth, a failing push, a missing secret) route to the
117
- **same escalation path**: record it, sleep at `escalation-answered`, then retry
118
- the same round with the human's `answer`. They differ only by escalation `kind`,
118
+ **same escalation path**: record it, park on the native `wait-answer` user task
119
+ (answered through the canonical `completeUserTask` door and surfaced in the Tasks
120
+ inbox), reconcile the answer via the `record-answer` (`pr.answer-escalation`)
121
+ step, then retry the same round with the human's `answer`. They differ only by escalation `kind`,
119
122
  which the UI uses to label the card. Neither ends the run — a human always gets
120
123
  a chance to unblock and resume.
121
124
 
@@ -200,16 +203,15 @@ Consequences the prompt (`resources/prompts/review-round.md`) encodes:
200
203
  |---|---|---|---|
201
204
  | `pr-submitted` | — (start) | submit route/webhook | `{repo, prNumber, prUrl, prKey}` |
202
205
  | `review-ready` | `prKey` | **poller** | `{reviewId, reviewState, submittedAt}` |
203
- | `escalation-answered` | `prKey` | merge-loop answer (`answerEscalation`) | `{answer, escalationId}` |
204
206
  | `deps-cleared` | `prKey` | **poller** (merge) | — (all `Depends-on` PRs merged) |
205
207
  | `merge-ready` | `prKey` | **poller** (merge) | `{mergeState}` (`ready` \| `conflict` \| `blocked`); when `blocked`, also `{failingChecks, failingChecksList}` for the `senior:fix-ci` branch |
206
208
  | `merge-landed` | `prKey` | **poller** (merge) | — (queued PR merged, or merged out-of-band) |
207
209
 
208
- Note `escalation-answered` is now used only by the `merge-loop` process (the
209
- `convergence-loop` review escalation is a native `userTask` answered through the
210
- task inbox, not this message). The merge-loop `.bpmn` gives it a distinct message
211
- **id** (and distinct envelope shape ids) to avoid duplicate-id collisions when the
212
- manifest deploys both files.
210
+ The `escalation-answered` message was retired (#256): the merge-loop escalation is
211
+ now a native `wait-merge-answer` `userTask`, exactly like the `convergence-loop`
212
+ review escalation (`wait-answer`). Both are answered through the ONE canonical
213
+ `completeUserTask` door and surface in the Tasks inbox there is no longer a
214
+ merge-only message pathway.
213
215
 
214
216
  ## 7. Domain model (SQLite — `db/migrations/001_init.sql`) — PROPOSED
215
217
 
@@ -287,8 +289,8 @@ The full, authoritative contract is `openapi.yaml` (Swagger UI at
287
289
  | `GET` | `/app/api/version` | app + engine version |
288
290
  | `POST` | `/app/api/actions/start/convergence-loop` | parse the PR ref → create the aggregate + start the process (the ONE submit door — page + external callers) |
289
291
  | `POST` | `/app/api/actions/start/plan-fanout` | parse the issue ref → start a plan fan-out run (the ONE plan door) |
290
- | `POST` | `/app/api/actions/message` (`escalation-answered`) | answer an open merge-loop escalation publish `escalation-answered` (the four #156 escalation kinds are native user tasks answered via the task inbox) |
291
- | `POST` | `/app/api/actions/complete-user-task` | complete an open native user-task escalation from the Tasks page (plan-review / trial-merge / PR `wait-answer`) → `completeEscalationAsHuman` (the same resume path the task inbox uses) |
292
+ | `POST` | `/app/api/actions/message` | publish a BPMN message (optionally correlated) into the engine (generic; every escalation kind is now answered via `/actions/complete-user-task`) |
293
+ | `POST` | `/app/api/actions/complete-user-task` | complete an open native user-task escalation from the Tasks page (plan-review / trial-merge / PR `wait-answer` / PR merge `wait-merge-answer`) → `completeEscalationAsHuman` (the same resume path the task inbox uses) |
292
294
  | `GET`/`POST` | `/app/api/hooks/blackboard` | per-plan coordination blackboard (capability-token side-channel) |
293
295
  | `GET` | `/app/api/hooks/abandon` | cooperative abandon check (per-PR capability token) |
294
296
 
@@ -401,7 +403,7 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
401
403
  └─ could not fix ─► escalate ─┐ │ │
402
404
  ▼ ▼ ▼
403
405
  wait: answered ─► (re-arm) ◄──────── (all escalations)
404
- (escalation-answered)
406
+ (wait-merge-answer userTask)
405
407
  ```
406
408
 
407
409
  - **CI auto-fix** — a `blocked` verdict means a **required check failed**
@@ -438,7 +440,8 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
438
440
  recorded in the `merges` audit table.
439
441
  - **Escalation** — a conflict or a failing gate raises the same
440
442
  `pr.persist-escalation` worker as the review stage (status `escalated`), answered
441
- via the merge-loop `escalation-answered` message; answering re-arms and retries.
443
+ via the native `wait-merge-answer` `userTask` (the same `completeUserTask` door as
444
+ the review escalation, #256); answering re-arms and retries.
442
445
  - **Terminal** — `merged` (with `merged_at`), or `converged` when
443
446
  `NANO_PR_AUTO_MERGE=0` (review-only), or `abandoned` on cancel.
444
447
 
@@ -68,6 +68,7 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
68
68
  "plan-review-decision",
69
69
  "trial-merge-decision",
70
70
  "wait-answer", // PR review-loop escalation (convergence-loop.bpmn, U3)
71
+ "wait-merge-answer", // PR merge-loop escalation (merge-loop.bpmn) — same native user-task path (#256)
71
72
  ]);
72
73
 
73
74
  /** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
@@ -78,6 +79,7 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
78
79
  "plan-review-decision": "plan-review-decision",
79
80
  "trial-merge-decision": "trial-merge-decision",
80
81
  "wait-answer": "pr-escalation",
82
+ "wait-merge-answer": "pr-escalation",
81
83
  };
82
84
 
83
85
  interface FormContract {
package/app/agentGuide.ts CHANGED
@@ -33,7 +33,8 @@ const RAW_GUIDE: string = (() => {
33
33
  "- `GET /version` — which code is live.",
34
34
  "- `POST /actions/start/convergence-loop` — submit a PR (`{ pr, convergeOnly?, maxRounds?, dependsOn? }`).",
35
35
  "- `POST /actions/start/plan-fanout` — submit an epic (`{ issue, baseBranch }` or `{ url, baseBranch }`; base is required — a missing `epic/*` base is auto-created, and `confirmDefaultBase`/`allowSharedBase` gate the default-branch and shared-base cases — see ADR 0003).",
36
- "- `POST /actions/message` — answer an escalation (`escalation-answered`, correlate by PR key).",
36
+ "- `POST /actions/complete-user-task` — answer an escalation (`{ userTaskKey, variables }`); the parked user task's key comes from `GET /status`/the Tasks inbox, and the typed variables match its `.form` (e.g. a PR escalation's `{ answer }`).",
37
+ "- `POST /actions/message` — publish a BPMN message (optionally correlated) into the engine.",
37
38
  "",
38
39
  "Engine (Camunda-8 v2 REST) base for debugging: `__ENGINE__`.",
39
40
  "Source repository: `nanobpm/nano-workforce`.",
@@ -50,6 +50,26 @@ H0 itself needs no migration.
50
50
  present as `?token=…`. When neither is set, the channel is **not mounted** (logged), so the app
51
51
  never exposes an unauthenticated upgrade.
52
52
 
53
+ ## Event-sourced transcripts (#251)
54
+
55
+ The H3 transcript store (`db/migrations/024_agentic_transcript.sql`) is already **append-only and
56
+ offset-keyed** — the log half of dsh's event-sourced-session pattern. `transcript-events.ts` adds the
57
+ derivation half:
58
+
59
+ - **Typed, merge-extensible event vocabulary** — `parseTranscriptEvent` is the **one** parser that
60
+ classifies each stored chunk into a typed `TranscriptEvent` (`message` / `tool-call` / `tool-result`
61
+ / `turn` / `step` / `lifecycle`, plus `stream-chunk` for raw terminal bytes retained **verbatim** for
62
+ byte-replay fidelity). A structured producer tags a chunk with the `nwfTranscriptEvent` marker;
63
+ anything else stays a raw `stream-chunk`. Authors extend the vocabulary additively with
64
+ `mergeTranscriptVocab` — never a second parser.
65
+ - **One `deriveView()` fold** — every higher-level view (the cockpit's structured message/tool/turn
66
+ view in `cockpit/transcript-derive.ts`, and any future search / token-accounting / export consumer)
67
+ is a **derivation** of the single log: "the log IS the state". `transcript-events.drift.test.ts`
68
+ asserts exactly one parser (no consumer re-parses raw bytes).
69
+ - **Replay-by-reseed / fork** — `transcript-fork.ts` seeds a **new** stream from an existing log up to
70
+ a chosen offset, so an exited session can be branched and replayed independently, offset-parity
71
+ preserved. Byte-replay and resume-from-offset (`transcript-read.ts`) are untouched.
72
+
53
73
  ## Invariants (ADR 0056)
54
74
 
55
75
  - **App-tier only** — never the engine. The Camunda-8 job protocol (worker⇄engine) is untouched;
@@ -29,6 +29,11 @@ export {
29
29
  type SupplyWorkerView,
30
30
  supplyView,
31
31
  } from "./supply-view.ts";
32
+ export {
33
+ type DerivedTranscriptDom,
34
+ deriveTranscript,
35
+ renderDerivedTranscript,
36
+ } from "./transcript-derive.ts";
32
37
  export {
33
38
  type RenderTranscriptsOptions,
34
39
  renderTranscripts,
@@ -0,0 +1,78 @@
1
+ // Unit tests for the cockpit STRUCTURED transcript view derived from the one fold (#251).
2
+ //
3
+ // Proves the cockpit's structured view is a DERIVATION of the typed event log (message history, tool
4
+ // cards, per-turn boundaries), and that raw chunks are preserved in the fidelity footer — the byte
5
+ // replay is not lost. It renders into the in-memory DOM double, no browser.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import { FakeDocument, FakeElement } from "../../../test/agentic-cockpit-doubles.ts";
9
+ import { TRANSCRIPT_EVENT_MARKER, TRANSCRIPT_EVENT_VERSION } from "../transcript-events.ts";
10
+ import { deriveTranscript, renderDerivedTranscript } from "./transcript-derive.ts";
11
+ import type { TranscriptDataReport } from "./transcript-render.ts";
12
+
13
+ const doc = new FakeDocument();
14
+
15
+ function env(kind: string, extra: Record<string, unknown> = {}): string {
16
+ return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, kind, ...extra });
17
+ }
18
+
19
+ function report(): TranscriptDataReport {
20
+ return {
21
+ stream: "job:1",
22
+ from: 0,
23
+ gap: false,
24
+ nextOffset: 6,
25
+ entries: [
26
+ { offset: 0, chunk: env("turn", { index: 0 }) },
27
+ { offset: 1, chunk: env("message", { role: "user", text: "please build it" }) },
28
+ { offset: 2, chunk: env("tool-call", { name: "grep", callId: "c1" }) },
29
+ { offset: 3, chunk: env("tool-result", { callId: "c1", ok: true, content: "hit" }) },
30
+ { offset: 4, chunk: "\u001b[2Jraw terminal frame" },
31
+ { offset: 5, chunk: env("message", { role: "assistant", text: "done" }) },
32
+ ],
33
+ };
34
+ }
35
+
36
+ test("deriveTranscript folds the fetched page into structured turns/messages/tools", () => {
37
+ const view = deriveTranscript(report());
38
+ assertEquals(view.turns.length, 1);
39
+ assertEquals(view.messages.map((m) => m.text), ["please build it", "done"]);
40
+ assertEquals(view.tools.length, 1);
41
+ assertEquals(view.tools[0]?.result?.ok, true);
42
+ assertEquals(view.rawChunkCount, 1); // the one raw terminal frame is retained
43
+ });
44
+
45
+ test("renderDerivedTranscript draws a turn section with derived messages and tool cards", () => {
46
+ const host = new FakeElement("div");
47
+ renderDerivedTranscript(host, doc, report());
48
+ assertEquals(host.byData("turn-count", "1").length, 1);
49
+ assertEquals(host.byData("message-count", "2").length, 1);
50
+ assertEquals(host.byData("tool-count", "1").length, 1);
51
+ const tool = host.byData("tool", "grep")[0];
52
+ assertEquals(tool?.getAttribute("data-status"), "ok");
53
+ const roles = host.byClass("cockpit-transcript-message").map((n) => n.getAttribute("data-role"));
54
+ assertEquals(roles, ["user", "assistant"]);
55
+ });
56
+
57
+ test("renderDerivedTranscript keeps the raw-fidelity footer so byte replay is visibly preserved", () => {
58
+ const host = new FakeElement("div");
59
+ renderDerivedTranscript(host, doc, report());
60
+ const footer = host.byClass("cockpit-transcript-raw")[0];
61
+ assertEquals(footer?.getAttribute("data-raw-chunks"), "1");
62
+ });
63
+
64
+ test("renderDerivedTranscript shows an empty state for an all-raw (unstructured) transcript", () => {
65
+ const host = new FakeElement("div");
66
+ renderDerivedTranscript(host, doc, {
67
+ stream: "job:2",
68
+ from: 0,
69
+ gap: false,
70
+ nextOffset: 2,
71
+ entries: [
72
+ { offset: 0, chunk: "just raw\n" },
73
+ { offset: 1, chunk: "bytes\n" },
74
+ ],
75
+ });
76
+ assertEquals(host.byData("empty", "true").length, 1);
77
+ assertEquals(host.byData("turn-count", "0").length, 1);
78
+ });
@@ -0,0 +1,90 @@
1
+ // The cockpit STRUCTURED transcript view — derived from the one event fold (ADR 0056, #251).
2
+ //
3
+ // Beside the byte-level replay (`transcript-render.ts` feeds stored chunks through the live terminal
4
+ // renderer for pixel-faithful playback), the cockpit can also show a STRUCTURED view of a captured
5
+ // session: its derived message history, tool cards and per-turn boundaries. Per the issue's acceptance
6
+ // criterion, that structured view is a DERIVATION of the one typed event log — it re-parses nothing.
7
+ // It reads a fetched transcript page and folds it through the single {@link deriveViewFromChunks} entry
8
+ // point (which routes every chunk through the ONE parser, `parseTranscriptEvent`), so there is no
9
+ // second parser of the raw bytes. The drift-guard test enforces that this module never parses chunks
10
+ // itself.
11
+ //
12
+ // Framework-free and side-effect-free, like the sibling cockpit views: the same report always yields
13
+ // the same {@link DerivedView}, and the renderer draws into the injected {@link DocumentLike} subset so
14
+ // a real DOM satisfies it at runtime and an in-memory fake satisfies it for DOM-free Node tests.
15
+ import type { DocumentLike, ElementLike } from "@nanobpm/agentic/cockpit";
16
+ import { type DerivedView, deriveViewFromChunks } from "../transcript-events.ts";
17
+ import type { TranscriptDataReport } from "./transcript-render.ts";
18
+
19
+ /**
20
+ * Derive the structured view of a fetched transcript page by folding its stored chunks through the ONE
21
+ * event parser + fold. Pure: the cockpit reads THIS instead of re-parsing raw frame bytes.
22
+ */
23
+ export function deriveTranscript(data: TranscriptDataReport): DerivedView {
24
+ return deriveViewFromChunks(data.entries);
25
+ }
26
+
27
+ function el(doc: DocumentLike, tag: string, className?: string, text?: string): ElementLike {
28
+ const node = doc.createElement(tag);
29
+ if (className !== undefined) node.className = className;
30
+ if (text !== undefined) node.textContent = text;
31
+ return node;
32
+ }
33
+
34
+ /** Handles into the rendered structured tree the caller may need. */
35
+ export interface DerivedTranscriptDom {
36
+ readonly root: ElementLike;
37
+ }
38
+
39
+ /**
40
+ * Render the DERIVED structured view of a fetched transcript into `host`, replacing whatever was there.
41
+ * Draws per-turn sections with their derived messages and tool cards, plus a raw-fidelity footer
42
+ * (retained bytes/chunks) so the operator sees the byte-replay is preserved alongside the structure.
43
+ * Idempotent — call again on each refresh. Everything it shows is a derivation of the one event log.
44
+ */
45
+ export function renderDerivedTranscript(host: ElementLike, doc: DocumentLike, data: TranscriptDataReport): DerivedTranscriptDom {
46
+ const view = deriveTranscript(data);
47
+ host.replaceChildren();
48
+ const root = el(doc, "div", "cockpit-transcript-derived");
49
+ root.setAttribute("data-stream", data.stream);
50
+ root.setAttribute("data-lifecycle", view.lifecycle);
51
+ root.setAttribute("data-turn-count", String(view.turns.length));
52
+ root.setAttribute("data-message-count", String(view.messages.length));
53
+ root.setAttribute("data-tool-count", String(view.tools.length));
54
+
55
+ if (view.turns.length === 0) {
56
+ const empty = el(doc, "div", "cockpit-transcript-empty", "No structured events derived — raw replay only.");
57
+ empty.setAttribute("data-empty", "true");
58
+ root.appendChild(empty);
59
+ }
60
+
61
+ for (const turn of view.turns) {
62
+ const section = el(doc, "section", "cockpit-transcript-turn");
63
+ section.setAttribute("data-turn", String(turn.index));
64
+ section.setAttribute("data-steps", String(turn.steps));
65
+ section.appendChild(el(doc, "h3", "cockpit-transcript-turn-title", `Turn ${turn.index}`));
66
+ for (const msg of turn.messages) {
67
+ const row = el(doc, "div", "cockpit-transcript-message", msg.text);
68
+ row.setAttribute("data-role", msg.role);
69
+ row.setAttribute("data-offset", String(msg.offset));
70
+ section.appendChild(row);
71
+ }
72
+ for (const tool of turn.tools) {
73
+ const card = el(doc, "div", "cockpit-transcript-tool", tool.name);
74
+ card.setAttribute("data-tool", tool.name);
75
+ card.setAttribute("data-offset", String(tool.offset));
76
+ card.setAttribute("data-status", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
77
+ section.appendChild(card);
78
+ }
79
+ root.appendChild(section);
80
+ }
81
+
82
+ const footer = el(doc, "footer", "cockpit-transcript-raw");
83
+ footer.setAttribute("data-raw-bytes", String(view.rawByteLength));
84
+ footer.setAttribute("data-raw-chunks", String(view.rawChunkCount));
85
+ footer.textContent = `${view.rawChunkCount} raw chunk(s) · ${view.rawByteLength} B retained for replay`;
86
+ root.appendChild(footer);
87
+
88
+ host.appendChild(root);
89
+ return { root };
90
+ }
@@ -0,0 +1,55 @@
1
+ // Drift-guard: exactly ONE parser of the transcript log (ADR 0056, #251).
2
+ //
3
+ // Acceptance criterion (#251): "the cockpit renders from a single derive*() fold, with no independent
4
+ // re-parse of raw bytes (drift-guard test asserts one parser)". This is that guard. It enforces
5
+ // structurally — by scanning the app-tier source — that the raw-chunk → typed-event classification
6
+ // lives in exactly one module (`transcript-events.ts`), so a second, divergent parser of the same
7
+ // bytes cannot creep in. The whole point of the event-sourced model is "the log IS the state": every
8
+ // view derives from the one fold, none re-parses the bytes itself.
9
+ import { test } from "node:test";
10
+ import { readdirSync, readFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { assert, assertEquals } from "#test-assert";
14
+ import { TRANSCRIPT_EVENT_MARKER } from "./transcript-events.ts";
15
+
16
+ const AGENTIC_DIR = dirname(fileURLToPath(import.meta.url));
17
+
18
+ /** Every non-test `.ts` source file under app/agentic, recursively. */
19
+ function sourceFiles(dir: string): string[] {
20
+ const out: string[] = [];
21
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
22
+ const path = join(dir, entry.name);
23
+ if (entry.isDirectory()) out.push(...sourceFiles(path));
24
+ else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) out.push(path);
25
+ }
26
+ return out;
27
+ }
28
+
29
+ const PARSER_MODULE = join(AGENTIC_DIR, "transcript-events.ts");
30
+
31
+ test("the transcript-event marker literal is DEFINED in exactly one module (no second parser)", () => {
32
+ // Consumers reference the marker via the imported `TRANSCRIPT_EVENT_MARKER` identifier; only the ONE
33
+ // parser embeds the marker's string literal. A second module hardcoding it would be a second parser.
34
+ // Match every quote form (double, single, backtick) so a second parser can't bypass the guard by
35
+ // hardcoding the marker in a different literal style.
36
+ const quotedMarkerForms = ['"', "'", "`"].map((q) => `${q}${TRANSCRIPT_EVENT_MARKER}${q}`);
37
+ const owners = sourceFiles(AGENTIC_DIR).filter((path) => {
38
+ const src = readFileSync(path, "utf8");
39
+ return quotedMarkerForms.some((literal) => src.includes(literal));
40
+ });
41
+ assertEquals(owners, [PARSER_MODULE]);
42
+ });
43
+
44
+ test("no transcript consumer re-parses raw chunks — JSON.parse of the log lives only in the parser", () => {
45
+ // The cockpit + read projections must fold through the single parser, never JSON.parse a chunk
46
+ // themselves. Scan the transcript-facing consumers and assert none contains a raw JSON.parse.
47
+ const consumers = sourceFiles(AGENTIC_DIR).filter(
48
+ (path) => path !== PARSER_MODULE && /transcript-(read|render|view|derive|fork)\.ts$/.test(path),
49
+ );
50
+ assert(consumers.length >= 3, "expected to scan several transcript consumers");
51
+ for (const path of consumers) {
52
+ const src = readFileSync(path, "utf8");
53
+ assert(!src.includes("JSON.parse"), `${path} must derive through parseTranscriptEvent, not re-parse the log itself`);
54
+ }
55
+ });
@@ -0,0 +1,186 @@
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
+ // encode↔parse round-trips, and deriveView folds the log into per-turn structure / message history /
6
+ // tool cards / raw-byte accounting / lifecycle — "the log IS the state".
7
+ import { test } from "node:test";
8
+ import { assert, assertEquals } from "#test-assert";
9
+ import {
10
+ CORE_TRANSCRIPT_VOCAB,
11
+ deriveView,
12
+ deriveViewFromChunks,
13
+ encodeTranscriptEvent,
14
+ mergeTranscriptVocab,
15
+ parseTranscriptEvent,
16
+ type TranscriptEvent,
17
+ TRANSCRIPT_EVENT_MARKER,
18
+ TRANSCRIPT_EVENT_VERSION,
19
+ } from "./transcript-events.ts";
20
+
21
+ function env(kind: string, extra: Record<string, unknown> = {}): string {
22
+ return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, kind, ...extra });
23
+ }
24
+
25
+ test("parseTranscriptEvent: raw terminal bytes are retained verbatim as a stream-chunk", () => {
26
+ const event = parseTranscriptEvent({ offset: 3, chunk: "\u001b[32mok\u001b[0m\r\n" });
27
+ assertEquals(event, { kind: "stream-chunk", offset: 3, chunk: "\u001b[32mok\u001b[0m\r\n" });
28
+ });
29
+
30
+ test("parseTranscriptEvent: JSON without the marker is NOT mis-classified — stays a raw chunk", () => {
31
+ const chunk = JSON.stringify({ kind: "message", text: "hi" }); // no marker → raw
32
+ const event = parseTranscriptEvent({ offset: 0, chunk });
33
+ assertEquals(event.kind, "stream-chunk");
34
+ });
35
+
36
+ test("parseTranscriptEvent: a marker envelope with an unknown kind falls back to raw", () => {
37
+ const event = parseTranscriptEvent({ offset: 0, chunk: env("no-such-kind", { foo: 1 }) });
38
+ assertEquals(event.kind, "stream-chunk");
39
+ });
40
+
41
+ test("parseTranscriptEvent: malformed JSON carrying the marker text falls back to raw", () => {
42
+ const event = parseTranscriptEvent({ offset: 0, chunk: `{"${TRANSCRIPT_EVENT_MARKER}":1, broken` });
43
+ assertEquals(event.kind, "stream-chunk");
44
+ });
45
+
46
+ test("core vocab decodes message with role (default assistant)", () => {
47
+ assertEquals(parseTranscriptEvent({ offset: 1, chunk: env("message", { text: "hello" }) }), {
48
+ kind: "message",
49
+ offset: 1,
50
+ role: "assistant",
51
+ text: "hello",
52
+ });
53
+ assertEquals(parseTranscriptEvent({ offset: 2, chunk: env("message", { role: "user", text: "hi" }) }), {
54
+ kind: "message",
55
+ offset: 2,
56
+ role: "user",
57
+ text: "hi",
58
+ });
59
+ });
60
+
61
+ test("core vocab: a message envelope missing text is rejected → raw fallback", () => {
62
+ assertEquals(parseTranscriptEvent({ offset: 0, chunk: env("message", { role: "user" }) }).kind, "stream-chunk");
63
+ });
64
+
65
+ test("core vocab decodes tool-call / tool-result / turn / step / lifecycle", () => {
66
+ assertEquals(parseTranscriptEvent({ offset: 1, chunk: env("tool-call", { name: "grep", callId: "c1", args: { q: "x" } }) }), {
67
+ kind: "tool-call",
68
+ offset: 1,
69
+ name: "grep",
70
+ callId: "c1",
71
+ args: { q: "x" },
72
+ });
73
+ assertEquals(parseTranscriptEvent({ offset: 2, chunk: env("tool-result", { callId: "c1", ok: true, content: "found" }) }), {
74
+ kind: "tool-result",
75
+ offset: 2,
76
+ ok: true,
77
+ callId: "c1",
78
+ content: "found",
79
+ });
80
+ assertEquals(parseTranscriptEvent({ offset: 3, chunk: env("turn", { index: 4 }) }), { kind: "turn", offset: 3, index: 4 });
81
+ assertEquals(parseTranscriptEvent({ offset: 4, chunk: env("step", { label: "loop" }) }), { kind: "step", offset: 4, label: "loop" });
82
+ assertEquals(parseTranscriptEvent({ offset: 5, chunk: env("lifecycle", { phase: "completed" }) }), {
83
+ kind: "lifecycle",
84
+ offset: 5,
85
+ phase: "completed",
86
+ });
87
+ });
88
+
89
+ test("mergeTranscriptVocab: adds a new kind without forking the parser, and can override a core one", () => {
90
+ const vocab = mergeTranscriptVocab(CORE_TRANSCRIPT_VOCAB, {
91
+ // A brand new merge-extensible kind, decoded into a message so deriveView still folds it.
92
+ reasoning: (body, offset) => ({ kind: "message", offset, role: "system", text: String(body.text ?? "") }),
93
+ });
94
+ const event = parseTranscriptEvent({ offset: 7, chunk: env("reasoning", { text: "thinking" }) }, vocab);
95
+ assertEquals(event, { kind: "message", offset: 7, role: "system", text: "thinking" });
96
+ // The core vocab is unchanged (merge returns a new object).
97
+ assertEquals(parseTranscriptEvent({ offset: 7, chunk: env("reasoning", { text: "thinking" }) }).kind, "stream-chunk");
98
+ });
99
+
100
+ test("encodeTranscriptEvent round-trips every non-raw kind through the one parser", () => {
101
+ const events: TranscriptEvent[] = [
102
+ { kind: "message", offset: 0, role: "assistant", text: "hi" },
103
+ { kind: "tool-call", offset: 1, name: "ls", callId: "c1" },
104
+ { kind: "tool-result", offset: 2, ok: false, callId: "c1", content: "boom" },
105
+ { kind: "turn", offset: 3, index: 1 },
106
+ { kind: "step", offset: 4, label: "s" },
107
+ { kind: "lifecycle", offset: 5, phase: "exited" },
108
+ ];
109
+ for (const original of events) {
110
+ const chunk = encodeTranscriptEvent(original);
111
+ assertEquals(parseTranscriptEvent({ offset: original.offset, chunk }), original);
112
+ }
113
+ });
114
+
115
+ test("encodeTranscriptEvent returns raw bytes verbatim for a stream-chunk", () => {
116
+ assertEquals(encodeTranscriptEvent({ kind: "stream-chunk", offset: 0, chunk: "raw" }), "raw");
117
+ });
118
+
119
+ test("deriveView: folds messages + tool cards into per-turn structure with lifecycle", () => {
120
+ const events: TranscriptEvent[] = [
121
+ { kind: "turn", offset: 0, index: 0 },
122
+ { kind: "message", offset: 1, role: "user", text: "do it" },
123
+ { kind: "step", offset: 2 },
124
+ { kind: "tool-call", offset: 3, name: "grep", callId: "c1" },
125
+ { kind: "tool-result", offset: 4, ok: true, callId: "c1", content: "hit" },
126
+ { kind: "message", offset: 5, role: "assistant", text: "done" },
127
+ { kind: "turn", offset: 6, index: 1 },
128
+ { kind: "message", offset: 7, role: "assistant", text: "next" },
129
+ { kind: "stream-chunk", offset: 8, chunk: "raw-bytes" },
130
+ { kind: "lifecycle", offset: 9, phase: "completed" },
131
+ ];
132
+ const view = deriveView(events);
133
+ assertEquals(view.turns.length, 2);
134
+ assertEquals(view.turns[0]?.messages.map((m) => m.text), ["do it", "done"]);
135
+ assertEquals(view.turns[0]?.steps, 1);
136
+ assertEquals(view.turns[0]?.tools.length, 1);
137
+ assertEquals(view.turns[0]?.tools[0]?.result, { ok: true, offset: 4, content: "hit" });
138
+ assertEquals(view.turns[1]?.messages.map((m) => m.text), ["next"]);
139
+ assertEquals(view.messages.length, 3);
140
+ assertEquals(view.tools.length, 1);
141
+ assertEquals(view.lifecycle, "completed");
142
+ assertEquals(view.rawChunkCount, 1);
143
+ assertEquals(view.rawByteLength, Buffer.byteLength("raw-bytes", "utf8"));
144
+ assertEquals(view.eventCount, 10);
145
+ });
146
+
147
+ test("deriveView: content before any turn event opens an implicit turn 0", () => {
148
+ const view = deriveView([
149
+ { kind: "message", offset: 0, role: "assistant", text: "hello" },
150
+ { kind: "tool-call", offset: 1, name: "ls" },
151
+ ]);
152
+ assertEquals(view.turns.length, 1);
153
+ assertEquals(view.turns[0]?.index, 0);
154
+ assertEquals(view.turns[0]?.messages.length, 1);
155
+ assertEquals(view.turns[0]?.tools.length, 1);
156
+ });
157
+
158
+ test("deriveView: an anonymous tool-result pairs with the most recent open anonymous call", () => {
159
+ const view = deriveView([
160
+ { kind: "tool-call", offset: 0, name: "a" },
161
+ { kind: "tool-result", offset: 1, ok: false, content: "nope" },
162
+ ]);
163
+ assertEquals(view.tools[0]?.result, { ok: false, offset: 1, content: "nope" });
164
+ });
165
+
166
+ test("deriveViewFromChunks: an all-raw log derives no structure but full raw fidelity accounting", () => {
167
+ const view = deriveViewFromChunks([
168
+ { offset: 0, chunk: "line-1\n" },
169
+ { offset: 1, chunk: "line-2\n" },
170
+ ]);
171
+ assertEquals(view.turns.length, 0);
172
+ assertEquals(view.messages.length, 0);
173
+ assertEquals(view.rawChunkCount, 2);
174
+ assert(view.rawByteLength > 0);
175
+ });
176
+
177
+ test("deriveViewFromChunks: a mixed log derives typed structure while retaining raw chunks", () => {
178
+ const view = deriveViewFromChunks([
179
+ { offset: 0, chunk: env("turn", { index: 0 }) },
180
+ { offset: 1, chunk: "\u001b[2Jraw frame" },
181
+ { offset: 2, chunk: env("message", { role: "assistant", text: "hi" }) },
182
+ ]);
183
+ assertEquals(view.turns.length, 1);
184
+ assertEquals(view.messages.map((m) => m.text), ["hi"]);
185
+ assertEquals(view.rawChunkCount, 1);
186
+ });