@nanobpm/agentic 0.1.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.
- package/README.md +2 -1
- package/dist/demand/model.d.ts +7 -4
- package/dist/demand/model.js +22 -4
- package/dist/demand/taskdef.d.ts +13 -1
- package/dist/demand/taskdef.js +20 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/protocol/conformance/frames.js +32 -4
- package/dist/protocol/index.d.ts +1 -1
- package/dist/protocol/payloads.d.ts +44 -0
- package/dist/protocol/payloads.js +61 -7
- package/dist/session/acp/client.d.ts +109 -0
- package/dist/session/acp/client.js +254 -0
- package/dist/session/acp/index.d.ts +27 -0
- package/dist/session/acp/index.js +27 -0
- package/dist/session/acp/jsonrpc.d.ts +25 -0
- package/dist/session/acp/jsonrpc.js +148 -0
- package/dist/session/acp/normalize.d.ts +48 -0
- package/dist/session/acp/normalize.js +162 -0
- package/dist/session/acp/protocol.d.ts +94 -0
- package/dist/session/acp/protocol.js +136 -0
- package/dist/session/acp/spawn.d.ts +36 -0
- package/dist/session/acp/spawn.js +68 -0
- package/dist/session/acp/transport.d.ts +62 -0
- package/dist/session/acp/transport.js +126 -0
- package/dist/session/adapter.d.ts +135 -0
- package/dist/session/adapter.js +24 -0
- package/dist/session/backend.d.ts +43 -0
- package/dist/session/backend.js +95 -0
- package/dist/session/events.d.ts +152 -0
- package/dist/session/events.js +192 -0
- package/dist/session/index.d.ts +31 -0
- package/dist/session/index.js +5 -0
- package/dist/session/log.d.ts +107 -0
- package/dist/session/log.js +351 -0
- package/dist/session/normalizer/claude.d.ts +23 -0
- package/dist/session/normalizer/claude.js +138 -0
- package/dist/session/normalizer/copilot.d.ts +27 -0
- package/dist/session/normalizer/copilot.js +105 -0
- package/dist/session/normalizer/deepseek.d.ts +11 -0
- package/dist/session/normalizer/deepseek.js +68 -0
- package/dist/session/normalizer/index.d.ts +36 -0
- package/dist/session/normalizer/index.js +29 -0
- package/dist/session/normalizer/kimi.d.ts +10 -0
- package/dist/session/normalizer/kimi.js +80 -0
- package/dist/session/normalizer/link.d.ts +36 -0
- package/dist/session/normalizer/link.js +56 -0
- package/dist/session/normalizer/pi.d.ts +13 -0
- package/dist/session/normalizer/pi.js +61 -0
- package/dist/session/normalizer/qwen.d.ts +11 -0
- package/dist/session/normalizer/qwen.js +65 -0
- package/dist/session/normalizer/record.d.ts +21 -0
- package/dist/session/normalizer/record.js +87 -0
- package/dist/session/normalizer/types.d.ts +139 -0
- package/dist/session/normalizer/types.js +31 -0
- package/dist/session/schema.d.ts +38 -0
- package/dist/session/schema.js +74 -0
- package/dist/transcript/index.d.ts +2 -2
- package/dist/transcript/index.js +1 -1
- package/dist/transcript/schema.d.ts +23 -1
- package/dist/transcript/schema.js +34 -1
- package/dist/transcript/store.d.ts +93 -5
- package/dist/transcript/store.js +287 -6
- package/package.json +17 -1
- package/src/demand/model.test.ts +82 -4
- package/src/demand/model.ts +30 -9
- package/src/demand/taskdef.test.ts +51 -6
- package/src/demand/taskdef.ts +31 -2
- package/src/index.ts +1 -0
- package/src/protocol/conformance/frames.ts +32 -4
- package/src/protocol/index.ts +4 -0
- package/src/protocol/payloads.test.ts +31 -1
- package/src/protocol/payloads.ts +110 -7
- package/src/session/acp/client.test.ts +222 -0
- package/src/session/acp/client.ts +356 -0
- package/src/session/acp/fake-agent.ts +71 -0
- package/src/session/acp/index.ts +68 -0
- package/src/session/acp/integration.test.ts +37 -0
- package/src/session/acp/jsonrpc.test.ts +75 -0
- package/src/session/acp/jsonrpc.ts +171 -0
- package/src/session/acp/normalize.test.ts +150 -0
- package/src/session/acp/normalize.ts +204 -0
- package/src/session/acp/protocol.ts +178 -0
- package/src/session/acp/spawn.test.ts +45 -0
- package/src/session/acp/spawn.ts +91 -0
- package/src/session/acp/transport.test.ts +82 -0
- package/src/session/acp/transport.ts +155 -0
- package/src/session/adapter.ts +159 -0
- package/src/session/backend.test.ts +198 -0
- package/src/session/backend.ts +128 -0
- package/src/session/events.test.ts +168 -0
- package/src/session/events.ts +347 -0
- package/src/session/index.ts +67 -0
- package/src/session/log.test.ts +215 -0
- package/src/session/log.ts +525 -0
- package/src/session/normalizer/backend-integration.test.ts +103 -0
- package/src/session/normalizer/claude.test.ts +68 -0
- package/src/session/normalizer/claude.ts +136 -0
- package/src/session/normalizer/copilot.test.ts +59 -0
- package/src/session/normalizer/copilot.ts +133 -0
- package/src/session/normalizer/deepseek.ts +80 -0
- package/src/session/normalizer/index.ts +61 -0
- package/src/session/normalizer/kimi.ts +82 -0
- package/src/session/normalizer/link.test.ts +24 -0
- package/src/session/normalizer/link.ts +81 -0
- package/src/session/normalizer/pi.ts +75 -0
- package/src/session/normalizer/probe.test.ts +49 -0
- package/src/session/normalizer/qwen.test.ts +20 -0
- package/src/session/normalizer/qwen.ts +77 -0
- package/src/session/normalizer/record.test.ts +68 -0
- package/src/session/normalizer/record.ts +88 -0
- package/src/session/normalizer/resume.test.ts +25 -0
- package/src/session/normalizer/types.ts +152 -0
- package/src/session/normalizer/vectors.test.ts +180 -0
- package/src/session/schema.test.ts +84 -0
- package/src/session/schema.ts +78 -0
- package/src/session/test-db.ts +56 -0
- package/src/transcript/index.ts +8 -0
- package/src/transcript/schema.test.ts +31 -4
- package/src/transcript/schema.ts +36 -1
- package/src/transcript/store.ts +438 -6
- package/src/transcript/turns.test.ts +334 -0
- package/dist/blackboard/test-db.d.ts +0 -5
- package/dist/blackboard/test-db.js +0 -42
- package/dist/presence/test-db.d.ts +0 -5
- package/dist/presence/test-db.js +0 -42
- package/dist/transcript/test-db.d.ts +0 -5
- package/dist/transcript/test-db.js +0 -41
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { parseSessionEvent, type SessionEvent } from "../events.ts";
|
|
4
|
+
import { normalizeSession } from "./link.ts";
|
|
5
|
+
import { FLEET_NORMALIZERS } from "./index.ts";
|
|
6
|
+
import type { DistributiveOmit, HarnessNormalizer } from "./types.ts";
|
|
7
|
+
import { copilotNormalizer } from "./copilot.ts";
|
|
8
|
+
import { claudeNormalizer } from "./claude.ts";
|
|
9
|
+
import { qwenNormalizer } from "./qwen.ts";
|
|
10
|
+
import { kimiNormalizer } from "./kimi.ts";
|
|
11
|
+
import { piNormalizer } from "./pi.ts";
|
|
12
|
+
import { deepseekNormalizer } from "./deepseek.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The shared canonical scenario every dialect must map onto (ADR 0062 slice 3
|
|
16
|
+
* acceptance: "shared normalization test vectors validate every dialect maps
|
|
17
|
+
* onto the same canonical SessionEvent shape"). It is the *semantic* projection —
|
|
18
|
+
* the canonical union payload minus the causal-chain fields (`id`/`parentId`),
|
|
19
|
+
* which the shared linker assigns and a separate test checks. A native transcript
|
|
20
|
+
* from *any* harness in the fleet, when normalized, must equal this exactly.
|
|
21
|
+
*/
|
|
22
|
+
const CANONICAL: readonly DistributiveOmit<SessionEvent, "id" | "parentId">[] = [
|
|
23
|
+
{ type: "user", text: "please refactor the parser" },
|
|
24
|
+
{ type: "reasoning", text: "weighing two approaches", providerContinuation: "OPAQUE-CONT" },
|
|
25
|
+
{ type: "assistant", text: "here is the plan" },
|
|
26
|
+
{ type: "tool-call", callId: "t1", name: "write_file", args: { path: "a.ts" } },
|
|
27
|
+
{ type: "tool-result", callId: "t1", ok: true, result: "written" },
|
|
28
|
+
{ type: "usage", inputTokens: 100, outputTokens: 50, model: "m-1" },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** One dialect's native transcript that must normalize to {@link CANONICAL}. */
|
|
32
|
+
interface Vector {
|
|
33
|
+
readonly normalizer: HarnessNormalizer;
|
|
34
|
+
readonly records: readonly unknown[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const VECTORS: readonly Vector[] = [
|
|
38
|
+
{
|
|
39
|
+
// @github/copilot — copilot-sdk SessionEvent stream. reasoningOpaque is the
|
|
40
|
+
// provider reasoning-continuation blob (ADR 0062 §5 resume-critical fidelity).
|
|
41
|
+
normalizer: copilotNormalizer,
|
|
42
|
+
records: [
|
|
43
|
+
{ type: "user_message", text: "please refactor the parser" },
|
|
44
|
+
{ type: "reasoning", text: "weighing two approaches", reasoningOpaque: "OPAQUE-CONT" },
|
|
45
|
+
{ type: "assistant_message", text: "here is the plan" },
|
|
46
|
+
{ type: "tool_call", id: "t1", name: "write_file", arguments: { path: "a.ts" } },
|
|
47
|
+
{ type: "tool_result", id: "t1", output: "written" },
|
|
48
|
+
{ type: "usage", inputTokens: 100, outputTokens: 50, model: "m-1" },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
// Claude Code — `-p --output-format stream-json`. thinking.signature is the
|
|
53
|
+
// encrypted reasoning-continuation token; usage rides the terminal `result`.
|
|
54
|
+
normalizer: claudeNormalizer,
|
|
55
|
+
records: [
|
|
56
|
+
{ type: "user", message: { role: "user", content: [{ type: "text", text: "please refactor the parser" }] } },
|
|
57
|
+
{
|
|
58
|
+
type: "assistant",
|
|
59
|
+
message: {
|
|
60
|
+
role: "assistant",
|
|
61
|
+
content: [
|
|
62
|
+
{ type: "thinking", thinking: "weighing two approaches", signature: "OPAQUE-CONT" },
|
|
63
|
+
{ type: "text", text: "here is the plan" },
|
|
64
|
+
{ type: "tool_use", id: "t1", name: "write_file", input: { path: "a.ts" } },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{ type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "written" }] } },
|
|
69
|
+
{ type: "result", subtype: "success", model: "m-1", usage: { input_tokens: 100, output_tokens: 50 } },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
// Qwen Code — `-p -o stream-json` (Gemini-CLI lineage).
|
|
74
|
+
normalizer: qwenNormalizer,
|
|
75
|
+
records: [
|
|
76
|
+
{ type: "content", role: "user", text: "please refactor the parser" },
|
|
77
|
+
{ type: "thought", description: "weighing two approaches", thoughtSignature: "OPAQUE-CONT" },
|
|
78
|
+
{ type: "content", role: "model", text: "here is the plan" },
|
|
79
|
+
{ type: "tool_call_request", callId: "t1", name: "write_file", args: { path: "a.ts" } },
|
|
80
|
+
{ type: "tool_call_response", callId: "t1", responseParts: "written" },
|
|
81
|
+
{ type: "usage_metadata", promptTokenCount: 100, candidatesTokenCount: 50, model: "m-1" },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
// Kimi — `-p --output-format stream-json` (event/phase discriminated).
|
|
86
|
+
normalizer: kimiNormalizer,
|
|
87
|
+
records: [
|
|
88
|
+
{ event: "text", role: "user", text: "please refactor the parser" },
|
|
89
|
+
{ event: "reasoning", text: "weighing two approaches", continuation: "OPAQUE-CONT" },
|
|
90
|
+
{ event: "text", role: "assistant", text: "here is the plan" },
|
|
91
|
+
{ event: "tool", phase: "call", id: "t1", name: "write_file", arguments: { path: "a.ts" } },
|
|
92
|
+
{ event: "tool", phase: "result", id: "t1", ok: true, output: "written" },
|
|
93
|
+
{ event: "usage", prompt_tokens: 100, completion_tokens: 50, model: "m-1" },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
// pi / little-coder — `-p --mode json` (JSON-RPC notifications).
|
|
98
|
+
normalizer: piNormalizer,
|
|
99
|
+
records: [
|
|
100
|
+
{ jsonrpc: "2.0", method: "session/message", params: { role: "user", text: "please refactor the parser" } },
|
|
101
|
+
{ jsonrpc: "2.0", method: "session/reasoning", params: { text: "weighing two approaches", continuation: "OPAQUE-CONT" } },
|
|
102
|
+
{ jsonrpc: "2.0", method: "session/message", params: { role: "assistant", text: "here is the plan" } },
|
|
103
|
+
{ jsonrpc: "2.0", method: "session/toolCall", params: { id: "t1", name: "write_file", args: { path: "a.ts" } } },
|
|
104
|
+
{ jsonrpc: "2.0", method: "session/toolResult", params: { id: "t1", ok: true, result: "written" } },
|
|
105
|
+
{ jsonrpc: "2.0", method: "session/usage", params: { inputTokens: 100, outputTokens: 50, model: "m-1" } },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
// DeepSeek Harness — live SessionEvent feed (kind-tagged).
|
|
110
|
+
normalizer: deepseekNormalizer,
|
|
111
|
+
records: [
|
|
112
|
+
{ kind: "message", role: "user", content: "please refactor the parser" },
|
|
113
|
+
{ kind: "reasoning", content: "weighing two approaches", continuation: "OPAQUE-CONT" },
|
|
114
|
+
{ kind: "message", role: "assistant", content: "here is the plan" },
|
|
115
|
+
{ kind: "tool", id: "t1", name: "write_file", arguments: { path: "a.ts" } },
|
|
116
|
+
{ kind: "tool_result", id: "t1", ok: true, output: "written" },
|
|
117
|
+
{ kind: "usage", input: 100, output: 50, model: "m-1" },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
/** Strip the linker-assigned causal-chain fields to get the semantic projection. */
|
|
123
|
+
function semantic(event: SessionEvent): DistributiveOmit<SessionEvent, "id" | "parentId"> {
|
|
124
|
+
const { id: _id, parentId: _parentId, ...rest } = event;
|
|
125
|
+
return rest;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Every dialect in the fleet has a vector (no harness ships without a test vector).
|
|
129
|
+
// Compare the harness-id *sets* (not just counts) and assert vector uniqueness, so a
|
|
130
|
+
// duplicated vector masking a missing normalizer cannot slip through a length check.
|
|
131
|
+
test("every fleet normalizer has a shared test vector", () => {
|
|
132
|
+
const vectorHarnesses = VECTORS.map((v) => v.normalizer.harness).sort();
|
|
133
|
+
const fleetHarnesses = FLEET_NORMALIZERS.map((n) => n.harness).sort();
|
|
134
|
+
assert.equal(new Set(vectorHarnesses).size, vectorHarnesses.length, "each harness must have a unique shared test vector (no duplicates)");
|
|
135
|
+
assert.deepEqual(vectorHarnesses, fleetHarnesses, "the set of test-vector harnesses must match the fleet exactly");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
for (const { normalizer, records } of VECTORS) {
|
|
139
|
+
test(`[${normalizer.harness}] native transcript normalizes onto the shared canonical shape`, () => {
|
|
140
|
+
const events = normalizeSession(normalizer, records);
|
|
141
|
+
assert.deepEqual(events.map(semantic), CANONICAL, "dialect must map onto the identical canonical SessionEvent shape");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test(`[${normalizer.harness}] normalized events form a well-formed causal chain`, () => {
|
|
145
|
+
const events = normalizeSession(normalizer, records);
|
|
146
|
+
assert.equal(events[0].parentId, null, "first event starts a fresh chain");
|
|
147
|
+
const ids = new Set<string>();
|
|
148
|
+
for (let i = 0; i < events.length; i++) {
|
|
149
|
+
assert.equal(typeof events[i].id, "string");
|
|
150
|
+
assert.ok(!ids.has(events[i].id), "ids are unique");
|
|
151
|
+
ids.add(events[i].id);
|
|
152
|
+
if (i > 0) assert.equal(events[i].parentId, events[i - 1].id, "parentId links to the prior event");
|
|
153
|
+
// The whole event re-validates against the slice-1 storage boundary.
|
|
154
|
+
assert.deepEqual(parseSessionEvent(events[i]), events[i]);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test(`[${normalizer.harness}] preserves the resume-critical provider reasoning continuation`, () => {
|
|
159
|
+
const events = normalizeSession(normalizer, records);
|
|
160
|
+
const reasoning = events.find((e) => e.type === "reasoning");
|
|
161
|
+
assert.ok(reasoning && reasoning.type === "reasoning");
|
|
162
|
+
assert.equal(reasoning.providerContinuation, "OPAQUE-CONT", "the opaque reasoning blob must survive normalization");
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
test("a resume continues the same causal chain across the boundary", () => {
|
|
167
|
+
// First leg produces a chain; the resumed leg is threaded onto the last id, so
|
|
168
|
+
// offset-free causality is preserved across the resume (parentId, not offset).
|
|
169
|
+
const first = normalizeSession(copilotNormalizer, [
|
|
170
|
+
{ type: "user_message", text: "hi" },
|
|
171
|
+
{ type: "assistant_message", text: "hello" },
|
|
172
|
+
]);
|
|
173
|
+
const lastId = first[first.length - 1].id;
|
|
174
|
+
const resumed = normalizeSession(
|
|
175
|
+
copilotNormalizer,
|
|
176
|
+
[{ type: "user_message", text: "continue" }],
|
|
177
|
+
{ parentId: lastId, newId: (() => { let n = 0; return () => `r-${n++}`; })() },
|
|
178
|
+
);
|
|
179
|
+
assert.equal(resumed[0].parentId, lastId, "the resumed transcript continues the pre-resume chain");
|
|
180
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import {
|
|
6
|
+
SESSION_CHECKPOINT_TABLE,
|
|
7
|
+
SESSION_EVENT_TABLE,
|
|
8
|
+
SESSION_LOG_TABLE,
|
|
9
|
+
SESSION_SCHEMA_SQL,
|
|
10
|
+
} from "./schema.ts";
|
|
11
|
+
import { SqliteSessionLog } from "./log.ts";
|
|
12
|
+
import { openTestDb } from "./test-db.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalise SQL for a drift comparison: drop `-- …` line comments, collapse
|
|
16
|
+
* whitespace runs to one space, trim. Two DDL scripts that create the same
|
|
17
|
+
* objects normalise identically regardless of comments/indentation.
|
|
18
|
+
*/
|
|
19
|
+
function normaliseSql(sql: string): string {
|
|
20
|
+
return sql
|
|
21
|
+
.split("\n")
|
|
22
|
+
.map((line) => line.replace(/--.*$/, ""))
|
|
23
|
+
.join("\n")
|
|
24
|
+
.replace(/\s+/g, " ")
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const migrationPath = fileURLToPath(
|
|
29
|
+
new URL("../../../../db/migrations/005_agentic_session.sql", import.meta.url),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
test("the boot migration and SqliteSessionLog's DDL do not drift", () => {
|
|
33
|
+
const migrationSql = readFileSync(migrationPath, "utf8");
|
|
34
|
+
assert.equal(
|
|
35
|
+
normaliseSql(migrationSql),
|
|
36
|
+
normaliseSql(SESSION_SCHEMA_SQL),
|
|
37
|
+
"db/migrations/005_agentic_session.sql must match SESSION_SCHEMA_SQL — update both together",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the boot migration is forward-only and additive (IF NOT EXISTS, no drops/alters)", () => {
|
|
42
|
+
const migrationSql = readFileSync(migrationPath, "utf8");
|
|
43
|
+
assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_session_log/);
|
|
44
|
+
assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_session_event/);
|
|
45
|
+
assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_session_checkpoint/);
|
|
46
|
+
assert.doesNotMatch(migrationSql, /\bDROP\b/i);
|
|
47
|
+
assert.doesNotMatch(migrationSql, /\bALTER\b/i);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("the migration takes prefix 005, after 004_urban_lineage", () => {
|
|
51
|
+
assert.match(migrationPath, /\/005_agentic_session\.sql$/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("ensureSchema creates all three session tables idempotently", (t) => {
|
|
55
|
+
const db = openTestDb(t);
|
|
56
|
+
const log = new SqliteSessionLog(db);
|
|
57
|
+
log.ensureSchema();
|
|
58
|
+
log.ensureSchema();
|
|
59
|
+
const tables = db
|
|
60
|
+
.all<{ name: string }>(
|
|
61
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?) ORDER BY name",
|
|
62
|
+
[SESSION_CHECKPOINT_TABLE, SESSION_EVENT_TABLE, SESSION_LOG_TABLE],
|
|
63
|
+
)
|
|
64
|
+
.map((r) => r.name);
|
|
65
|
+
assert.deepEqual(tables, [SESSION_CHECKPOINT_TABLE, SESSION_EVENT_TABLE, SESSION_LOG_TABLE].sort());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("the production build excludes the test-only sqlite helper (it is never published)", () => {
|
|
69
|
+
// Guards the failure-mode class the `test-db.ts` header documents: a test-only
|
|
70
|
+
// helper must not leak into the emitted `dist`. The comment claims exclusion via
|
|
71
|
+
// `tsconfig.build.json`; this asserts the config actually honours it.
|
|
72
|
+
const buildTsconfigPath = fileURLToPath(new URL("../../tsconfig.build.json", import.meta.url));
|
|
73
|
+
const buildTsconfig: unknown = JSON.parse(readFileSync(buildTsconfigPath, "utf8"));
|
|
74
|
+
assert.ok(
|
|
75
|
+
buildTsconfig !== null && typeof buildTsconfig === "object" && "exclude" in buildTsconfig,
|
|
76
|
+
"tsconfig.build.json must declare an exclude list",
|
|
77
|
+
);
|
|
78
|
+
const exclude = buildTsconfig.exclude;
|
|
79
|
+
assert.ok(Array.isArray(exclude), "tsconfig.build.json exclude must be an array");
|
|
80
|
+
assert.ok(
|
|
81
|
+
exclude.includes("**/test-db.ts"),
|
|
82
|
+
"tsconfig.build.json must exclude **/test-db.ts so the test-only helper is not shipped",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical authoritative session-log schema — ADR 0062, slice 1.
|
|
3
|
+
*
|
|
4
|
+
* The DDL here is the single source of truth {@link SqliteSessionLog.ensureSchema}
|
|
5
|
+
* applies, and is mirrored statement-for-statement by the app-boot migration
|
|
6
|
+
* `db/migrations/005_agentic_session.sql`. `schema.test.ts` normalises both and
|
|
7
|
+
* asserts they are identical — drift is a red test, not a silent boot mismatch.
|
|
8
|
+
* This mirrors the S6 transcript store's drift-guard exactly (ADR 0056 §12), the
|
|
9
|
+
* advisory precedent this authoritative log is promoted from.
|
|
10
|
+
*
|
|
11
|
+
* Three tables back the log:
|
|
12
|
+
* - `agentic_session_log` — one row per **activation** `(processInstanceKey,
|
|
13
|
+
* elementId)`: its fence high-water (`incarnation`), retention lifecycle,
|
|
14
|
+
* status, and the retained offset window (`first_offset` … `next_offset`).
|
|
15
|
+
* - `agentic_session_event` — the durable, authoritative events, keyed
|
|
16
|
+
* `(process_instance_key, element_id, event_offset)` so a re-lease can replay
|
|
17
|
+
* from any offset and a resuming incarnation can overwrite an uncommitted tail
|
|
18
|
+
* idempotently.
|
|
19
|
+
* - `agentic_session_checkpoint` — the mind/world join points, keyed
|
|
20
|
+
* `(process_instance_key, element_id, checkpoint_id)` with the pinned offset.
|
|
21
|
+
*
|
|
22
|
+
* `event_offset`/`checkpoint_offset` (not `offset`) is deliberate: `OFFSET` is a
|
|
23
|
+
* SQLite keyword, so the columns are named to avoid quoting it everywhere.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The per-activation metadata + fence table name. */
|
|
27
|
+
export const SESSION_LOG_TABLE = "agentic_session_log";
|
|
28
|
+
|
|
29
|
+
/** The durable per-event table name. */
|
|
30
|
+
export const SESSION_EVENT_TABLE = "agentic_session_event";
|
|
31
|
+
|
|
32
|
+
/** The checkpoint table name. */
|
|
33
|
+
export const SESSION_CHECKPOINT_TABLE = "agentic_session_checkpoint";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The canonical session-log DDL. Forward-only and additive; every column added
|
|
37
|
+
* here must also be added to the boot migration (the drift guard enforces it).
|
|
38
|
+
* Events are immutable once committed under an incarnation; a resume overwrites
|
|
39
|
+
* only the *uncommitted* tail past the last checkpoint (a re-key at the same
|
|
40
|
+
* `(activation, offset)`), never a committed row.
|
|
41
|
+
*/
|
|
42
|
+
export const SESSION_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${SESSION_LOG_TABLE} (
|
|
43
|
+
process_instance_key TEXT NOT NULL,
|
|
44
|
+
element_id TEXT NOT NULL,
|
|
45
|
+
incarnation INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
lifecycle TEXT NOT NULL DEFAULT 'activation',
|
|
47
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
48
|
+
created_at TEXT NOT NULL,
|
|
49
|
+
completed_at TEXT,
|
|
50
|
+
first_offset INTEGER,
|
|
51
|
+
next_offset INTEGER NOT NULL DEFAULT 0,
|
|
52
|
+
PRIMARY KEY (process_instance_key, element_id)
|
|
53
|
+
);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS ${SESSION_EVENT_TABLE} (
|
|
55
|
+
process_instance_key TEXT NOT NULL,
|
|
56
|
+
element_id TEXT NOT NULL,
|
|
57
|
+
event_offset INTEGER NOT NULL,
|
|
58
|
+
incarnation INTEGER NOT NULL,
|
|
59
|
+
event_id TEXT NOT NULL,
|
|
60
|
+
parent_id TEXT,
|
|
61
|
+
event_type TEXT NOT NULL,
|
|
62
|
+
payload TEXT NOT NULL,
|
|
63
|
+
appended_at TEXT NOT NULL,
|
|
64
|
+
PRIMARY KEY (process_instance_key, element_id, event_offset)
|
|
65
|
+
);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS ${SESSION_CHECKPOINT_TABLE} (
|
|
67
|
+
process_instance_key TEXT NOT NULL,
|
|
68
|
+
element_id TEXT NOT NULL,
|
|
69
|
+
checkpoint_id TEXT NOT NULL,
|
|
70
|
+
checkpoint_offset INTEGER NOT NULL,
|
|
71
|
+
incarnation INTEGER NOT NULL,
|
|
72
|
+
commit_sha TEXT NOT NULL,
|
|
73
|
+
effect_ledger TEXT NOT NULL,
|
|
74
|
+
created_at TEXT NOT NULL,
|
|
75
|
+
PRIMARY KEY (process_instance_key, element_id, checkpoint_id)
|
|
76
|
+
);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_${SESSION_CHECKPOINT_TABLE}_offset ON ${SESSION_CHECKPOINT_TABLE} (process_instance_key, element_id, checkpoint_offset);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_${SESSION_LOG_TABLE}_retention ON ${SESSION_LOG_TABLE} (lifecycle, status, completed_at);`;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only helper: a {@link SqliteDb} backed by an in-memory `node:sqlite`
|
|
3
|
+
* database, mirroring the Node host adapter's `wrapNodeSqlite` (and the identical
|
|
4
|
+
* helper in @nanobpm/agentic's presence/transcript families). Kept out of the
|
|
5
|
+
* published build (see `tsconfig.build.json` exclude) — it exists solely so the
|
|
6
|
+
* durable-log tests exercise the store against a real SQLite engine, not a mock.
|
|
7
|
+
*/
|
|
8
|
+
import type { TestContext } from "node:test";
|
|
9
|
+
import { DatabaseSync } from "node:sqlite";
|
|
10
|
+
import type { SqliteDb } from "./log.ts";
|
|
11
|
+
|
|
12
|
+
export interface TestDb extends SqliteDb {
|
|
13
|
+
close(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function toParams(params: unknown[]): (string | number | bigint | null | Uint8Array)[] {
|
|
17
|
+
return params.map((p) => {
|
|
18
|
+
if (p === null) return null;
|
|
19
|
+
if (typeof p === "string" || typeof p === "number" || typeof p === "bigint" || p instanceof Uint8Array) {
|
|
20
|
+
return p;
|
|
21
|
+
}
|
|
22
|
+
if (typeof p === "boolean") return p ? 1 : 0;
|
|
23
|
+
throw new TypeError(`unsupported SQLite parameter type: ${typeof p}`);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Open an in-memory test database. Pass the test's {@link TestContext} to make
|
|
29
|
+
* the DB close automatically when the test finishes (`t.after`) — the single
|
|
30
|
+
* canonical cleanup path, so no test can leak an open SQLite handle by forgetting
|
|
31
|
+
* to call `close()`. `close()` is idempotent, so an explicit call is still safe.
|
|
32
|
+
*/
|
|
33
|
+
export function openTestDb(t?: TestContext): TestDb {
|
|
34
|
+
const db = new DatabaseSync(":memory:");
|
|
35
|
+
let closed = false;
|
|
36
|
+
const close = (): void => {
|
|
37
|
+
if (closed) return;
|
|
38
|
+
closed = true;
|
|
39
|
+
db.close();
|
|
40
|
+
};
|
|
41
|
+
t?.after(close);
|
|
42
|
+
return {
|
|
43
|
+
exec: (sql) => db.exec(sql),
|
|
44
|
+
run: (sql, params = []) => {
|
|
45
|
+
const stmt = db.prepare(sql);
|
|
46
|
+
const r = stmt.run(...toParams(params));
|
|
47
|
+
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
48
|
+
},
|
|
49
|
+
all: <T>(sql: string, params: unknown[] = []): T[] => {
|
|
50
|
+
const stmt = db.prepare(sql);
|
|
51
|
+
// biome-ignore lint/plugin: Node sqlite returns untyped row objects; SqliteDb.all<T> is the host adapter boundary.
|
|
52
|
+
return stmt.all(...toParams(params)) as T[];
|
|
53
|
+
},
|
|
54
|
+
close,
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/transcript/index.ts
CHANGED
|
@@ -18,16 +18,24 @@ export type {
|
|
|
18
18
|
Clock,
|
|
19
19
|
SqliteDb,
|
|
20
20
|
TranscriptChunk,
|
|
21
|
+
TranscriptContentBlock,
|
|
22
|
+
TranscriptContentType,
|
|
21
23
|
TranscriptLifecycle,
|
|
22
24
|
TranscriptRing,
|
|
23
25
|
TranscriptSlice,
|
|
24
26
|
TranscriptStatus,
|
|
25
27
|
TranscriptStoreOptions,
|
|
26
28
|
TranscriptStream,
|
|
29
|
+
TranscriptToolCall,
|
|
30
|
+
TranscriptTurn,
|
|
31
|
+
TranscriptTurnMetrics,
|
|
32
|
+
TranscriptTurnRole,
|
|
27
33
|
} from "./store.ts";
|
|
28
34
|
|
|
29
35
|
export {
|
|
30
36
|
TRANSCRIPT_CHUNK_TABLE,
|
|
31
37
|
TRANSCRIPT_SCHEMA_SQL,
|
|
32
38
|
TRANSCRIPT_STREAM_TABLE,
|
|
39
|
+
TRANSCRIPT_TURN_SCHEMA_SQL,
|
|
40
|
+
TRANSCRIPT_TURN_TABLE,
|
|
33
41
|
} from "./schema.ts";
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
TRANSCRIPT_CHUNK_TABLE,
|
|
7
7
|
TRANSCRIPT_SCHEMA_SQL,
|
|
8
8
|
TRANSCRIPT_STREAM_TABLE,
|
|
9
|
+
TRANSCRIPT_TURN_SCHEMA_SQL,
|
|
10
|
+
TRANSCRIPT_TURN_TABLE,
|
|
9
11
|
} from "./schema.ts";
|
|
10
12
|
import { TranscriptStore } from "./store.ts";
|
|
11
13
|
import { openTestDb } from "./test-db.ts";
|
|
@@ -29,6 +31,10 @@ const migrationPath = fileURLToPath(
|
|
|
29
31
|
new URL("../../../../db/migrations/002_agentic_transcript.sql", import.meta.url),
|
|
30
32
|
);
|
|
31
33
|
|
|
34
|
+
const turnMigrationPath = fileURLToPath(
|
|
35
|
+
new URL("../../../../db/migrations/008_agentic_transcript_turns.sql", import.meta.url),
|
|
36
|
+
);
|
|
37
|
+
|
|
32
38
|
test("the boot migration and TranscriptStore's DDL do not drift", () => {
|
|
33
39
|
const migrationSql = readFileSync(migrationPath, "utf8");
|
|
34
40
|
assert.equal(
|
|
@@ -53,17 +59,38 @@ test("the migration takes prefix 002, after S2's 001 and before S7", () => {
|
|
|
53
59
|
assert.match(stream, /\/002_agentic_transcript\.sql$/);
|
|
54
60
|
});
|
|
55
61
|
|
|
56
|
-
test("
|
|
62
|
+
test("the turn-view boot migration and TranscriptStore's turn DDL do not drift", () => {
|
|
63
|
+
const migrationSql = readFileSync(turnMigrationPath, "utf8");
|
|
64
|
+
assert.equal(
|
|
65
|
+
normaliseSql(migrationSql),
|
|
66
|
+
normaliseSql(TRANSCRIPT_TURN_SCHEMA_SQL),
|
|
67
|
+
"db/migrations/008_agentic_transcript_turns.sql must match TRANSCRIPT_TURN_SCHEMA_SQL — update both together",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the turn-view boot migration is forward-only and additive (IF NOT EXISTS, no drops/alters)", () => {
|
|
72
|
+
const migrationSql = readFileSync(turnMigrationPath, "utf8");
|
|
73
|
+
assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_transcript_turn/);
|
|
74
|
+
assert.doesNotMatch(migrationSql, /\bDROP\b/i);
|
|
75
|
+
assert.doesNotMatch(migrationSql, /\bALTER\b/i);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the turn-view migration takes prefix 008 (next free after 007)", () => {
|
|
79
|
+
const stream = new URL("../../../../db/migrations/008_agentic_transcript_turns.sql", import.meta.url).pathname;
|
|
80
|
+
assert.match(stream, /\/008_agentic_transcript_turns\.sql$/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("ensureSchema creates all three transcript tables idempotently", () => {
|
|
57
84
|
const db = openTestDb();
|
|
58
85
|
const store = new TranscriptStore(db);
|
|
59
86
|
store.ensureSchema();
|
|
60
87
|
store.ensureSchema();
|
|
61
88
|
const tables = db
|
|
62
89
|
.all<{ name: string }>(
|
|
63
|
-
"SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?) ORDER BY name",
|
|
64
|
-
[TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE],
|
|
90
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?) ORDER BY name",
|
|
91
|
+
[TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE, TRANSCRIPT_TURN_TABLE],
|
|
65
92
|
)
|
|
66
93
|
.map((r) => r.name);
|
|
67
|
-
assert.deepEqual(tables, [TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE]);
|
|
94
|
+
assert.deepEqual(tables, [TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE, TRANSCRIPT_TURN_TABLE]);
|
|
68
95
|
db.close();
|
|
69
96
|
});
|
package/src/transcript/schema.ts
CHANGED
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
* statement-for-statement identical — divergence is a red test, not a silent
|
|
10
10
|
* production/boot mismatch.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Three tables back the store:
|
|
13
13
|
* - `agentic_transcript_stream` — one row per relay stream: its retention
|
|
14
14
|
* lifecycle (`ephemeral` vs `long-lived`), its status (`open`/`completed`),
|
|
15
15
|
* and the offset window (`first_offset` … `next_offset`) currently retained.
|
|
16
16
|
* - `agentic_transcript_chunk` — the durable chunks, keyed `(stream, chunk_offset)`
|
|
17
17
|
* so a flush/append is idempotent and reattach can slice from any offset.
|
|
18
|
+
* - `agentic_transcript_turn` — the additive turn-structured view (Camunda
|
|
19
|
+
* `AgentHistoryRecordValue` parity, issue #475), keyed `(stream, turn_sequence)`.
|
|
20
|
+
* It is layered over — never a replacement for — the raw chunk stream.
|
|
18
21
|
*
|
|
19
22
|
* `chunk_offset` (not `offset`) is deliberate: `OFFSET` is a SQLite keyword, so
|
|
20
23
|
* the column is named to avoid quoting it in every statement.
|
|
@@ -26,6 +29,9 @@ export const TRANSCRIPT_STREAM_TABLE = "agentic_transcript_stream";
|
|
|
26
29
|
/** The durable per-chunk table name. */
|
|
27
30
|
export const TRANSCRIPT_CHUNK_TABLE = "agentic_transcript_chunk";
|
|
28
31
|
|
|
32
|
+
/** The durable per-turn (structured-view) table name. */
|
|
33
|
+
export const TRANSCRIPT_TURN_TABLE = "agentic_transcript_turn";
|
|
34
|
+
|
|
29
35
|
/**
|
|
30
36
|
* The canonical transcript-store DDL. Forward-only and additive; every column
|
|
31
37
|
* added here must also be added to the boot migration (the drift guard enforces
|
|
@@ -49,3 +55,32 @@ CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_CHUNK_TABLE} (
|
|
|
49
55
|
PRIMARY KEY (stream, chunk_offset)
|
|
50
56
|
);
|
|
51
57
|
CREATE INDEX IF NOT EXISTS idx_${TRANSCRIPT_STREAM_TABLE}_retention ON ${TRANSCRIPT_STREAM_TABLE} (lifecycle, status, completed_at);`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The turn-structured transcript DDL — the additive, Camunda-`AgentHistoryRecordValue`
|
|
61
|
+
* parity view layered over the raw chunk stream (issue #475). It ships as its own
|
|
62
|
+
* forward-only migration `db/migrations/008_agentic_transcript_turns.sql` (the raw
|
|
63
|
+
* chunk stream in {@link TRANSCRIPT_SCHEMA_SQL} is untouched — additive, no regression
|
|
64
|
+
* to existing readers), mirrored here as the single source of truth applied by
|
|
65
|
+
* {@link TranscriptStore.ensureSchema} and kept in lockstep by a drift-guard test.
|
|
66
|
+
*
|
|
67
|
+
* One row per structured turn, keyed `(stream, turn_sequence)` so an append/re-record
|
|
68
|
+
* is idempotent (exactly the `(stream, chunk_offset)` discipline of the chunk table).
|
|
69
|
+
* `turn_sequence` is the stream-local append order and idempotency key;
|
|
70
|
+
* `loop_iteration` is the agent-loop turn counter carried as data (Camunda allows
|
|
71
|
+
* several role-split records — e.g. ASSISTANT then TOOL_RESULT — within one iteration).
|
|
72
|
+
* `content`, `tool_calls` and `metrics` hold the typed content blocks, tool calls and
|
|
73
|
+
* per-turn metrics as JSON.
|
|
74
|
+
*/
|
|
75
|
+
export const TRANSCRIPT_TURN_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_TURN_TABLE} (
|
|
76
|
+
stream TEXT NOT NULL,
|
|
77
|
+
turn_sequence INTEGER NOT NULL,
|
|
78
|
+
loop_iteration INTEGER NOT NULL,
|
|
79
|
+
role TEXT NOT NULL,
|
|
80
|
+
content TEXT NOT NULL,
|
|
81
|
+
tool_calls TEXT NOT NULL,
|
|
82
|
+
metrics TEXT,
|
|
83
|
+
produced_at INTEGER,
|
|
84
|
+
recorded_at TEXT NOT NULL,
|
|
85
|
+
PRIMARY KEY (stream, turn_sequence)
|
|
86
|
+
);`;
|