@nanobpm/agentic 0.4.0 → 0.5.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.
@@ -0,0 +1,334 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, test } from "node:test";
3
+ import { TRANSCRIPT_TURN_TABLE } from "./schema.ts";
4
+ import {
5
+ TranscriptCorruptionError,
6
+ TranscriptLifecycleError,
7
+ TranscriptStore,
8
+ type TranscriptTurn,
9
+ } from "./store.ts";
10
+ import { openTestDb, type TestDb } from "./test-db.ts";
11
+
12
+ let db: TestDb;
13
+ afterEach(() => db.close());
14
+ beforeEach(() => {
15
+ db = openTestDb();
16
+ });
17
+
18
+ function newStore(): TranscriptStore {
19
+ const store = new TranscriptStore(db);
20
+ store.ensureSchema();
21
+ return store;
22
+ }
23
+
24
+ /**
25
+ * A representative multi-turn agent run mirroring Camunda's AgentHistoryRecordValue:
26
+ * a USER turn, an ASSISTANT turn that dispatches a tool call (with per-turn metrics),
27
+ * and a TOOL_RESULT turn — with typed content blocks across TEXT/DOCUMENT/OBJECT and
28
+ * two role-split turns sharing loopIteration 1.
29
+ */
30
+ const turns: readonly TranscriptTurn[] = [
31
+ {
32
+ sequence: 0,
33
+ loopIteration: 0,
34
+ role: "USER",
35
+ content: [{ contentType: "TEXT", text: "Summarise the attached report and return JSON." }],
36
+ toolCalls: [],
37
+ producedAt: 1_000,
38
+ },
39
+ {
40
+ sequence: 1,
41
+ loopIteration: 1,
42
+ role: "ASSISTANT",
43
+ content: [
44
+ { contentType: "TEXT", text: "I'll fetch the report first." },
45
+ { contentType: "DOCUMENT", documentReference: "documents/report-2026-08.pdf" },
46
+ ],
47
+ toolCalls: [
48
+ {
49
+ toolCallId: "call-abc",
50
+ toolName: "fetchDocument",
51
+ elementId: "Activity_fetchDoc",
52
+ arguments: { path: "documents/report-2026-08.pdf", pages: [1, 2, 3] },
53
+ },
54
+ ],
55
+ metrics: {
56
+ inputTokens: 1200,
57
+ outputTokens: 64,
58
+ reasoningTokenCount: 32,
59
+ cacheCreationTokenCount: 128,
60
+ cacheReadTokenCount: 900,
61
+ durationMs: 742,
62
+ },
63
+ producedAt: 2_000,
64
+ },
65
+ {
66
+ sequence: 2,
67
+ loopIteration: 1,
68
+ role: "TOOL_RESULT",
69
+ content: [{ contentType: "OBJECT", object: { pages: 3, words: 5123, sections: ["intro", "body"] } }],
70
+ toolCalls: [],
71
+ producedAt: 3_000,
72
+ },
73
+ ];
74
+
75
+ test("a multi-turn transcript round-trips its turn/role/tool-call/metrics structure", () => {
76
+ const store = newStore();
77
+
78
+ const written = store.recordTurns("job:475", turns, "ephemeral");
79
+ assert.equal(written, turns.length);
80
+
81
+ const read = store.readTurns("job:475");
82
+ // The whole turn structure — loopIteration, role, typed content blocks, tool
83
+ // calls and per-turn metrics — round-trips faithfully and in sequence order.
84
+ assert.deepEqual(read, turns);
85
+
86
+ // Durable across a fresh handle over the same DB (no in-memory state).
87
+ const reopened = new TranscriptStore(db);
88
+ assert.deepEqual(reopened.readTurns("job:475"), turns);
89
+ });
90
+
91
+ test("recordTurns is idempotent on (stream, sequence)", () => {
92
+ const store = newStore();
93
+ store.recordTurns("job:475", turns, "ephemeral");
94
+ // Re-recording the same turns (a retry / overlapping reattach) persists nothing new…
95
+ const again = store.recordTurns("job:475", turns, "ephemeral");
96
+ assert.equal(again, 0);
97
+ // …and never duplicates.
98
+ assert.equal(store.readTurns("job:475").length, turns.length);
99
+ });
100
+
101
+ test("the turn view is additive — recording turns leaves the raw chunk stream intact", () => {
102
+ const store = newStore();
103
+ // A raw chunk reader keeps working unchanged when turns are layered on.
104
+ store.record("job:475", [{ offset: 0, chunk: "hello\n" }], "ephemeral");
105
+ store.recordTurns("job:475", turns, "ephemeral");
106
+
107
+ assert.deepEqual(
108
+ store.read("job:475").map((c) => c.chunk),
109
+ ["hello\n"],
110
+ );
111
+ assert.equal(store.readTurns("job:475").length, turns.length);
112
+ // The chunk-stream offset window is untouched by turn recording.
113
+ assert.equal(store.get("job:475")?.nextOffset, 1);
114
+ });
115
+
116
+ test("recordTurns enforces the stream's first-wins lifecycle", () => {
117
+ const store = newStore();
118
+ store.recordTurns("job:475", [turns[0]], "long-lived");
119
+ assert.throws(
120
+ () => store.recordTurns("job:475", [turns[1]], "ephemeral"),
121
+ TranscriptLifecycleError,
122
+ );
123
+ });
124
+
125
+ test("recordTurns rejects an invalid sequence and rolls the whole batch back", () => {
126
+ const store = newStore();
127
+ const bad: TranscriptTurn = { ...turns[1], sequence: -1 };
128
+ assert.throws(() => store.recordTurns("job:475", [turns[0], bad], "ephemeral"), RangeError);
129
+ // Atomic: the first (valid) turn must not have been persisted.
130
+ assert.equal(store.readTurns("job:475").length, 0);
131
+ });
132
+
133
+ test("sweep drops a completed ephemeral stream's turns along with its chunks", () => {
134
+ const clock = { now: () => 10_000 };
135
+ const store = new TranscriptStore(db, { ephemeralRetentionMs: 0, clock });
136
+ store.ensureSchema();
137
+ store.record("job:475", [{ offset: 0, chunk: "x" }], "ephemeral");
138
+ store.recordTurns("job:475", turns, "ephemeral");
139
+ // Complete the ephemeral stream so it becomes sweep-eligible.
140
+ store.flush("job:475", { since: () => ({ entries: [] }), nextOffset: 1 }, "ephemeral");
141
+
142
+ const removed = store.sweep(20_000);
143
+ assert.deepEqual(removed, ["job:475"]);
144
+ assert.equal(store.readTurns("job:475").length, 0);
145
+ assert.equal(store.read("job:475").length, 0);
146
+ });
147
+
148
+ test("recordTurns enforces the typed content-block payload invariant", () => {
149
+ const store = newStore();
150
+ // A TEXT block that also carries a documentReference payload — impossible per the
151
+ // typed-content contract. Build the runtime-invalid fixture via JSON.parse (not a
152
+ // type assertion) so a caller passing junk through an untyped boundary is exercised.
153
+ const twoPayloads: TranscriptTurn = JSON.parse(
154
+ '{"sequence":0,"loopIteration":0,"role":"USER","content":[{"contentType":"TEXT","text":"hi","documentReference":"d"}],"toolCalls":[]}',
155
+ );
156
+ assert.throws(() => store.recordTurns("job:bad", [twoPayloads], "ephemeral"), TranscriptCorruptionError);
157
+
158
+ // A DOCUMENT block missing its documentReference payload is equally invalid.
159
+ const missingPayload: TranscriptTurn = JSON.parse(
160
+ '{"sequence":0,"loopIteration":0,"role":"USER","content":[{"contentType":"DOCUMENT"}],"toolCalls":[]}',
161
+ );
162
+ assert.throws(() => store.recordTurns("job:bad2", [missingPayload], "ephemeral"), TranscriptCorruptionError);
163
+
164
+ // Nothing was persisted for either rejected batch.
165
+ assert.equal(store.readTurns("job:bad").length, 0);
166
+ assert.equal(store.readTurns("job:bad2").length, 0);
167
+ });
168
+
169
+ /** Insert a raw (possibly corrupt) turn row straight into the table, bypassing recordTurns. */
170
+ function insertRawTurn(
171
+ store: TranscriptStore,
172
+ db: TestDb,
173
+ row: {
174
+ stream: string;
175
+ turn_sequence: number;
176
+ loop_iteration: number;
177
+ role: string;
178
+ content: string;
179
+ tool_calls: string;
180
+ metrics: string | null;
181
+ produced_at: number | null;
182
+ },
183
+ ): void {
184
+ store.ensureSchema();
185
+ db.run(
186
+ `INSERT INTO ${TRANSCRIPT_TURN_TABLE}
187
+ (stream, turn_sequence, loop_iteration, role, content, tool_calls, metrics, produced_at, recorded_at)
188
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
189
+ [
190
+ row.stream,
191
+ row.turn_sequence,
192
+ row.loop_iteration,
193
+ row.role,
194
+ row.content,
195
+ row.tool_calls,
196
+ row.metrics,
197
+ row.produced_at,
198
+ "2026-01-01T00:00:00.000Z",
199
+ ],
200
+ );
201
+ }
202
+
203
+ const rawTurn = {
204
+ stream: "job:corrupt",
205
+ turn_sequence: 0,
206
+ loop_iteration: 0,
207
+ role: "USER",
208
+ content: "[]",
209
+ tool_calls: "[]",
210
+ metrics: null,
211
+ produced_at: null,
212
+ };
213
+
214
+ test("readTurns fails fast on a corrupt role", () => {
215
+ const store = new TranscriptStore(db);
216
+ insertRawTurn(store, db, { ...rawTurn, role: "WIZARD" });
217
+ assert.throws(() => store.readTurns("job:corrupt"), TranscriptCorruptionError);
218
+ });
219
+
220
+ test("readTurns fails fast on a corrupt turn_sequence", () => {
221
+ const store = new TranscriptStore(db);
222
+ insertRawTurn(store, db, { ...rawTurn, turn_sequence: -1 });
223
+ assert.throws(() => store.readTurns("job:corrupt"), TranscriptCorruptionError);
224
+ });
225
+
226
+ test("readTurns fails fast on a corrupt produced_at", () => {
227
+ const store = new TranscriptStore(db);
228
+ insertRawTurn(store, db, { ...rawTurn, produced_at: -5 });
229
+ assert.throws(() => store.readTurns("job:corrupt"), TranscriptCorruptionError);
230
+ });
231
+
232
+ test("readTurns fails fast on corrupt (non-numeric) metrics", () => {
233
+ const store = new TranscriptStore(db);
234
+ insertRawTurn(store, db, { ...rawTurn, metrics: JSON.stringify({ inputTokens: "lots" }) });
235
+ assert.throws(() => store.readTurns("job:corrupt"), TranscriptCorruptionError);
236
+ });
237
+
238
+ test("readTurns fails fast on a corrupt content-block payload", () => {
239
+ const store = new TranscriptStore(db);
240
+ insertRawTurn(store, db, {
241
+ ...rawTurn,
242
+ content: JSON.stringify([{ contentType: "TEXT", documentReference: "d" }]),
243
+ });
244
+ assert.throws(() => store.readTurns("job:corrupt"), TranscriptCorruptionError);
245
+ });
246
+
247
+ test("readTurns fails fast on a negative / fractional metric (not just non-numeric)", () => {
248
+ const store = new TranscriptStore(db);
249
+ const metrics = {
250
+ inputTokens: -1,
251
+ outputTokens: 0,
252
+ reasoningTokenCount: 0,
253
+ cacheCreationTokenCount: 0,
254
+ cacheReadTokenCount: 0,
255
+ durationMs: 0,
256
+ };
257
+ insertRawTurn(store, db, { ...rawTurn, metrics: JSON.stringify(metrics) });
258
+ assert.throws(() => store.readTurns("job:corrupt"), TranscriptCorruptionError);
259
+ });
260
+
261
+ test("readTurns re-raises malformed JSON as a corruption error, not a SyntaxError", () => {
262
+ const store = new TranscriptStore(db);
263
+ insertRawTurn(store, db, { ...rawTurn, content: "{not json" });
264
+ assert.throws(() => store.readTurns("job:corrupt"), TranscriptCorruptionError);
265
+ });
266
+
267
+ test("recordTurns rejects a non-array content / toolCalls at the untyped boundary", () => {
268
+ const store = newStore();
269
+ // A caller passing runtime-invalid data through an untyped boundary: content is not an
270
+ // array. Build the fixture via JSON.parse (not a type assertion) and expect a clear
271
+ // corruption error, never a raw TypeError from `.map`.
272
+ const badContent: TranscriptTurn = JSON.parse(
273
+ '{"sequence":0,"loopIteration":0,"role":"USER","content":{},"toolCalls":[]}',
274
+ );
275
+ assert.throws(() => store.recordTurns("job:bad3", [badContent], "ephemeral"), TranscriptCorruptionError);
276
+
277
+ const badToolCalls: TranscriptTurn = JSON.parse(
278
+ '{"sequence":0,"loopIteration":0,"role":"USER","content":[],"toolCalls":"nope"}',
279
+ );
280
+ assert.throws(() => store.recordTurns("job:bad4", [badToolCalls], "ephemeral"), TranscriptCorruptionError);
281
+
282
+ assert.equal(store.readTurns("job:bad3").length, 0);
283
+ assert.equal(store.readTurns("job:bad4").length, 0);
284
+ });
285
+
286
+ test("recordTurns re-raises a non-JSON-serialisable payload (bigint) as a corruption error", () => {
287
+ const store = newStore();
288
+ // `bigint` is assignable to an OBJECT block's `unknown` payload but makes JSON.stringify
289
+ // throw a raw TypeError. The store must re-raise it inside its own error taxonomy and
290
+ // leave nothing persisted.
291
+ const turn: TranscriptTurn = {
292
+ sequence: 0,
293
+ loopIteration: 0,
294
+ role: "ASSISTANT",
295
+ content: [{ contentType: "OBJECT", object: 10n }],
296
+ toolCalls: [],
297
+ };
298
+ assert.throws(() => store.recordTurns("job:bigint", [turn], "ephemeral"), TranscriptCorruptionError);
299
+ assert.equal(store.readTurns("job:bigint").length, 0);
300
+ });
301
+
302
+ test("recordTurns rejects a non-plain (class-instance-like) object at the untyped boundary", () => {
303
+ const store = newStore();
304
+ // A value that is an object but not a plain/null-prototype one (here, one whose prototype
305
+ // is another object — the same shape a `Date`/`Map` instance presents). Left unchecked it
306
+ // would serialise into a non-object and make the stored turn unreadable, so recordTurns
307
+ // must reject it up front. `Object.create` returns `any`, so no type assertion is needed.
308
+ const inheritedProto: Record<string, unknown> = { inherited: true };
309
+ const nonPlainArgs: Record<string, unknown> = Object.create(inheritedProto);
310
+ nonPlainArgs.path = "report.pdf";
311
+ const turn: TranscriptTurn = {
312
+ sequence: 0,
313
+ loopIteration: 0,
314
+ role: "ASSISTANT",
315
+ content: [],
316
+ toolCalls: [{ toolCallId: "call-1", toolName: "read", arguments: nonPlainArgs }],
317
+ };
318
+ assert.throws(() => store.recordTurns("job:nonplain", [turn], "ephemeral"), TranscriptCorruptionError);
319
+ assert.equal(store.readTurns("job:nonplain").length, 0);
320
+ });
321
+
322
+ test("recordTurns rejects a null / non-object turn at the untyped boundary", () => {
323
+ const store = newStore();
324
+ // A caller passing `JSON.parse("null")` (or any non-object) through an untyped boundary
325
+ // must get a corruption error, never a raw "Cannot read properties of null" TypeError.
326
+ const nullTurn: TranscriptTurn = JSON.parse("null");
327
+ assert.throws(() => store.recordTurns("job:nullturn", [nullTurn], "ephemeral"), TranscriptCorruptionError);
328
+
329
+ const scalarTurn: TranscriptTurn = JSON.parse("42");
330
+ assert.throws(() => store.recordTurns("job:scalarturn", [scalarTurn], "ephemeral"), TranscriptCorruptionError);
331
+
332
+ assert.equal(store.readTurns("job:nullturn").length, 0);
333
+ assert.equal(store.readTurns("job:scalarturn").length, 0);
334
+ });