@nanobpm/agentic 0.5.0 → 0.7.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/protocol/conformance/control.d.ts +32 -0
- package/dist/protocol/conformance/control.js +113 -0
- package/dist/protocol/conformance/index.d.ts +1 -0
- package/dist/protocol/conformance/index.js +1 -0
- package/dist/protocol/control.d.ts +110 -0
- package/dist/protocol/control.js +191 -0
- package/dist/protocol/index.d.ts +1 -0
- package/dist/protocol/index.js +1 -0
- 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/protocol/conformance/control.ts +152 -0
- package/src/protocol/conformance/corpus.test.ts +76 -0
- package/src/protocol/conformance/index.ts +6 -0
- package/src/protocol/control.test.ts +131 -0
- package/src/protocol/control.ts +258 -0
- package/src/protocol/index.ts +17 -0
- 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
|
@@ -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
|
+
});
|