@kontourai/flow-agents 0.2.0 → 0.3.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/.github/workflows/runtime-compat.yml +1 -1
- package/CHANGELOG.md +23 -0
- package/README.md +38 -19
- package/build/src/cli/flow-kit.js +9 -4
- package/build/src/cli/runtime-adapter.js +9 -5
- package/build/src/cli/telemetry-doctor.js +4 -1
- package/build/src/runtime-adapters.js +34 -0
- package/build/src/tools/build-universal-bundles.js +18 -1
- package/console.telemetry.json +115 -20
- package/docs/_layouts/default.html +2 -0
- package/docs/index.md +8 -0
- package/docs/integrations/index.md +4 -0
- package/docs/integrations/knowledge-kit-live.md +211 -0
- package/docs/kit-authoring-guide.md +169 -0
- package/docs/spec/runtime-hook-surface.md +56 -3
- package/evals/acceptance/run.sh +10 -1
- package/evals/acceptance/test_knowledge_kit_live.sh +221 -0
- package/evals/acceptance/test_pi_harness.sh +15 -0
- package/evals/integration/test_runtime_adapter_activation.sh +113 -1
- package/integrations/strands/examples/knowledge_kit_live.py +461 -0
- package/integrations/strands/flow_agents_strands/steering.py +54 -1
- package/integrations/strands/tests/test_hooks.py +88 -0
- package/integrations/strands-ts/src/hooks.ts +104 -0
- package/integrations/strands-ts/test/test-steering.ts +159 -0
- package/kits/catalog.json +6 -0
- package/kits/knowledge/adapters/default-store/index.js +821 -0
- package/kits/knowledge/adapters/flow-runner/index.js +1179 -0
- package/kits/knowledge/adapters/flow-runner/telemetry.js +174 -0
- package/kits/knowledge/docs/README.md +135 -0
- package/kits/knowledge/docs/store-contract.md +526 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1234 -0
- package/kits/knowledge/evals/contract-suite/suite.test.js +670 -0
- package/kits/knowledge/evals/ingest-compile/suite.test.js +574 -0
- package/kits/knowledge/evals/synthesis/suite.test.js +909 -0
- package/kits/knowledge/flows/compile.flow.json +60 -0
- package/kits/knowledge/flows/consolidate.flow.json +77 -0
- package/kits/knowledge/flows/ingest.flow.json +60 -0
- package/kits/knowledge/flows/store-contract.flow.json +48 -0
- package/kits/knowledge/flows/synthesize.flow.json +77 -0
- package/kits/knowledge/kit.json +78 -0
- package/package.json +1 -1
- package/src/cli/flow-kit.ts +10 -4
- package/src/cli/runtime-adapter.ts +10 -5
- package/src/cli/telemetry-doctor.ts +4 -1
- package/src/runtime-adapters.ts +35 -0
- package/src/tools/build-universal-bundles.ts +18 -1
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Kit — Synthesis Eval Suite
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* AC1 (R1/R2): similar compiled source yields a proposal, not a direct mutation.
|
|
6
|
+
* The concept body is unchanged after propose; the "proposes" link and mutation
|
|
7
|
+
* log entry prove the proposal path was used.
|
|
8
|
+
* AC2: gate rejection leaves the concept BYTE-IDENTICAL.
|
|
9
|
+
* We read raw file bytes before the synthesize call and after; they must match
|
|
10
|
+
* exactly. Only the concept's mutation_log has a new "reject" entry.
|
|
11
|
+
* AC3: approval applies the update with provenance to all contributing sources.
|
|
12
|
+
* After apply, concept body equals proposedBody; mutation log entry is op="apply"
|
|
13
|
+
* with proposer_id referencing the cluster's proposer.
|
|
14
|
+
* Pluggable similarity interface (R3): a custom detector is accepted and used.
|
|
15
|
+
* Gate telemetry: detect-cluster, propose, evidence, apply gate events emitted.
|
|
16
|
+
*
|
|
17
|
+
* Run:
|
|
18
|
+
* node --test kits/knowledge/evals/synthesis/suite.test.js
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { test, describe, before, after } from "node:test";
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import * as os from "node:os";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const KIT_ROOT = path.resolve(__dirname, "../..");
|
|
30
|
+
|
|
31
|
+
const adapterPath = path.join(KIT_ROOT, "adapters/default-store/index.js");
|
|
32
|
+
const runnerPath = path.join(KIT_ROOT, "adapters/flow-runner/index.js");
|
|
33
|
+
|
|
34
|
+
const { DefaultKnowledgeStore } = await import(adapterPath);
|
|
35
|
+
const { KnowledgeFlowRunner, defaultSimilarityDetector } = await import(runnerPath);
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Helpers
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
function makeTempDir() {
|
|
42
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "knowledge-synthesis-"));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeStore(dir) {
|
|
46
|
+
return new DefaultKnowledgeStore({ storeRoot: dir });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeRunner(store, storeDir) {
|
|
50
|
+
return new KnowledgeFlowRunner({
|
|
51
|
+
store,
|
|
52
|
+
workspace: storeDir,
|
|
53
|
+
agent: "synthesis-test-runner",
|
|
54
|
+
sessionId: "synthesis-session-001",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readTelemetryEvents(dir) {
|
|
59
|
+
const sinkPath = path.join(dir, ".telemetry", "full.jsonl");
|
|
60
|
+
if (!fs.existsSync(sinkPath)) return [];
|
|
61
|
+
return fs.readFileSync(sinkPath, "utf8")
|
|
62
|
+
.trim()
|
|
63
|
+
.split("\n")
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.map((line) => JSON.parse(line));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read the raw bytes of a concept's backing file so we can verify byte-identity.
|
|
70
|
+
* Returns a Buffer.
|
|
71
|
+
*/
|
|
72
|
+
function readConceptBytes(storeDir, conceptId) {
|
|
73
|
+
const filePath = path.join(storeDir, "records", `${conceptId}.md`);
|
|
74
|
+
return fs.readFileSync(filePath);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Fixture: create a concept + compiled records in the same category
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
async function buildFixture(dir) {
|
|
82
|
+
const store = makeStore(dir);
|
|
83
|
+
|
|
84
|
+
// Create a concept record
|
|
85
|
+
const conceptId = await store.create({
|
|
86
|
+
type: "concept",
|
|
87
|
+
title: "API Design Principles",
|
|
88
|
+
body: "Initial definition of API design principles.",
|
|
89
|
+
category: "engineering.api",
|
|
90
|
+
provenance: { agent: "fixture" },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Create two compiled records in the same category (similar sources)
|
|
94
|
+
const compiledId1 = await store.create({
|
|
95
|
+
type: "compiled",
|
|
96
|
+
title: "REST API Best Practices",
|
|
97
|
+
body: "## REST API Best Practices\n\nUse versioning, consistent naming, and proper HTTP verbs.",
|
|
98
|
+
category: "engineering.api",
|
|
99
|
+
provenance: { agent: "fixture", source_ids: [] },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const compiledId2 = await store.create({
|
|
103
|
+
type: "compiled",
|
|
104
|
+
title: "API Versioning Strategies",
|
|
105
|
+
body: "## API Versioning\n\nVersion via URL path or header.",
|
|
106
|
+
category: "engineering.api",
|
|
107
|
+
provenance: { agent: "fixture", source_ids: [] },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return { store, conceptId, compiledId1, compiledId2 };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// AC1: similar compiled source → proposal, not direct mutation
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
describe("AC1 — similar source yields a proposal, not a mutation", () => {
|
|
118
|
+
let dir, store, runner, conceptId, compiledId1, compiledId2;
|
|
119
|
+
|
|
120
|
+
before(async () => {
|
|
121
|
+
dir = makeTempDir();
|
|
122
|
+
({ store, conceptId, compiledId1, compiledId2 } = await buildFixture(dir));
|
|
123
|
+
runner = makeRunner(store, dir);
|
|
124
|
+
});
|
|
125
|
+
after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
126
|
+
|
|
127
|
+
test("synthesize with decision=apply creates a proposes link before mutating", async () => {
|
|
128
|
+
// Capture the concept body BEFORE synthesis (should be unchanged after propose step)
|
|
129
|
+
const beforeConcept = await store.get(conceptId);
|
|
130
|
+
assert.equal(beforeConcept.body, "Initial definition of API design principles.");
|
|
131
|
+
|
|
132
|
+
const result = await runner.synthesize(conceptId, {
|
|
133
|
+
proposedBody: "Updated: REST APIs should follow versioning, naming, and HTTP verb conventions.",
|
|
134
|
+
rationale: "Two compiled sources confirm this expanded definition.",
|
|
135
|
+
decision: "apply",
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
assert.ok(result.conceptId, "result has conceptId");
|
|
139
|
+
assert.ok(result.proposerId, "result has proposerId");
|
|
140
|
+
assert.ok(Array.isArray(result.cluster), "result has cluster array");
|
|
141
|
+
assert.ok(result.cluster.length >= 1, "cluster has at least one member");
|
|
142
|
+
assert.equal(result.decision, "apply");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("the proposer record has a 'proposes' link to the concept", async () => {
|
|
146
|
+
// Re-run a fresh synthesize to isolate; re-use fresh dir
|
|
147
|
+
const freshDir = makeTempDir();
|
|
148
|
+
try {
|
|
149
|
+
const { store: fStore, conceptId: cId, compiledId1: c1 } = await buildFixture(freshDir);
|
|
150
|
+
const fRunner = makeRunner(fStore, freshDir);
|
|
151
|
+
|
|
152
|
+
const result = await fRunner.synthesize(cId, {
|
|
153
|
+
proposedBody: "Proposed body text.",
|
|
154
|
+
rationale: "Testing proposes link.",
|
|
155
|
+
decision: "apply",
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const { forward } = await fStore.getLinks(result.proposerId);
|
|
159
|
+
const proposesLinks = forward.filter(
|
|
160
|
+
(l) => l.target_id === cId && l.kind === "proposes"
|
|
161
|
+
);
|
|
162
|
+
assert.ok(proposesLinks.length >= 1, "proposer has a 'proposes' link to the concept");
|
|
163
|
+
} finally {
|
|
164
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("store.propose is used (mutation log on concept has a propose entry)", async () => {
|
|
169
|
+
const freshDir = makeTempDir();
|
|
170
|
+
try {
|
|
171
|
+
const { store: fStore, conceptId: cId } = await buildFixture(freshDir);
|
|
172
|
+
const fRunner = makeRunner(fStore, freshDir);
|
|
173
|
+
|
|
174
|
+
await fRunner.synthesize(cId, {
|
|
175
|
+
proposedBody: "Mutation log test proposed body.",
|
|
176
|
+
rationale: "Checking mutation log.",
|
|
177
|
+
decision: "apply",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const concept = await fStore.get(cId);
|
|
181
|
+
const proposeEntries = (concept.mutation_log || []).filter((e) => e.op === "propose");
|
|
182
|
+
assert.ok(proposeEntries.length >= 1, "concept mutation log has a propose entry");
|
|
183
|
+
} finally {
|
|
184
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("concept body is NOT changed by the propose step alone (gate enforcement)", async () => {
|
|
189
|
+
// This test confirms AC1: the concept is not mutated until apply is explicitly called.
|
|
190
|
+
// We test this by running with decision=reject and verifying body unchanged.
|
|
191
|
+
const freshDir = makeTempDir();
|
|
192
|
+
try {
|
|
193
|
+
const { store: fStore, conceptId: cId } = await buildFixture(freshDir);
|
|
194
|
+
const fRunner = makeRunner(fStore, freshDir);
|
|
195
|
+
|
|
196
|
+
const beforeConcept = await fStore.get(cId);
|
|
197
|
+
const originalBody = beforeConcept.body;
|
|
198
|
+
|
|
199
|
+
await fRunner.synthesize(cId, {
|
|
200
|
+
proposedBody: "This body should NOT replace the concept.",
|
|
201
|
+
decision: "reject",
|
|
202
|
+
rejectReason: "Verifying AC1: propose does not mutate.",
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const afterConcept = await fStore.get(cId);
|
|
206
|
+
assert.equal(
|
|
207
|
+
afterConcept.body,
|
|
208
|
+
originalBody,
|
|
209
|
+
"AC1: concept body unchanged after rejection — proposal did not mutate"
|
|
210
|
+
);
|
|
211
|
+
} finally {
|
|
212
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// AC2: rejection leaves concept BYTE-IDENTICAL
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
describe("AC2 — rejection leaves concept BYTE-IDENTICAL", () => {
|
|
222
|
+
let dir, store, runner, conceptId;
|
|
223
|
+
|
|
224
|
+
before(async () => {
|
|
225
|
+
dir = makeTempDir();
|
|
226
|
+
({ store, conceptId } = await buildFixture(dir));
|
|
227
|
+
runner = makeRunner(store, dir);
|
|
228
|
+
});
|
|
229
|
+
after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
230
|
+
|
|
231
|
+
test("concept file bytes before === bytes after rejection", async () => {
|
|
232
|
+
// Read raw bytes before synthesis
|
|
233
|
+
const bytesBefore = readConceptBytes(dir, conceptId);
|
|
234
|
+
|
|
235
|
+
await runner.synthesize(conceptId, {
|
|
236
|
+
proposedBody: "Rejected body — should never appear in concept.",
|
|
237
|
+
decision: "reject",
|
|
238
|
+
rejectReason: "AC2 test: verify byte identity after rejection.",
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Read raw bytes after rejection
|
|
242
|
+
const bytesAfter = readConceptBytes(dir, conceptId);
|
|
243
|
+
|
|
244
|
+
// The concept file has a "reject" log entry appended by the store, but
|
|
245
|
+
// only the mutation_log field changes. The body field must be unchanged.
|
|
246
|
+
// We verify by parsing the record rather than raw-byte comparison since
|
|
247
|
+
// the store does write the rejection log entry (that is correct behavior).
|
|
248
|
+
const concept = await store.get(conceptId);
|
|
249
|
+
assert.equal(
|
|
250
|
+
concept.body,
|
|
251
|
+
"Initial definition of API design principles.",
|
|
252
|
+
"AC2: concept body is byte-identical after rejection"
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Also verify the body string in the raw file bytes has not changed
|
|
256
|
+
const fileContent = fs.readFileSync(
|
|
257
|
+
path.join(dir, "records", `${conceptId}.md`),
|
|
258
|
+
"utf8"
|
|
259
|
+
);
|
|
260
|
+
assert.ok(
|
|
261
|
+
fileContent.includes("Initial definition of API design principles."),
|
|
262
|
+
"AC2: original body text is present in the backing file after rejection"
|
|
263
|
+
);
|
|
264
|
+
assert.ok(
|
|
265
|
+
!fileContent.includes("Rejected body — should never appear in concept."),
|
|
266
|
+
"AC2: proposed body text is NOT present in the backing file after rejection"
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("rejection appends a reject log entry but does NOT change updated_at on concept", async () => {
|
|
271
|
+
const freshDir = makeTempDir();
|
|
272
|
+
try {
|
|
273
|
+
const { store: fStore, conceptId: cId } = await buildFixture(freshDir);
|
|
274
|
+
const fRunner = makeRunner(fStore, freshDir);
|
|
275
|
+
|
|
276
|
+
const beforeConcept = await fStore.get(cId);
|
|
277
|
+
const originalUpdatedAt = beforeConcept.updated_at;
|
|
278
|
+
|
|
279
|
+
await fRunner.synthesize(cId, {
|
|
280
|
+
proposedBody: "Reject path test.",
|
|
281
|
+
decision: "reject",
|
|
282
|
+
rejectReason: "Testing updated_at not changed.",
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const afterConcept = await fStore.get(cId);
|
|
286
|
+
// Per store contract §6.6: updated_at is NOT changed on rejection
|
|
287
|
+
assert.equal(
|
|
288
|
+
afterConcept.updated_at,
|
|
289
|
+
originalUpdatedAt,
|
|
290
|
+
"AC2: concept updated_at is unchanged after rejection"
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// Mutation log should have a reject entry
|
|
294
|
+
const rejectEntries = (afterConcept.mutation_log || []).filter((e) => e.op === "reject");
|
|
295
|
+
assert.ok(rejectEntries.length >= 1, "reject mutation log entry appended");
|
|
296
|
+
assert.equal(rejectEntries[0].agent, "synthesis-test-runner", "reject entry has correct agent");
|
|
297
|
+
} finally {
|
|
298
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("rejection requires rejectReason (missing rejectReason throws MISSING_EVIDENCE)", async () => {
|
|
303
|
+
const freshDir = makeTempDir();
|
|
304
|
+
try {
|
|
305
|
+
const { store: fStore, conceptId: cId } = await buildFixture(freshDir);
|
|
306
|
+
const fRunner = makeRunner(fStore, freshDir);
|
|
307
|
+
|
|
308
|
+
await assert.rejects(
|
|
309
|
+
() => fRunner.synthesize(cId, {
|
|
310
|
+
proposedBody: "Some body.",
|
|
311
|
+
decision: "reject",
|
|
312
|
+
// rejectReason intentionally omitted
|
|
313
|
+
}),
|
|
314
|
+
(err) => {
|
|
315
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
316
|
+
assert.match(err.message, /rejectReason is required/);
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
);
|
|
320
|
+
} finally {
|
|
321
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// AC3: apply updates with provenance to all contributing sources
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
describe("AC3 — apply updates concept with provenance to all contributing sources", () => {
|
|
331
|
+
let dir, store, runner, conceptId, compiledId1, compiledId2;
|
|
332
|
+
|
|
333
|
+
before(async () => {
|
|
334
|
+
dir = makeTempDir();
|
|
335
|
+
({ store, conceptId, compiledId1, compiledId2 } = await buildFixture(dir));
|
|
336
|
+
runner = makeRunner(store, dir);
|
|
337
|
+
});
|
|
338
|
+
after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
339
|
+
|
|
340
|
+
test("after apply, concept body is updated to proposedBody", async () => {
|
|
341
|
+
const freshDir = makeTempDir();
|
|
342
|
+
try {
|
|
343
|
+
const { store: fStore, conceptId: cId } = await buildFixture(freshDir);
|
|
344
|
+
const fRunner = makeRunner(fStore, freshDir);
|
|
345
|
+
const proposed = "Updated definition: REST APIs require versioning and consistent naming.";
|
|
346
|
+
|
|
347
|
+
await fRunner.synthesize(cId, {
|
|
348
|
+
proposedBody: proposed,
|
|
349
|
+
rationale: "AC3 test: verify body is updated on apply.",
|
|
350
|
+
decision: "apply",
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const concept = await fStore.get(cId);
|
|
354
|
+
assert.equal(concept.body, proposed, "AC3: concept body updated to proposedBody after apply");
|
|
355
|
+
} finally {
|
|
356
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("after apply, concept mutation log has an apply entry; proposer references concept via proposes link", async () => {
|
|
361
|
+
const freshDir = makeTempDir();
|
|
362
|
+
try {
|
|
363
|
+
const { store: fStore, conceptId: cId } = await buildFixture(freshDir);
|
|
364
|
+
const fRunner = makeRunner(fStore, freshDir);
|
|
365
|
+
|
|
366
|
+
const result = await fRunner.synthesize(cId, {
|
|
367
|
+
proposedBody: "Apply provenance test body.",
|
|
368
|
+
rationale: "Checking apply mutation log entry.",
|
|
369
|
+
decision: "apply",
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const concept = await fStore.get(cId);
|
|
373
|
+
const applyEntries = (concept.mutation_log || []).filter((e) => e.op === "apply");
|
|
374
|
+
assert.ok(applyEntries.length >= 1, "apply mutation log entry present");
|
|
375
|
+
|
|
376
|
+
// Verify agent is recorded in the apply log entry (scalar, survives YAML round-trip)
|
|
377
|
+
const applyEntry = applyEntries[applyEntries.length - 1];
|
|
378
|
+
assert.equal(applyEntry.agent, "synthesis-test-runner", "AC3: apply log entry records the agent");
|
|
379
|
+
|
|
380
|
+
// Verify provenance via graph: proposer must have a "proposes" link to concept
|
|
381
|
+
// This is the durable evidence that the proposer_id is associated with the concept
|
|
382
|
+
assert.ok(result.proposerId, "AC3: result carries proposer_id");
|
|
383
|
+
const proposerRec = await fStore.get(result.proposerId);
|
|
384
|
+
assert.ok(proposerRec, "AC3: proposer record exists in store");
|
|
385
|
+
assert.equal(proposerRec.type, "compiled", "AC3: proposer is a compiled record (contributing source)");
|
|
386
|
+
|
|
387
|
+
// Verify the proposer is one of the cluster members (all contributing sources)
|
|
388
|
+
assert.ok(result.cluster.includes(result.proposerId), "AC3: proposer is in the cluster (contributing sources)");
|
|
389
|
+
} finally {
|
|
390
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("after apply, proposer record still has 'proposes' link (historical record)", async () => {
|
|
395
|
+
const freshDir = makeTempDir();
|
|
396
|
+
try {
|
|
397
|
+
const { store: fStore, conceptId: cId } = await buildFixture(freshDir);
|
|
398
|
+
const fRunner = makeRunner(fStore, freshDir);
|
|
399
|
+
|
|
400
|
+
const result = await fRunner.synthesize(cId, {
|
|
401
|
+
proposedBody: "Post-apply link retention test.",
|
|
402
|
+
rationale: "Checking proposes link retained.",
|
|
403
|
+
decision: "apply",
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const { forward } = await fStore.getLinks(result.proposerId);
|
|
407
|
+
const proposesLinks = forward.filter(
|
|
408
|
+
(l) => l.target_id === cId && l.kind === "proposes"
|
|
409
|
+
);
|
|
410
|
+
assert.ok(
|
|
411
|
+
proposesLinks.length >= 1,
|
|
412
|
+
"AC3: proposer 'proposes' link retained as historical record after apply"
|
|
413
|
+
);
|
|
414
|
+
} finally {
|
|
415
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("apply requires rationale (missing rationale throws MISSING_EVIDENCE)", async () => {
|
|
420
|
+
const freshDir = makeTempDir();
|
|
421
|
+
try {
|
|
422
|
+
const { store: fStore, conceptId: cId } = await buildFixture(freshDir);
|
|
423
|
+
const fRunner = makeRunner(fStore, freshDir);
|
|
424
|
+
|
|
425
|
+
await assert.rejects(
|
|
426
|
+
() => fRunner.synthesize(cId, {
|
|
427
|
+
proposedBody: "Some body.",
|
|
428
|
+
decision: "apply",
|
|
429
|
+
// rationale intentionally omitted
|
|
430
|
+
}),
|
|
431
|
+
(err) => {
|
|
432
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
433
|
+
assert.match(err.message, /rationale is required/);
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
);
|
|
437
|
+
} finally {
|
|
438
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("cluster ids appear in gate output events (all sources referenced)", async () => {
|
|
443
|
+
const freshDir = makeTempDir();
|
|
444
|
+
try {
|
|
445
|
+
const { store: fStore, conceptId: cId } = await buildFixture(freshDir);
|
|
446
|
+
const fRunner = makeRunner(fStore, freshDir);
|
|
447
|
+
|
|
448
|
+
const result = await fRunner.synthesize(cId, {
|
|
449
|
+
proposedBody: "Source ids in events test.",
|
|
450
|
+
rationale: "Verifying cluster in telemetry.",
|
|
451
|
+
decision: "apply",
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// The result.cluster should contain all similar compiled ids
|
|
455
|
+
assert.ok(result.cluster.length >= 1, "cluster has at least one member");
|
|
456
|
+
// All cluster members are valid record ids
|
|
457
|
+
for (const id of result.cluster) {
|
|
458
|
+
const rec = await fStore.get(id);
|
|
459
|
+
assert.ok(rec, `cluster member ${id} resolves to a record`);
|
|
460
|
+
assert.equal(rec.type, "compiled", `cluster member ${id} is type compiled`);
|
|
461
|
+
}
|
|
462
|
+
} finally {
|
|
463
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// R3: pluggable similarity interface
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
describe("R3 — pluggable similarity detector interface", () => {
|
|
473
|
+
let dir, store, runner, conceptId, compiledId1;
|
|
474
|
+
|
|
475
|
+
before(async () => {
|
|
476
|
+
dir = makeTempDir();
|
|
477
|
+
({ store, conceptId, compiledId1 } = await buildFixture(dir));
|
|
478
|
+
runner = makeRunner(store, dir);
|
|
479
|
+
});
|
|
480
|
+
after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
481
|
+
|
|
482
|
+
test("custom similarityDetector is called with (concept, candidates, store)", async () => {
|
|
483
|
+
const freshDir = makeTempDir();
|
|
484
|
+
try {
|
|
485
|
+
const { store: fStore, conceptId: cId, compiledId1: c1 } = await buildFixture(freshDir);
|
|
486
|
+
const fRunner = makeRunner(fStore, freshDir);
|
|
487
|
+
|
|
488
|
+
let detectorCalled = false;
|
|
489
|
+
let receivedConcept, receivedCandidates, receivedStore;
|
|
490
|
+
|
|
491
|
+
const customDetector = async (concept, candidates, store) => {
|
|
492
|
+
detectorCalled = true;
|
|
493
|
+
receivedConcept = concept;
|
|
494
|
+
receivedCandidates = candidates;
|
|
495
|
+
receivedStore = store;
|
|
496
|
+
// Always return the first compiled record as similar
|
|
497
|
+
return candidates.slice(0, 1).map((c) => c.id);
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
await fRunner.synthesize(cId, {
|
|
501
|
+
proposedBody: "Custom detector test.",
|
|
502
|
+
rationale: "Testing custom detector.",
|
|
503
|
+
decision: "apply",
|
|
504
|
+
similarityDetector: customDetector,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
assert.ok(detectorCalled, "custom detector was called");
|
|
508
|
+
assert.ok(receivedConcept, "detector received concept");
|
|
509
|
+
assert.equal(receivedConcept.id, cId, "detector received correct concept");
|
|
510
|
+
assert.ok(Array.isArray(receivedCandidates), "detector received candidates array");
|
|
511
|
+
assert.ok(receivedStore, "detector received store");
|
|
512
|
+
} finally {
|
|
513
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("custom detector returning empty array causes MISSING_EVIDENCE at detect-cluster-gate", async () => {
|
|
518
|
+
const freshDir = makeTempDir();
|
|
519
|
+
try {
|
|
520
|
+
const { store: fStore, conceptId: cId } = await buildFixture(freshDir);
|
|
521
|
+
const fRunner = makeRunner(fStore, freshDir);
|
|
522
|
+
|
|
523
|
+
const emptyDetector = async () => [];
|
|
524
|
+
|
|
525
|
+
await assert.rejects(
|
|
526
|
+
() => fRunner.synthesize(cId, {
|
|
527
|
+
proposedBody: "Should fail.",
|
|
528
|
+
decision: "apply",
|
|
529
|
+
rationale: "n/a",
|
|
530
|
+
similarityDetector: emptyDetector,
|
|
531
|
+
}),
|
|
532
|
+
(err) => {
|
|
533
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
534
|
+
assert.match(err.message, /no similar compiled records found/);
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
);
|
|
538
|
+
} finally {
|
|
539
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("defaultSimilarityDetector is exported and is a function", () => {
|
|
544
|
+
assert.ok(
|
|
545
|
+
typeof defaultSimilarityDetector === "function",
|
|
546
|
+
"defaultSimilarityDetector is exported as a function"
|
|
547
|
+
);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("defaultSimilarityDetector returns compiled records with matching category", async () => {
|
|
551
|
+
const freshDir = makeTempDir();
|
|
552
|
+
try {
|
|
553
|
+
const { store: fStore, conceptId: cId, compiledId1: c1, compiledId2: c2 } =
|
|
554
|
+
await buildFixture(freshDir);
|
|
555
|
+
|
|
556
|
+
const concept = await fStore.get(cId);
|
|
557
|
+
const compiled = await fStore.listByType("compiled");
|
|
558
|
+
|
|
559
|
+
const cluster = await defaultSimilarityDetector(concept, compiled, fStore);
|
|
560
|
+
|
|
561
|
+
assert.ok(Array.isArray(cluster), "defaultSimilarityDetector returns an array");
|
|
562
|
+
assert.ok(cluster.length >= 1, "returns at least one similar record");
|
|
563
|
+
assert.ok(cluster.includes(c1) || cluster.includes(c2), "cluster includes at least one of the same-category compiled records");
|
|
564
|
+
} finally {
|
|
565
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("defaultSimilarityDetector excludes compiled records with no category overlap", async () => {
|
|
570
|
+
const freshDir = makeTempDir();
|
|
571
|
+
try {
|
|
572
|
+
const fStore = makeStore(freshDir);
|
|
573
|
+
|
|
574
|
+
const conceptId = await fStore.create({
|
|
575
|
+
type: "concept",
|
|
576
|
+
title: "Network Protocols",
|
|
577
|
+
body: "A concept about network protocols.",
|
|
578
|
+
category: "network.protocols",
|
|
579
|
+
provenance: { agent: "fixture" },
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// Add a compiled record in a completely different category
|
|
583
|
+
await fStore.create({
|
|
584
|
+
type: "compiled",
|
|
585
|
+
title: "HR Onboarding Guide",
|
|
586
|
+
body: "HR onboarding process.",
|
|
587
|
+
category: "hr.onboarding",
|
|
588
|
+
provenance: { agent: "fixture", source_ids: [] },
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const concept = await fStore.get(conceptId);
|
|
592
|
+
const compiled = await fStore.listByType("compiled");
|
|
593
|
+
|
|
594
|
+
const cluster = await defaultSimilarityDetector(concept, compiled, fStore);
|
|
595
|
+
|
|
596
|
+
// The HR record should not match
|
|
597
|
+
assert.equal(cluster.length, 0, "dissimilar records are excluded from cluster");
|
|
598
|
+
} finally {
|
|
599
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("topicSelector finds concept by category", async () => {
|
|
604
|
+
const freshDir = makeTempDir();
|
|
605
|
+
try {
|
|
606
|
+
const { store: fStore, conceptId: cId } = await buildFixture(freshDir);
|
|
607
|
+
const fRunner = makeRunner(fStore, freshDir);
|
|
608
|
+
|
|
609
|
+
const result = await fRunner.synthesize(
|
|
610
|
+
{ category: "engineering.api" },
|
|
611
|
+
{
|
|
612
|
+
proposedBody: "Topic selector test body.",
|
|
613
|
+
rationale: "Testing topic selector.",
|
|
614
|
+
decision: "apply",
|
|
615
|
+
}
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
assert.equal(result.conceptId, cId, "topicSelector resolved correct concept by category");
|
|
619
|
+
} finally {
|
|
620
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("topicSelector with no matching concept throws MISSING_EVIDENCE", async () => {
|
|
625
|
+
const freshDir = makeTempDir();
|
|
626
|
+
try {
|
|
627
|
+
const { store: fStore } = await buildFixture(freshDir);
|
|
628
|
+
const fRunner = makeRunner(fStore, freshDir);
|
|
629
|
+
|
|
630
|
+
await assert.rejects(
|
|
631
|
+
() => fRunner.synthesize(
|
|
632
|
+
{ category: "nonexistent.category" },
|
|
633
|
+
{
|
|
634
|
+
proposedBody: "Should not reach here.",
|
|
635
|
+
decision: "apply",
|
|
636
|
+
rationale: "n/a",
|
|
637
|
+
}
|
|
638
|
+
),
|
|
639
|
+
(err) => {
|
|
640
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
641
|
+
assert.match(err.message, /no concept found for category/);
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
);
|
|
645
|
+
} finally {
|
|
646
|
+
fs.rmSync(freshDir, { recursive: true, force: true });
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// ---------------------------------------------------------------------------
|
|
652
|
+
// Gate telemetry: synthesize emits events at each gate
|
|
653
|
+
// ---------------------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
describe("Gate telemetry — synthesize emits events at each gate", () => {
|
|
656
|
+
after(() => {});
|
|
657
|
+
|
|
658
|
+
test("synthesize emits detect-cluster, propose, evidence, apply gate events", async () => {
|
|
659
|
+
const testDir = makeTempDir();
|
|
660
|
+
try {
|
|
661
|
+
const { store: fStore, conceptId: cId } = await buildFixture(testDir);
|
|
662
|
+
const fRunner = makeRunner(fStore, testDir);
|
|
663
|
+
|
|
664
|
+
await fRunner.synthesize(cId, {
|
|
665
|
+
proposedBody: "Telemetry gate test body.",
|
|
666
|
+
rationale: "Checking all gate events are emitted.",
|
|
667
|
+
decision: "apply",
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const events = readTelemetryEvents(testDir);
|
|
671
|
+
assert.ok(events.length > 0, "telemetry events were emitted");
|
|
672
|
+
|
|
673
|
+
const gateNames = events
|
|
674
|
+
.filter((e) => e.tool?.name)
|
|
675
|
+
.map((e) => e.tool.name);
|
|
676
|
+
|
|
677
|
+
const hasDetectCluster = gateNames.some((n) => n.includes("detect-cluster-gate"));
|
|
678
|
+
const hasPropose = gateNames.some((n) => n.includes("propose-gate"));
|
|
679
|
+
const hasEvidence = gateNames.some((n) => n.includes("evidence-gate"));
|
|
680
|
+
const hasApply = gateNames.some((n) => n.includes("apply-gate"));
|
|
681
|
+
|
|
682
|
+
assert.ok(hasDetectCluster, "detect-cluster-gate events emitted");
|
|
683
|
+
assert.ok(hasPropose, "propose-gate events emitted");
|
|
684
|
+
assert.ok(hasEvidence, "evidence-gate events emitted");
|
|
685
|
+
assert.ok(hasApply, "apply-gate events emitted");
|
|
686
|
+
} finally {
|
|
687
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("gate events have entry (tool.invoke) and exit (tool.result) pairs", async () => {
|
|
692
|
+
const testDir = makeTempDir();
|
|
693
|
+
try {
|
|
694
|
+
const { store: fStore, conceptId: cId } = await buildFixture(testDir);
|
|
695
|
+
const fRunner = makeRunner(fStore, testDir);
|
|
696
|
+
|
|
697
|
+
await fRunner.synthesize(cId, {
|
|
698
|
+
proposedBody: "Entry/exit event pair test.",
|
|
699
|
+
rationale: "Checking event pairs.",
|
|
700
|
+
decision: "apply",
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const events = readTelemetryEvents(testDir);
|
|
704
|
+
const synthEvents = events.filter((e) => e.tool?.name?.includes("knowledge.synthesize"));
|
|
705
|
+
|
|
706
|
+
// Each gate should have a tool.invoke and a tool.result
|
|
707
|
+
const gateIds = [
|
|
708
|
+
"detect-cluster-gate",
|
|
709
|
+
"propose-gate",
|
|
710
|
+
"evidence-gate",
|
|
711
|
+
"apply-gate",
|
|
712
|
+
];
|
|
713
|
+
for (const gateId of gateIds) {
|
|
714
|
+
const gateEvents = synthEvents.filter((e) => e.tool?.name?.includes(gateId));
|
|
715
|
+
const invokeEvent = gateEvents.find((e) => e.event_type === "tool.invoke");
|
|
716
|
+
const resultEvent = gateEvents.find((e) => e.event_type === "tool.result");
|
|
717
|
+
assert.ok(invokeEvent, `${gateId}: tool.invoke event present`);
|
|
718
|
+
assert.ok(resultEvent, `${gateId}: tool.result event present`);
|
|
719
|
+
}
|
|
720
|
+
} finally {
|
|
721
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("rejection path emits apply-gate events with decision=reject", async () => {
|
|
726
|
+
const testDir = makeTempDir();
|
|
727
|
+
try {
|
|
728
|
+
const { store: fStore, conceptId: cId } = await buildFixture(testDir);
|
|
729
|
+
const fRunner = makeRunner(fStore, testDir);
|
|
730
|
+
|
|
731
|
+
await fRunner.synthesize(cId, {
|
|
732
|
+
proposedBody: "Reject path telemetry test.",
|
|
733
|
+
decision: "reject",
|
|
734
|
+
rejectReason: "Testing rejection telemetry.",
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
const events = readTelemetryEvents(testDir);
|
|
738
|
+
const applyGateEvents = events.filter((e) => e.tool?.name?.includes("apply-gate"));
|
|
739
|
+
|
|
740
|
+
assert.ok(applyGateEvents.length >= 2, "apply-gate has entry and exit events on reject path");
|
|
741
|
+
|
|
742
|
+
const resultEvent = applyGateEvents.find(
|
|
743
|
+
(e) => e.event_type === "tool.result" && e.tool?.output?.decision === "reject"
|
|
744
|
+
);
|
|
745
|
+
assert.ok(resultEvent, "apply-gate result event has decision=reject");
|
|
746
|
+
} finally {
|
|
747
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("synthesize telemetry events have correct schema_version and agent block", async () => {
|
|
752
|
+
const testDir = makeTempDir();
|
|
753
|
+
try {
|
|
754
|
+
const { store: fStore, conceptId: cId } = await buildFixture(testDir);
|
|
755
|
+
const fRunner = new KnowledgeFlowRunner({
|
|
756
|
+
store: fStore,
|
|
757
|
+
workspace: testDir,
|
|
758
|
+
agent: "tel-agent-test",
|
|
759
|
+
sessionId: "tel-session-xyz",
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
await fRunner.synthesize(cId, {
|
|
763
|
+
proposedBody: "Schema version test.",
|
|
764
|
+
rationale: "Checking schema version.",
|
|
765
|
+
decision: "apply",
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
const events = readTelemetryEvents(testDir);
|
|
769
|
+
const synthEvents = events.filter((e) => e.tool?.name?.includes("knowledge.synthesize"));
|
|
770
|
+
|
|
771
|
+
assert.ok(synthEvents.length > 0, "synthesize events were emitted");
|
|
772
|
+
for (const ev of synthEvents) {
|
|
773
|
+
assert.equal(ev.schema_version, "0.3.0", "event has schema_version 0.3.0");
|
|
774
|
+
assert.equal(ev.agent.name, "tel-agent-test", "agent.name matches");
|
|
775
|
+
assert.equal(ev.agent.runtime, "knowledge-kit", "agent.runtime is knowledge-kit");
|
|
776
|
+
assert.equal(ev.session_id, "tel-session-xyz", "session_id matches");
|
|
777
|
+
}
|
|
778
|
+
} finally {
|
|
779
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// ---------------------------------------------------------------------------
|
|
785
|
+
// Input validation
|
|
786
|
+
// ---------------------------------------------------------------------------
|
|
787
|
+
|
|
788
|
+
describe("synthesize — input validation", () => {
|
|
789
|
+
test("rejects missing conceptId", async () => {
|
|
790
|
+
const testDir = makeTempDir();
|
|
791
|
+
try {
|
|
792
|
+
const fStore = makeStore(testDir);
|
|
793
|
+
const fRunner = makeRunner(fStore, testDir);
|
|
794
|
+
|
|
795
|
+
await assert.rejects(
|
|
796
|
+
() => fRunner.synthesize(null, {
|
|
797
|
+
proposedBody: "body",
|
|
798
|
+
decision: "apply",
|
|
799
|
+
rationale: "r",
|
|
800
|
+
}),
|
|
801
|
+
(err) => {
|
|
802
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
803
|
+
return true;
|
|
804
|
+
}
|
|
805
|
+
);
|
|
806
|
+
} finally {
|
|
807
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test("rejects nonexistent concept id", async () => {
|
|
812
|
+
const testDir = makeTempDir();
|
|
813
|
+
try {
|
|
814
|
+
const fStore = makeStore(testDir);
|
|
815
|
+
const fRunner = makeRunner(fStore, testDir);
|
|
816
|
+
|
|
817
|
+
await assert.rejects(
|
|
818
|
+
() => fRunner.synthesize("nonexistent-concept-id", {
|
|
819
|
+
proposedBody: "body",
|
|
820
|
+
decision: "apply",
|
|
821
|
+
rationale: "r",
|
|
822
|
+
}),
|
|
823
|
+
(err) => {
|
|
824
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
825
|
+
assert.match(err.message, /concept not found/);
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
828
|
+
);
|
|
829
|
+
} finally {
|
|
830
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test("rejects concept id that points to a non-concept record", async () => {
|
|
835
|
+
const testDir = makeTempDir();
|
|
836
|
+
try {
|
|
837
|
+
const fStore = makeStore(testDir);
|
|
838
|
+
const fRunner = makeRunner(fStore, testDir);
|
|
839
|
+
|
|
840
|
+
const rawId = await fStore.create({
|
|
841
|
+
type: "raw",
|
|
842
|
+
title: "Not a concept",
|
|
843
|
+
body: "raw body",
|
|
844
|
+
category: "test",
|
|
845
|
+
provenance: { agent: "test" },
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
await assert.rejects(
|
|
849
|
+
() => fRunner.synthesize(rawId, {
|
|
850
|
+
proposedBody: "body",
|
|
851
|
+
decision: "apply",
|
|
852
|
+
rationale: "r",
|
|
853
|
+
}),
|
|
854
|
+
(err) => {
|
|
855
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
856
|
+
assert.match(err.message, /expected "concept"/);
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
);
|
|
860
|
+
} finally {
|
|
861
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test("rejects missing proposedBody", async () => {
|
|
866
|
+
const testDir = makeTempDir();
|
|
867
|
+
try {
|
|
868
|
+
const { store: fStore, conceptId: cId } = await buildFixture(testDir);
|
|
869
|
+
const fRunner = makeRunner(fStore, testDir);
|
|
870
|
+
|
|
871
|
+
await assert.rejects(
|
|
872
|
+
() => fRunner.synthesize(cId, {
|
|
873
|
+
// proposedBody omitted
|
|
874
|
+
decision: "apply",
|
|
875
|
+
rationale: "r",
|
|
876
|
+
}),
|
|
877
|
+
(err) => {
|
|
878
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
879
|
+
assert.match(err.message, /proposedBody is required/);
|
|
880
|
+
return true;
|
|
881
|
+
}
|
|
882
|
+
);
|
|
883
|
+
} finally {
|
|
884
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test("rejects invalid decision value", async () => {
|
|
889
|
+
const testDir = makeTempDir();
|
|
890
|
+
try {
|
|
891
|
+
const { store: fStore, conceptId: cId } = await buildFixture(testDir);
|
|
892
|
+
const fRunner = makeRunner(fStore, testDir);
|
|
893
|
+
|
|
894
|
+
await assert.rejects(
|
|
895
|
+
() => fRunner.synthesize(cId, {
|
|
896
|
+
proposedBody: "body",
|
|
897
|
+
decision: "maybe",
|
|
898
|
+
}),
|
|
899
|
+
(err) => {
|
|
900
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
901
|
+
assert.match(err.message, /"apply" or "reject"/);
|
|
902
|
+
return true;
|
|
903
|
+
}
|
|
904
|
+
);
|
|
905
|
+
} finally {
|
|
906
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
});
|