@nanobpm/nano-workforce 0.78.0 → 0.80.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 +14 -0
- package/app/agentic/README.md +20 -0
- package/app/agentic/cockpit/index.ts +5 -0
- package/app/agentic/cockpit/transcript-derive.test.ts +78 -0
- package/app/agentic/cockpit/transcript-derive.ts +90 -0
- package/app/agentic/transcript-events.drift.test.ts +55 -0
- package/app/agentic/transcript-events.test.ts +186 -0
- package/app/agentic/transcript-events.ts +470 -0
- package/app/agentic/transcript-fork.test.ts +156 -0
- package/app/agentic/transcript-fork.ts +151 -0
- package/app/agentic/transcript-read.ts +2 -1
- package/app/delivery.test.ts +2 -1
- package/app/delivery.ts +76 -0
- package/app/instance-tracking.test.ts +2 -1
- package/app/lineage.test.ts +300 -0
- package/app/lineage.ts +537 -0
- package/app/migration037.test.ts +62 -0
- package/app/retro.ts +1 -1
- package/app/service.test.ts +104 -1
- package/app/service.ts +33 -71
- package/db/migrations/037_lineage.sql +69 -0
- package/openapi.yaml +120 -0
- package/operations/getLineage.test.ts +105 -0
- package/operations/getLineage.ts +32 -0
- package/package.json +1 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/pages/feature.page.json +1 -0
- package/pages/home.page.json +4 -0
- package/pages/lineage.page.json +146 -0
- package/pages/overview.page.json +1 -0
- package/pages/tasks.page.json +4 -0
- package/workers/converge-feature/worker.ts +1 -1
- package/workers/record-wave/worker.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.80.0](https://github.com/nanobpm/nano-workforce/compare/v0.79.0...v0.80.0) (2026-08-17)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* thread request→PR lineage and project intent→progress ([#245](https://github.com/nanobpm/nano-workforce/issues/245)) ([#253](https://github.com/nanobpm/nano-workforce/issues/253)) ([b603276](https://github.com/nanobpm/nano-workforce/commit/b603276ce65500b351293497e9af7a3e899d73e5))
|
|
7
|
+
|
|
8
|
+
# [0.79.0](https://github.com/nanobpm/nano-workforce/compare/v0.78.0...v0.79.0) (2026-08-17)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **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)
|
|
14
|
+
|
|
1
15
|
# [0.78.0](https://github.com/nanobpm/nano-workforce/compare/v0.77.0...v0.78.0) (2026-08-17)
|
|
2
16
|
|
|
3
17
|
|
package/app/agentic/README.md
CHANGED
|
@@ -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
|
+
});
|