@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
|
@@ -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
|
|
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
|
|
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.#
|
|
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
|
+
});
|