@bongos/core 1.19.705 → 1.19.707
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/.bongos-core.json +33 -13
- package/docs/module-api-changelog.md +4 -0
- package/modules/agents/spawn.js +24 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/agent-invoke.js +711 -0
- package/scripts/gds/smoke-write.sh +88 -0
- package/src/module-api.js +1 -1
- package/tests/agent_invoke.mjs +566 -0
- package/tests/agent_invoke_live.mjs +111 -0
- package/tests/smoke_write_contract.mjs +110 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
// tests/agent_invoke.mjs — the on-demand invocation chain (task 1002491, goal
|
|
2
|
+
// 1000038 Phase 1, criterion `agents-historian-exemplar`).
|
|
3
|
+
//
|
|
4
|
+
// Gates the claim the criterion actually makes: that registry -> validate ->
|
|
5
|
+
// spawn -> ledger runs end to end and yields a CITED answer. Everything is
|
|
6
|
+
// dependency-injected — a fake pool, a fake runner, a fake filesystem — so the
|
|
7
|
+
// whole chain runs with no database, no subprocess and no model call. What is
|
|
8
|
+
// proven here is the DECISIONS: the gate, the citation contract, and what the
|
|
9
|
+
// ledger ends up saying about each outcome.
|
|
10
|
+
//
|
|
11
|
+
// The one thing a fake cannot prove is that a real model returns a citable
|
|
12
|
+
// answer. That was verified separately against the live `claude` CLI and the
|
|
13
|
+
// real historian definition; see the task's ship notes.
|
|
14
|
+
//
|
|
15
|
+
// Run: node tests/agent_invoke.mjs
|
|
16
|
+
|
|
17
|
+
import { strict as assert } from 'node:assert';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const invoke = require('../scripts/gds/agent-invoke.js');
|
|
22
|
+
const { spawnAgent, composePrompt } = require('../modules/agents/spawn.js');
|
|
23
|
+
|
|
24
|
+
let passed = 0;
|
|
25
|
+
let failed = 0;
|
|
26
|
+
async function test(name, fn) {
|
|
27
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
28
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MODELS = { main: 'opus', subagentDefault: 'opus', subagentRoutine: 'haiku' };
|
|
32
|
+
|
|
33
|
+
const HISTORIAN = Object.freeze({
|
|
34
|
+
id: 7,
|
|
35
|
+
name: 'historian',
|
|
36
|
+
title: 'Historian',
|
|
37
|
+
persona: 'You are the historian of this repository.',
|
|
38
|
+
trigger_type: 'on-demand',
|
|
39
|
+
trigger_spec: {},
|
|
40
|
+
model_tier: 'default',
|
|
41
|
+
scope_modules: [],
|
|
42
|
+
scope_paths: [],
|
|
43
|
+
scope_violation: null,
|
|
44
|
+
source: 'file',
|
|
45
|
+
provenance: 'built-in',
|
|
46
|
+
author_rank: 'archon',
|
|
47
|
+
source_path: '.claude/agents/historian.md',
|
|
48
|
+
enabled: true,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// A tree where exactly these paths exist.
|
|
52
|
+
const TREE = new Set(['docs/adr/README.md', 'docs/adr/0016-trust-boundary.md', 'CLAUDE.md']);
|
|
53
|
+
const EXISTS = (p) => TREE.has(p);
|
|
54
|
+
|
|
55
|
+
// A pool that answers the three reads this chain makes and records every write,
|
|
56
|
+
// so the LEDGER can be asserted on directly (same idiom as tests/agents_spawn.mjs).
|
|
57
|
+
function fakePool({ definition = HISTORIAN, learnings = [] } = {}) {
|
|
58
|
+
const calls = [];
|
|
59
|
+
return {
|
|
60
|
+
calls,
|
|
61
|
+
async query(sql, params) {
|
|
62
|
+
calls.push({ sql, params });
|
|
63
|
+
if (/FROM agents_definitions/.test(sql)) {
|
|
64
|
+
return { rows: definition && definition.name === params[0] ? [definition] : [] };
|
|
65
|
+
}
|
|
66
|
+
if (/FROM learnings/.test(sql)) return { rows: learnings };
|
|
67
|
+
if (/^INSERT INTO agents_runs/.test(sql)) return { rows: [{ id: 99 }] };
|
|
68
|
+
return { rows: [] };
|
|
69
|
+
},
|
|
70
|
+
inserted() {
|
|
71
|
+
const c = calls.find((x) => /^INSERT INTO agents_runs/.test(x.sql));
|
|
72
|
+
if (!c) return null;
|
|
73
|
+
const cols = c.sql.match(/\(([^)]+)\) VALUES/)[1].split(',').map((s) => s.trim());
|
|
74
|
+
return Object.fromEntries(cols.map((k, i) => [k, c.params[i]]));
|
|
75
|
+
},
|
|
76
|
+
updated() {
|
|
77
|
+
const c = calls.find((x) => /^UPDATE agents_runs/.test(x.sql));
|
|
78
|
+
if (!c) return null;
|
|
79
|
+
const cols = [...c.sql.matchAll(/(\w+) = \$\d+/g)].map((m) => m[1]).filter((k) => k !== 'updated_at');
|
|
80
|
+
return Object.fromEntries(cols.map((k, i) => [k, c.params[i + 1]]));
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A runner that replies with whatever the test wants, in the envelope shape
|
|
86
|
+
// runSubagent returns.
|
|
87
|
+
function replyWith(payload, { cost = 0.0123 } = {}) {
|
|
88
|
+
const seen = {};
|
|
89
|
+
const run = async ({ prompt, opts }) => {
|
|
90
|
+
seen.prompt = prompt;
|
|
91
|
+
seen.opts = opts;
|
|
92
|
+
return { stdout: typeof payload === 'string' ? payload : JSON.stringify(payload), cost_usd: cost, exit_code: 0 };
|
|
93
|
+
};
|
|
94
|
+
run.seen = seen;
|
|
95
|
+
return run;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const GOOD_ANSWER = {
|
|
99
|
+
answer: 'The trust boundary is server-enforced.',
|
|
100
|
+
citations: [{ ref: 'docs/adr/0016-trust-boundary.md', supports: 'the decision of record' }],
|
|
101
|
+
confidence: 'high',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
async function run(overrides = {}, deps = {}) {
|
|
105
|
+
const pool = deps.pool || fakePool();
|
|
106
|
+
const runner = deps.runSubagentCached || replyWith(GOOD_ANSWER);
|
|
107
|
+
const result = await invoke.invokeAgent({
|
|
108
|
+
name: 'historian', question: 'why is the trust boundary server-side?',
|
|
109
|
+
requestedByBuilderId: 1000025, requestedByLogin: 'a-builder',
|
|
110
|
+
...overrides,
|
|
111
|
+
}, {
|
|
112
|
+
pool,
|
|
113
|
+
exists: EXISTS,
|
|
114
|
+
corpus: { manifests: [{ dir: 'docs/adr', why: 'decisions of record', files: ['0016-trust-boundary.md'], total: 1, truncated: false }], indexes: [['CLAUDE.md', 'the kernel']] },
|
|
115
|
+
runSubagentCached: runner,
|
|
116
|
+
spawnDeps: { brandingModels: MODELS, now: () => 1000 },
|
|
117
|
+
...deps,
|
|
118
|
+
});
|
|
119
|
+
return { result, pool, runner };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- the citation contract (rule 3) -----------------------------------------
|
|
123
|
+
|
|
124
|
+
await test('a citation resolves only against a file that is really there', () => {
|
|
125
|
+
assert.equal(invoke.resolveCitation('docs/adr/README.md', { exists: EXISTS }).ok, true);
|
|
126
|
+
const missing = invoke.resolveCitation('docs/adr/0999-invented.md', { exists: EXISTS });
|
|
127
|
+
assert.equal(missing.ok, false);
|
|
128
|
+
assert.match(missing.reason, /no such file/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await test('an anchor or a line hint is a more precise citation, not an invalid one', () => {
|
|
132
|
+
assert.equal(invoke.resolveCitation('docs/adr/0016-trust-boundary.md#4', { exists: EXISTS }).ok, true);
|
|
133
|
+
assert.equal(invoke.resolveCitation('CLAUDE.md:12', { exists: EXISTS }).ok, true);
|
|
134
|
+
assert.equal(invoke.resolveCitation('CLAUDE.md:12-40', { exists: EXISTS }).ok, true);
|
|
135
|
+
assert.equal(invoke.citationPath('docs/adr/0016-trust-boundary.md#4'), 'docs/adr/0016-trust-boundary.md');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await test('a citation cannot escape the tree, and is refused before anything is stat-ed', () => {
|
|
139
|
+
const probes = ['../../../etc/passwd', '/etc/passwd', 'C:\\Windows\\win.ini', 'docs/../../secrets.md'];
|
|
140
|
+
for (const p of probes) {
|
|
141
|
+
const r = invoke.resolveCitation(p, { exists: () => true }); // exists says yes to everything
|
|
142
|
+
assert.equal(r.ok, false, `${p} must be refused even when the matcher would admit it`);
|
|
143
|
+
assert.match(r.reason, /inside the tree/);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await test('a learning citation counts only when that learning was actually supplied', () => {
|
|
148
|
+
const ids = new Set(['41']);
|
|
149
|
+
assert.equal(invoke.resolveCitation('learning:41', { learningIds: ids }).ok, true);
|
|
150
|
+
const guessed = invoke.resolveCitation('learning:42', { learningIds: ids });
|
|
151
|
+
assert.equal(guessed.ok, false);
|
|
152
|
+
assert.match(guessed.reason, /was supplied/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await test('no citations at all is uncited, not merely thin', () => {
|
|
156
|
+
const v = invoke.checkCitations([], { exists: EXISTS });
|
|
157
|
+
assert.equal(v.ok, false);
|
|
158
|
+
assert.equal(v.code, 'agent_answer_uncited');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await test('ONE unresolvable citation fails the whole answer — a half-real bibliography is worse than none', () => {
|
|
162
|
+
const v = invoke.checkCitations([
|
|
163
|
+
{ ref: 'docs/adr/README.md' },
|
|
164
|
+
{ ref: 'docs/adr/0999-invented.md' },
|
|
165
|
+
], { exists: EXISTS });
|
|
166
|
+
assert.equal(v.ok, false);
|
|
167
|
+
assert.equal(v.code, 'agent_citation_unresolvable');
|
|
168
|
+
assert.equal(v.invalid.length, 1);
|
|
169
|
+
assert.match(v.reason, /0999-invented/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// --- parsing the reply ------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
await test('the answer is read out of a fenced block, a bare object, or prose around one', () => {
|
|
175
|
+
const body = JSON.stringify(GOOD_ANSWER);
|
|
176
|
+
for (const raw of [body, `\`\`\`json\n${body}\n\`\`\``, `Here you go:\n\n${body}\n\nHope that helps.`]) {
|
|
177
|
+
const a = invoke.parseAnswer(raw);
|
|
178
|
+
assert.ok(a, 'parsed');
|
|
179
|
+
assert.equal(a.citations[0].ref, 'docs/adr/0016-trust-boundary.md');
|
|
180
|
+
assert.equal(a.confidence, 'high');
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await test('the reply is read out of the `claude -p` ENVELOPE, not mistaken for it', () => {
|
|
185
|
+
// The runner returns { type, result: "<the reply>", total_cost_usd, ... }. That
|
|
186
|
+
// envelope is itself a valid JSON object with no `answer` in it, so parsing it
|
|
187
|
+
// directly turns a good run into an unparseable one — which is precisely how
|
|
188
|
+
// this failed against the first real model call.
|
|
189
|
+
const envelope = JSON.stringify({
|
|
190
|
+
type: 'result', total_cost_usd: 1.08, result: `\`\`\`json\n${JSON.stringify(GOOD_ANSWER)}\n\`\`\``,
|
|
191
|
+
});
|
|
192
|
+
const a = invoke.parseAnswer(envelope);
|
|
193
|
+
assert.ok(a, 'the answer is inside env.result');
|
|
194
|
+
assert.equal(a.citations[0].ref, 'docs/adr/0016-trust-boundary.md');
|
|
195
|
+
// ...and a reply that IS the answer is never mistaken for an envelope, even if
|
|
196
|
+
// it happens to carry a `result` key of its own.
|
|
197
|
+
const direct = invoke.parseAnswer(JSON.stringify({ ...GOOD_ANSWER, result: 'a field of my own' }));
|
|
198
|
+
assert.equal(direct.answer, GOOD_ANSWER.answer);
|
|
199
|
+
assert.equal(invoke.unwrapEnvelope('not json at all'), 'not json at all');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await test('a quoted JSON snippet in the answer does not get mistaken for the answer', () => {
|
|
203
|
+
// An agent whose corpus includes manifests and config quotes JSON all the time.
|
|
204
|
+
const reply = [
|
|
205
|
+
'I found this in the manifest:',
|
|
206
|
+
'```json',
|
|
207
|
+
'{ "key": "agents", "default": false }',
|
|
208
|
+
'```',
|
|
209
|
+
'and here is my answer:',
|
|
210
|
+
'```json',
|
|
211
|
+
JSON.stringify(GOOD_ANSWER),
|
|
212
|
+
'```',
|
|
213
|
+
].join('\n');
|
|
214
|
+
const a = invoke.parseAnswer(reply);
|
|
215
|
+
assert.ok(a, 'the answer-shaped object wins');
|
|
216
|
+
assert.equal(a.answer, GOOD_ANSWER.answer);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await test('a bare-string citation list is accepted — the shape is a convenience, the ref is the contract', () => {
|
|
220
|
+
const a = invoke.parseAnswer(JSON.stringify({ answer: 'x', citations: ['CLAUDE.md'] }));
|
|
221
|
+
assert.deepEqual(a.citations, [{ ref: 'CLAUDE.md', supports: null }]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await test('a reply with no answer, or no JSON at all, parses to null rather than throwing', () => {
|
|
225
|
+
assert.equal(invoke.parseAnswer('I had a think about it and the answer is probably yes.'), null);
|
|
226
|
+
assert.equal(invoke.parseAnswer(JSON.stringify({ citations: ['CLAUDE.md'] })), null);
|
|
227
|
+
assert.equal(invoke.parseAnswer(JSON.stringify({ answer: ' ' })), null);
|
|
228
|
+
assert.equal(invoke.parseAnswer(null), null);
|
|
229
|
+
assert.equal(invoke.parseAnswer('{ not json'), null);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await test('an unrecognised confidence is dropped, never echoed back as though it were a grade', () => {
|
|
233
|
+
const a = invoke.parseAnswer(JSON.stringify({ answer: 'x', citations: ['CLAUDE.md'], confidence: 'certain' }));
|
|
234
|
+
assert.equal(a.confidence, null);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// --- the command line -------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
await test('an unquoted question is rejoined, not truncated at the first space', () => {
|
|
240
|
+
const a = invoke.parseArgv(['historian', 'why', 'is', 'the', 'boundary', 'server-side?']);
|
|
241
|
+
assert.equal(a.error, null);
|
|
242
|
+
assert.equal(a.name, 'historian');
|
|
243
|
+
assert.equal(a.question, 'why is the boundary server-side?');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await test('--out takes the next argument out of the question, and --json is not a word of it', () => {
|
|
247
|
+
const a = invoke.parseArgv(['--json', 'historian', 'why', '--out', 'answer.md', 'not', 'lost']);
|
|
248
|
+
assert.equal(a.asJson, true);
|
|
249
|
+
assert.equal(a.outArg, 'answer.md');
|
|
250
|
+
assert.equal(a.question, 'why not lost');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await test('a bare --out is refused rather than eating the next flag as a filename', () => {
|
|
254
|
+
assert.equal(invoke.parseArgv(['historian', 'q', '--out']).error, '--out needs a path.');
|
|
255
|
+
assert.equal(invoke.parseArgv(['historian', 'q', '--out', '--json']).error, '--out needs a path.');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await test('a missing name or a missing question prints usage', () => {
|
|
259
|
+
assert.equal(invoke.parseArgv([]).error, 'usage');
|
|
260
|
+
assert.equal(invoke.parseArgv(['historian']).error, 'usage');
|
|
261
|
+
assert.equal(invoke.parseArgv(['historian', ' ']).error, 'usage');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// --- the corpus constants are a contract, not incidental --------------------
|
|
265
|
+
|
|
266
|
+
await test('the corpus roots and the learnings bound are pinned', () => {
|
|
267
|
+
assert.deepEqual(invoke.CORPUS_DIRS.map(([d]) => d), ['docs/adr', 'docs/session-logs']);
|
|
268
|
+
// docs/adr/README.md must NOT be listed: at ~500KB the Read tool refuses it,
|
|
269
|
+
// and the ADR manifest is what replaced it.
|
|
270
|
+
assert.ok(!invoke.INDEX_CANDIDATES.some(([p]) => p === 'docs/adr/README.md'));
|
|
271
|
+
assert.ok(invoke.INDEX_CANDIDATES.some(([p]) => p === 'CLAUDE.md'));
|
|
272
|
+
assert.equal(typeof invoke.LEARNINGS_LIMIT, 'number');
|
|
273
|
+
assert.ok(invoke.LEARNINGS_LIMIT > 0 && invoke.LEARNINGS_LIMIT <= 500);
|
|
274
|
+
assert.ok(invoke.INDEX_MAX_BYTES > 0);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await test('a padded learning id names the same row as an unpadded one', () => {
|
|
278
|
+
const { ids } = invoke.renderLearnings([{ id: '0041', title: 't', body_md: '' }]);
|
|
279
|
+
assert.deepEqual([...ids], ['41']);
|
|
280
|
+
assert.equal(invoke.resolveCitation('learning:41', { learningIds: ids }).ok, true);
|
|
281
|
+
assert.equal(invoke.resolveCitation('learning:0041', { learningIds: ids }).ok, true);
|
|
282
|
+
assert.equal(invoke.resolveCitation('learning:410', { learningIds: ids }).ok, false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// --- the gate (rule 2) ------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
await test('the gate refuses a definition that is present but not enabled', () => {
|
|
288
|
+
const g = invoke.gateFor({ ...HISTORIAN, enabled: false });
|
|
289
|
+
assert.equal(g.decision, 'no-go');
|
|
290
|
+
assert.match(g.reason, /not enabled/);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await test('a scope-wall flag is reported as the scope wall, not as "disabled"', () => {
|
|
294
|
+
// The schema forces a flagged row to enabled=false, so both are true at once —
|
|
295
|
+
// and the two have completely different fixes, so the reason must distinguish.
|
|
296
|
+
const g = invoke.gateFor({ ...HISTORIAN, enabled: false, scope_violation: 'scope_paths: reaches a protected surface' });
|
|
297
|
+
assert.equal(g.decision, 'no-go');
|
|
298
|
+
assert.match(g.reason, /scope wall/);
|
|
299
|
+
assert.match(g.reason, /protected surface/);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
await test('an event agent cannot be asked a question', () => {
|
|
303
|
+
const g = invoke.gateFor({ ...HISTORIAN, trigger_type: 'event' });
|
|
304
|
+
assert.equal(g.decision, 'no-go');
|
|
305
|
+
assert.match(g.reason, /not 'on-demand'/);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await test('an armed on-demand definition passes', () => {
|
|
309
|
+
assert.deepEqual(invoke.gateFor(HISTORIAN), { decision: 'go', reason: null });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// --- the demand half of the prompt ------------------------------------------
|
|
313
|
+
|
|
314
|
+
await test('the demand carries the question, the doors and the citation rule', () => {
|
|
315
|
+
const d = invoke.buildDemand({
|
|
316
|
+
question: 'why is the trust boundary server-side?',
|
|
317
|
+
indexes: [['CLAUDE.md', 'the kernel']],
|
|
318
|
+
manifests: [{ dir: 'docs/adr', why: 'decisions of record', files: ['0016-trust-boundary.md'], total: 1, truncated: false }],
|
|
319
|
+
learnings: { text: '', ids: new Set() },
|
|
320
|
+
});
|
|
321
|
+
assert.match(d, /why is the trust boundary server-side\?/);
|
|
322
|
+
assert.match(d, /docs\/adr\/0016-trust-boundary\.md/);
|
|
323
|
+
assert.match(d, /CLAUDE\.md/);
|
|
324
|
+
assert.match(d, /checked against/);
|
|
325
|
+
assert.match(d, /"citations"/);
|
|
326
|
+
// No learnings supplied ⇒ no learnings block, rather than an empty heading the
|
|
327
|
+
// agent could read as "there are none on record".
|
|
328
|
+
assert.doesNotMatch(d, /### Learnings/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// --- the doors (buildCorpus) ------------------------------------------------
|
|
332
|
+
|
|
333
|
+
await test('an index that is absent, and one too big for the Read tool, are both dropped', () => {
|
|
334
|
+
// Both cases are real and both were found by running this against the actual
|
|
335
|
+
// repo: the generated indexes are gitignored, and docs/adr/README.md is a
|
|
336
|
+
// tracked 500KB file the Read tool refuses.
|
|
337
|
+
const sizes = new Map([
|
|
338
|
+
['docs/file-map.md', 116037],
|
|
339
|
+
['CLAUDE.md', 22565],
|
|
340
|
+
['docs/adr/README.md', 512751],
|
|
341
|
+
// docs/repo-map.md and docs/session-log-index.md: absent
|
|
342
|
+
]);
|
|
343
|
+
const { indexes } = invoke.buildCorpus({
|
|
344
|
+
listDir: () => [],
|
|
345
|
+
fileSize: (p) => (sizes.has(p) ? sizes.get(p) : -1),
|
|
346
|
+
});
|
|
347
|
+
const listed = indexes.map(([p]) => p);
|
|
348
|
+
assert.deepEqual(listed, ['docs/file-map.md', 'CLAUDE.md']);
|
|
349
|
+
assert.ok(!listed.includes('docs/adr/README.md'), 'a door that will not open is worse than no door');
|
|
350
|
+
assert.ok(!listed.includes('docs/repo-map.md'));
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await test('the corpus manifest lists the real filenames, and says so when it had to stop', () => {
|
|
354
|
+
const many = Array.from({ length: 900 }, (_, i) => `${String(i).padStart(4, '0')}-a-decision.md`);
|
|
355
|
+
const { manifests } = invoke.buildCorpus({
|
|
356
|
+
listDir: (d) => (d === 'docs/adr' ? many : []),
|
|
357
|
+
fileSize: () => -1,
|
|
358
|
+
});
|
|
359
|
+
assert.equal(manifests.length, 1, 'an empty corpus dir contributes no manifest at all');
|
|
360
|
+
const [adr] = manifests;
|
|
361
|
+
assert.equal(adr.total, 900);
|
|
362
|
+
assert.equal(adr.files.length, invoke.MANIFEST_MAX_FILES);
|
|
363
|
+
assert.equal(adr.truncated, true);
|
|
364
|
+
// A truncated listing must say it is truncated: an agent that reads a partial
|
|
365
|
+
// list as complete concludes a record is absent when it is merely unlisted.
|
|
366
|
+
const d = invoke.buildDemand({ question: 'q', manifests, indexes: [] });
|
|
367
|
+
assert.match(d, /900 file\(s\), of which the first 600 are listed/);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await test('the learnings block and the accepted id set are built together', () => {
|
|
371
|
+
const { text, ids } = invoke.renderLearnings([
|
|
372
|
+
{ id: 41, title: 'ship-check is not CI', body_md: 'It misses three steps.' },
|
|
373
|
+
{ id: '42', title: 'stash is repo-global', body_md: '' },
|
|
374
|
+
{ id: 'not-an-id', title: 'ignored', body_md: '' },
|
|
375
|
+
]);
|
|
376
|
+
assert.match(text, /learning:41 — ship-check is not CI: It misses three steps\./);
|
|
377
|
+
assert.match(text, /learning:42 — stash is repo-global/);
|
|
378
|
+
assert.deepEqual([...ids].sort(), ['41', '42']);
|
|
379
|
+
// The id that is not an id never reaches the prompt, so it can never be cited.
|
|
380
|
+
assert.doesNotMatch(text, /not-an-id/);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
await test('the learnings block is bounded — asking a question cannot cost more the longer the instance has run', () => {
|
|
384
|
+
const many = Array.from({ length: 5000 }, (_, i) => ({ id: i + 1, title: 'x'.repeat(80), body_md: 'y'.repeat(600) }));
|
|
385
|
+
const { text, ids } = invoke.renderLearnings(many);
|
|
386
|
+
assert.ok(text.length <= 24000, `block is ${text.length} chars`);
|
|
387
|
+
// And the id set stops where the text does: a learning that was truncated away
|
|
388
|
+
// must not be quietly citable.
|
|
389
|
+
assert.ok(ids.size < many.length);
|
|
390
|
+
assert.ok([...ids].every((id) => text.includes(`learning:${id} `)));
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// --- the chain, end to end --------------------------------------------------
|
|
394
|
+
|
|
395
|
+
await test('a clean invocation returns the answer and its ledger row says ok', async () => {
|
|
396
|
+
const { result, pool } = await run();
|
|
397
|
+
assert.equal(result.ok, true);
|
|
398
|
+
assert.equal(result.answer.answer, 'The trust boundary is server-enforced.');
|
|
399
|
+
assert.equal(result.runId, 99);
|
|
400
|
+
assert.equal(result.model, 'opus');
|
|
401
|
+
|
|
402
|
+
const ins = pool.inserted();
|
|
403
|
+
assert.equal(ins.agent_name, 'historian');
|
|
404
|
+
assert.equal(ins.trigger_type, 'on-demand');
|
|
405
|
+
assert.equal(ins.gate_decision, 'go');
|
|
406
|
+
assert.equal(ins.status, 'pending');
|
|
407
|
+
assert.equal(ins.trigger_ref, 'on-demand:a-builder');
|
|
408
|
+
assert.equal(ins.requested_by_builder_id, 1000025);
|
|
409
|
+
|
|
410
|
+
const upd = pool.updated();
|
|
411
|
+
assert.equal(upd.status, 'ok');
|
|
412
|
+
assert.equal(upd.error_code, null);
|
|
413
|
+
assert.equal(upd.cost_usd, 0.0123);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
await test('the persona and the question BOTH reach the model', async () => {
|
|
417
|
+
const runner = replyWith(GOOD_ANSWER);
|
|
418
|
+
await run({}, { runSubagentCached: runner });
|
|
419
|
+
assert.match(runner.seen.prompt, /You are the historian of this repository\./);
|
|
420
|
+
assert.match(runner.seen.prompt, /why is the trust boundary server-side\?/);
|
|
421
|
+
// The persona comes first: the agent is told who it is before what it is asked.
|
|
422
|
+
assert.ok(runner.seen.prompt.indexOf('historian of this repository')
|
|
423
|
+
< runner.seen.prompt.indexOf('why is the trust boundary'));
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
await test('an UNCITED answer is a failed run in the ledger, with its cost kept', async () => {
|
|
427
|
+
const runner = replyWith({ answer: 'Because it just is.', citations: [] });
|
|
428
|
+
const { result, pool } = await run({}, { runSubagentCached: runner });
|
|
429
|
+
assert.equal(result.ok, false);
|
|
430
|
+
assert.equal(result.code, 'agent_answer_uncited');
|
|
431
|
+
const upd = pool.updated();
|
|
432
|
+
assert.equal(upd.status, 'error');
|
|
433
|
+
assert.equal(upd.error_code, 'agent_answer_uncited');
|
|
434
|
+
// The money was spent whether or not the answer was usable, and a ledger that
|
|
435
|
+
// hid that would under-report what agents cost.
|
|
436
|
+
assert.equal(upd.cost_usd, 0.0123);
|
|
437
|
+
// A failed run's partial output is not a result (task 1002489).
|
|
438
|
+
assert.equal(upd.output_ref, null);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await test('a FABRICATED citation fails the run — this is the check that makes "cited" mean something', async () => {
|
|
442
|
+
const runner = replyWith({
|
|
443
|
+
answer: 'ADR 0999 settled it.',
|
|
444
|
+
citations: [{ ref: 'docs/adr/0999-invented.md' }],
|
|
445
|
+
});
|
|
446
|
+
const { result, pool } = await run({}, { runSubagentCached: runner });
|
|
447
|
+
assert.equal(result.ok, false);
|
|
448
|
+
assert.equal(result.code, 'agent_citation_unresolvable');
|
|
449
|
+
assert.match(result.reason, /0999-invented/);
|
|
450
|
+
assert.equal(pool.updated().error_code, 'agent_citation_unresolvable');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
await test('a reply that is not an answer at all is recorded as unparsed, not as silence', async () => {
|
|
454
|
+
const { result, pool } = await run({}, { runSubagentCached: replyWith('I could not find anything.') });
|
|
455
|
+
assert.equal(result.ok, false);
|
|
456
|
+
assert.equal(result.code, 'agent_answer_unparsed');
|
|
457
|
+
assert.equal(pool.updated().error_code, 'agent_answer_unparsed');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
await test('a DISABLED agent is refused, and the refusal is a row (rule 2)', async () => {
|
|
461
|
+
const pool = fakePool({ definition: { ...HISTORIAN, enabled: false } });
|
|
462
|
+
const runner = replyWith(GOOD_ANSWER);
|
|
463
|
+
const { result } = await run({}, { pool, runSubagentCached: runner });
|
|
464
|
+
assert.equal(result.ok, false);
|
|
465
|
+
assert.equal(result.code, 'agent_refused');
|
|
466
|
+
assert.match(result.reason, /not enabled/);
|
|
467
|
+
|
|
468
|
+
const ins = pool.inserted();
|
|
469
|
+
assert.equal(ins.gate_decision, 'no-go');
|
|
470
|
+
assert.equal(ins.status, 'skipped');
|
|
471
|
+
assert.match(ins.gate_reason, /not enabled/);
|
|
472
|
+
// A no-go spawned nothing, so it has no model and no cost (schema invariant).
|
|
473
|
+
assert.equal(ins.model, null);
|
|
474
|
+
assert.equal(pool.updated(), null, 'a skipped run is terminal — nothing finishes it');
|
|
475
|
+
assert.equal(runner.seen.prompt, undefined, 'the model was never called');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
await test('a flagged agent is refused with the scope wall\'s own words', async () => {
|
|
479
|
+
const pool = fakePool({
|
|
480
|
+
definition: { ...HISTORIAN, enabled: false, scope_violation: 'scope_paths: "src/bongos/auth.js" reaches a protected surface' },
|
|
481
|
+
});
|
|
482
|
+
const { result } = await run({}, { pool });
|
|
483
|
+
assert.equal(result.code, 'agent_refused');
|
|
484
|
+
assert.match(pool.inserted().gate_reason, /reaches a protected surface/);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
await test('an UNKNOWN agent writes no ledger row — there is nothing that was refused', async () => {
|
|
488
|
+
const pool = fakePool({ definition: null });
|
|
489
|
+
const { result } = await run({ name: 'nobody' }, { pool });
|
|
490
|
+
assert.equal(result.ok, false);
|
|
491
|
+
assert.equal(result.code, 'agent_not_found');
|
|
492
|
+
assert.equal(result.runId, null);
|
|
493
|
+
assert.equal(pool.inserted(), null);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
await test('the registry is the authority: a file on disk is never consulted', async () => {
|
|
497
|
+
// The definition the pool hands back is the one that runs, persona included —
|
|
498
|
+
// there is no read of .claude/agents/ anywhere in the chain (rule 1).
|
|
499
|
+
const pool = fakePool({ definition: { ...HISTORIAN, persona: 'REGISTRY PERSONA' } });
|
|
500
|
+
const runner = replyWith(GOOD_ANSWER);
|
|
501
|
+
await run({}, { pool, runSubagentCached: runner });
|
|
502
|
+
assert.match(runner.seen.prompt, /REGISTRY PERSONA/);
|
|
503
|
+
assert.equal(pool.calls.filter((c) => /FROM agents_definitions/.test(c.sql)).length, 1);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
await test('learnings the agent was shown are citable; the run degrades rather than fails when the table is unreadable', async () => {
|
|
507
|
+
const pool = fakePool({ learnings: [{ id: 41, title: 'ship-check is not CI', body_md: 'misses three steps' }] });
|
|
508
|
+
const runner = replyWith({ answer: 'See the learning.', citations: [{ ref: 'learning:41' }] });
|
|
509
|
+
const { result } = await run({}, { pool, runSubagentCached: runner });
|
|
510
|
+
assert.equal(result.ok, true, 'a learning the prompt carried is a valid source');
|
|
511
|
+
assert.match(runner.seen.prompt, /learning:41 — ship-check is not CI/);
|
|
512
|
+
|
|
513
|
+
// And when the read throws, the answer still happens over the file corpus.
|
|
514
|
+
const broken = fakePool();
|
|
515
|
+
const original = broken.query.bind(broken);
|
|
516
|
+
broken.query = async (sql, params) => {
|
|
517
|
+
if (/FROM learnings/.test(sql)) throw new Error('relation "learnings" does not exist');
|
|
518
|
+
return original(sql, params);
|
|
519
|
+
};
|
|
520
|
+
const warnings = [];
|
|
521
|
+
const { result: degraded } = await run({}, { pool: broken, warn: (m) => warnings.push(m) });
|
|
522
|
+
assert.equal(degraded.ok, true);
|
|
523
|
+
assert.equal(warnings.length, 1);
|
|
524
|
+
assert.match(warnings[0], /learnings unavailable/);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
await test('the run is costed and attributed even when the model is slow or free', async () => {
|
|
528
|
+
const runner = replyWith(GOOD_ANSWER, { cost: 0 });
|
|
529
|
+
const { result, pool } = await run({}, { runSubagentCached: runner });
|
|
530
|
+
assert.equal(result.ok, true);
|
|
531
|
+
assert.equal(pool.updated().cost_usd, 0, 'a $0 cached replay is still a recorded cost, not an absent one');
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// --- the spawn seam's half of the join --------------------------------------
|
|
535
|
+
|
|
536
|
+
await test('composePrompt: an event fire with no input sends exactly the persona it always did', () => {
|
|
537
|
+
assert.equal(composePrompt({ persona: 'observe and report' }, null), 'observe and report');
|
|
538
|
+
assert.equal(composePrompt({ persona: 'observe and report' }, ' '), 'observe and report');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
await test('composePrompt: on-demand input follows the persona, separated', () => {
|
|
542
|
+
assert.equal(composePrompt({ persona: 'who you are' }, 'what you are asked'), 'who you are\n\nwhat you are asked');
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
await test('composePrompt: a definition with no persona still carries the demand', () => {
|
|
546
|
+
assert.equal(composePrompt({}, 'the question'), 'the question');
|
|
547
|
+
assert.equal(composePrompt(null, null), '');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
await test('spawnAgent carries `input` through to the runner, unchanged', async () => {
|
|
551
|
+
const calls = [];
|
|
552
|
+
const pool = {
|
|
553
|
+
async query(sql) {
|
|
554
|
+
if (/^INSERT INTO agents_runs/.test(sql)) return { rows: [{ id: 5 }] };
|
|
555
|
+
return { rows: [] };
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
await spawnAgent(
|
|
559
|
+
{ definition: { id: 1, name: 'historian', persona: 'P', model_tier: 'default' }, input: 'Q' },
|
|
560
|
+
{ pool, brandingModels: MODELS, runSubagentCached: async (a) => { calls.push(a.prompt); return { stdout: '', cost_usd: 0 }; } },
|
|
561
|
+
);
|
|
562
|
+
assert.deepEqual(calls, ['P\n\nQ']);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
566
|
+
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// tests/agent_invoke_live.mjs — the end-to-end proof, reproducible (task 1002491).
|
|
2
|
+
//
|
|
3
|
+
// tests/agent_invoke.mjs proves the DECISIONS with a fake pool, a fake runner and
|
|
4
|
+
// a fake tree. It cannot prove the one thing criterion `agents-historian-exemplar`
|
|
5
|
+
// actually claims: that the real historian, given the real corpus, comes back with
|
|
6
|
+
// an answer whose citations are real. Only a real model call can say that, and a
|
|
7
|
+
// real model call costs real money (~$0.90 a question at the time of writing) and
|
|
8
|
+
// needs an authenticated `claude` CLI — so it must never run in CI or in the unit
|
|
9
|
+
// gate, and it is OPT-IN.
|
|
10
|
+
//
|
|
11
|
+
// AGENT_INVOKE_LIVE=1 node tests/agent_invoke_live.mjs ["<question>"]
|
|
12
|
+
//
|
|
13
|
+
// Skipped and exit 0 otherwise, so `run-unit-tests.js` sweeps it up harmlessly.
|
|
14
|
+
//
|
|
15
|
+
// The POOL is still faked — a builder box has no database, which is also why the
|
|
16
|
+
// script's every edge is injectable. What is real here is the definition (parsed
|
|
17
|
+
// from the committed .claude/agents/historian.md exactly as agents-sync would land
|
|
18
|
+
// it), the corpus (this checkout), the model, and the citation check.
|
|
19
|
+
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
|
|
29
|
+
if (process.env.AGENT_INVOKE_LIVE !== '1') {
|
|
30
|
+
console.log(' skip agent_invoke_live — set AGENT_INVOKE_LIVE=1 to spend ~$1 proving the chain for real');
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const invoke = require('../scripts/gds/agent-invoke.js');
|
|
35
|
+
const agentsSync = require('../scripts/gds/agents-sync.js');
|
|
36
|
+
const grader = require('../modules/grading/grader.js');
|
|
37
|
+
|
|
38
|
+
// The definition exactly as the deploy-time reconcile would land it, so what runs
|
|
39
|
+
// is the committed file and not a fixture that drifted from it.
|
|
40
|
+
const candidate = agentsSync.parseAgentFile(
|
|
41
|
+
fs.readFileSync(path.join(ROOT, '.claude/agents/historian.md'), 'utf8'),
|
|
42
|
+
{ name: 'historian' },
|
|
43
|
+
);
|
|
44
|
+
const definition = {
|
|
45
|
+
id: 1,
|
|
46
|
+
...candidate,
|
|
47
|
+
trigger_spec: {},
|
|
48
|
+
scope_modules: [],
|
|
49
|
+
scope_paths: [],
|
|
50
|
+
scope_violation: null,
|
|
51
|
+
source: 'file',
|
|
52
|
+
provenance: 'built-in',
|
|
53
|
+
// Armed for the duration of this check. Note that a real deploy does NOT land
|
|
54
|
+
// it armed — see the header of scripts/gds/agent-invoke.js.
|
|
55
|
+
author_rank: 'archon',
|
|
56
|
+
enabled: true,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const ledger = [];
|
|
60
|
+
const pool = {
|
|
61
|
+
async query(sql, params) {
|
|
62
|
+
if (/FROM agents_definitions/.test(sql)) return { rows: [definition] };
|
|
63
|
+
// No database on a builder box; the degrade path is exercised for free.
|
|
64
|
+
if (/FROM learnings/.test(sql)) throw new Error('no database on this box');
|
|
65
|
+
if (/^INSERT INTO agents_runs/.test(sql)) { ledger.push(['insert', params]); return { rows: [{ id: 1 }] }; }
|
|
66
|
+
ledger.push(['update', params]);
|
|
67
|
+
return { rows: [] };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const question = process.argv.slice(2).join(' ')
|
|
72
|
+
|| 'Why does this project enforce permissions server-side rather than in the client, and what is the rule about editing local files to grant yourself authority?';
|
|
73
|
+
|
|
74
|
+
console.log(` Q: ${question}`);
|
|
75
|
+
const started = Date.now();
|
|
76
|
+
const result = await invoke.invokeAgent(
|
|
77
|
+
{ name: 'historian', question, requestedByBuilderId: 1, requestedByLogin: 'live-check' },
|
|
78
|
+
{
|
|
79
|
+
pool,
|
|
80
|
+
runSubagentCached: (a) => grader.runSubagentCached({ ...a, opts: { ...a.opts, timeoutMs: 600000 } }),
|
|
81
|
+
spawnDeps: { brandingModels: { subagentDefault: 'claude-opus-5', subagentRoutine: 'claude-haiku-4-5-20251001' } },
|
|
82
|
+
warn: (m) => console.log(` warn: ${m}`),
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
const secs = Math.round((Date.now() - started) / 1000);
|
|
86
|
+
|
|
87
|
+
if (!result.ok) {
|
|
88
|
+
console.error(` FAIL ${result.code} — ${result.reason || ''} (${secs}s)`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(`\n${result.answer.answer}\n`);
|
|
93
|
+
console.log(' Citations, every one resolved against this checkout:');
|
|
94
|
+
for (const c of result.answer.citations) console.log(` - ${c.ref}`);
|
|
95
|
+
console.log(`\n ${secs}s · ${result.model} · $${result.cost_usd} · confidence ${result.answer.confidence}`);
|
|
96
|
+
|
|
97
|
+
// The claim the criterion makes, asserted rather than eyeballed.
|
|
98
|
+
assert.ok(result.answer.citations.length > 0, 'a cited result');
|
|
99
|
+
for (const c of result.answer.citations) {
|
|
100
|
+
const p = invoke.citationPath(c.ref);
|
|
101
|
+
if (/^learning:/.test(c.ref)) continue;
|
|
102
|
+
assert.ok(fs.existsSync(path.join(ROOT, p)), `${p} exists in the tree`);
|
|
103
|
+
}
|
|
104
|
+
// ...and the ledger said so: one go row, finished ok, with a real cost on it.
|
|
105
|
+
const [insert] = ledger.filter(([k]) => k === 'insert');
|
|
106
|
+
const [update] = ledger.filter(([k]) => k === 'update');
|
|
107
|
+
assert.ok(insert && insert[1].includes('go'), 'the gate said go');
|
|
108
|
+
assert.ok(update && update[1].includes('ok'), 'the run finished ok');
|
|
109
|
+
assert.ok(Number(result.cost_usd) > 0, 'a real fire costs real money and the ledger carries it');
|
|
110
|
+
|
|
111
|
+
console.log('\n ok the historian returned a cited result end-to-end, and the ledger agrees');
|