vigiles 2.4.0 → 2.6.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 +72 -327
- package/dist/agent-result.d.ts +40 -0
- package/dist/agent-result.js +97 -0
- package/dist/agent-runtime.d.ts +64 -0
- package/dist/agent-runtime.js +147 -0
- package/dist/claude-code.d.ts +10 -0
- package/dist/claude-code.js +26 -0
- package/dist/cli.js +106 -0
- package/dist/compile.d.ts +32 -3
- package/dist/compile.js +268 -0
- package/dist/egress-proxy.d.ts +2 -0
- package/dist/egress-proxy.js +60 -0
- package/dist/eval-baseline.d.ts +68 -0
- package/dist/eval-baseline.js +173 -0
- package/dist/eval-cache.d.ts +33 -0
- package/dist/eval-cache.js +94 -0
- package/dist/eval.d.ts +172 -9
- package/dist/eval.js +319 -58
- package/dist/harness-assert.d.ts +175 -13
- package/dist/harness-assert.js +358 -25
- package/dist/harness-test.d.ts +97 -11
- package/dist/harness-test.js +147 -37
- package/dist/judge.js +2 -0
- package/dist/linters.d.ts +6 -0
- package/dist/linters.js +1 -0
- package/dist/linting.d.ts +9 -0
- package/dist/linting.js +25 -0
- package/dist/mock-entry.d.ts +2 -0
- package/dist/mock-entry.js +36 -0
- package/dist/mock-model.d.ts +29 -0
- package/dist/mock-model.js +40 -0
- package/dist/plugin-loader.js +51 -17
- package/dist/run-hook.d.ts +81 -1
- package/dist/run-hook.js +189 -11
- package/dist/sandbox.d.ts +107 -0
- package/dist/sandbox.js +307 -0
- package/dist/spec.d.ts +130 -0
- package/dist/spec.js +55 -0
- package/dist/stats.d.ts +49 -0
- package/dist/stats.js +109 -0
- package/dist/testing.d.ts +12 -0
- package/dist/testing.js +28 -0
- package/package.json +10 -4
package/dist/harness-assert.js
CHANGED
|
@@ -1,20 +1,46 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.vigilesMatchers = void 0;
|
|
3
|
+
exports.vigilesMatchers = exports.diffToJUnit = exports.formatBaselineDiff = exports.writeBaseline = exports.readBaseline = exports.parseBaselineFile = exports.toBaselineFile = exports.diffReports = exports.compareArms = void 0;
|
|
4
4
|
exports.withHarness = withHarness;
|
|
5
5
|
exports.assertCreated = assertCreated;
|
|
6
6
|
exports.assertNotCreated = assertNotCreated;
|
|
7
7
|
exports.assertServedTurns = assertServedTurns;
|
|
8
8
|
exports.assertHookBlocked = assertHookBlocked;
|
|
9
9
|
exports.assertHookAllowed = assertHookAllowed;
|
|
10
|
+
exports.egressHosts = egressHosts;
|
|
11
|
+
exports.assertNoEgress = assertNoEgress;
|
|
12
|
+
exports.assertEgressOnly = assertEgressOnly;
|
|
13
|
+
exports.assertNoWrite = assertNoWrite;
|
|
14
|
+
exports.assertWroteOnly = assertWroteOnly;
|
|
15
|
+
exports.assertAgentOk = assertAgentOk;
|
|
16
|
+
exports.assertAgentErr = assertAgentErr;
|
|
17
|
+
exports.assertAgentResult = assertAgentResult;
|
|
18
|
+
exports.usedTool = usedTool;
|
|
19
|
+
exports.toolCount = toolCount;
|
|
20
|
+
exports.skillResolved = skillResolved;
|
|
21
|
+
exports.toolUsedWith = toolUsedWith;
|
|
22
|
+
exports.outputContains = outputContains;
|
|
23
|
+
exports.requestContains = requestContains;
|
|
24
|
+
exports.hookFired = hookFired;
|
|
25
|
+
exports.hookBlocked = hookBlocked;
|
|
10
26
|
exports.assertToolUsed = assertToolUsed;
|
|
11
27
|
exports.assertToolNotUsed = assertToolNotUsed;
|
|
12
28
|
exports.assertSkillResolved = assertSkillResolved;
|
|
29
|
+
exports.assertToolUsedWith = assertToolUsedWith;
|
|
30
|
+
exports.assertOutputContains = assertOutputContains;
|
|
31
|
+
exports.assertRequestContains = assertRequestContains;
|
|
32
|
+
exports.assertHookFired = assertHookFired;
|
|
13
33
|
exports.assertToolCount = assertToolCount;
|
|
14
34
|
exports.assertToolSequence = assertToolSequence;
|
|
15
35
|
exports.assertToolCalls = assertToolCalls;
|
|
36
|
+
exports.reliable = reliable;
|
|
37
|
+
exports.assertReliable = assertReliable;
|
|
16
38
|
exports.improvement = improvement;
|
|
39
|
+
exports.significantlyBeats = significantlyBeats;
|
|
40
|
+
exports.assertSignificant = assertSignificant;
|
|
17
41
|
exports.assertImproves = assertImproves;
|
|
42
|
+
exports.assertNoRegression = assertNoRegression;
|
|
43
|
+
exports.assertTriggerRate = assertTriggerRate;
|
|
18
44
|
/**
|
|
19
45
|
* vigiles — runner-agnostic helpers for harness tests / evals.
|
|
20
46
|
*
|
|
@@ -31,11 +57,27 @@ exports.assertImproves = assertImproves;
|
|
|
31
57
|
* vitest and jest, so the same object supports both.
|
|
32
58
|
*/
|
|
33
59
|
const harness_test_js_1 = require("./harness-test.js");
|
|
60
|
+
const agent_result_js_1 = require("./agent-result.js");
|
|
61
|
+
const stats_js_1 = require("./stats.js");
|
|
62
|
+
const eval_baseline_js_1 = require("./eval-baseline.js");
|
|
63
|
+
// Re-export the significance primitives so the whole eval-analysis surface lives
|
|
64
|
+
// behind `vigiles/harness-assert` (no separate entry point).
|
|
65
|
+
var stats_js_2 = require("./stats.js");
|
|
66
|
+
Object.defineProperty(exports, "compareArms", { enumerable: true, get: function () { return stats_js_2.compareArms; } });
|
|
67
|
+
var eval_baseline_js_2 = require("./eval-baseline.js");
|
|
68
|
+
Object.defineProperty(exports, "diffReports", { enumerable: true, get: function () { return eval_baseline_js_2.diffReports; } });
|
|
69
|
+
Object.defineProperty(exports, "toBaselineFile", { enumerable: true, get: function () { return eval_baseline_js_2.toBaselineFile; } });
|
|
70
|
+
Object.defineProperty(exports, "parseBaselineFile", { enumerable: true, get: function () { return eval_baseline_js_2.parseBaselineFile; } });
|
|
71
|
+
Object.defineProperty(exports, "readBaseline", { enumerable: true, get: function () { return eval_baseline_js_2.readBaseline; } });
|
|
72
|
+
Object.defineProperty(exports, "writeBaseline", { enumerable: true, get: function () { return eval_baseline_js_2.writeBaseline; } });
|
|
73
|
+
Object.defineProperty(exports, "formatBaselineDiff", { enumerable: true, get: function () { return eval_baseline_js_2.formatBaselineDiff; } });
|
|
74
|
+
Object.defineProperty(exports, "diffToJUnit", { enumerable: true, get: function () { return eval_baseline_js_2.diffToJUnit; } });
|
|
34
75
|
/**
|
|
35
76
|
* Run a harness test, hand the result to `fn`, and always clean up the sandbox.
|
|
36
77
|
* Returns whatever `fn` returns. Use this instead of calling `cleanup()` by
|
|
37
78
|
* hand — it survives assertion failures.
|
|
38
79
|
*/
|
|
80
|
+
/* v8 ignore start -- thin wrapper over runHarnessTest (spawns the real CLI) */
|
|
39
81
|
async function withHarness(spec, fn) {
|
|
40
82
|
const r = await (0, harness_test_js_1.runHarnessTest)(spec);
|
|
41
83
|
try {
|
|
@@ -45,6 +87,7 @@ async function withHarness(spec, fn) {
|
|
|
45
87
|
r.cleanup();
|
|
46
88
|
}
|
|
47
89
|
}
|
|
90
|
+
/* v8 ignore stop */
|
|
48
91
|
// --- Plain throwing assertions (any runner) --------------------------------
|
|
49
92
|
function fail(message) {
|
|
50
93
|
throw new Error(message);
|
|
@@ -77,11 +120,164 @@ function assertHookAllowed(r) {
|
|
|
77
120
|
fail(`expected the hook to allow, but it blocked (exit ${String(r.exitCode)}, decision ${String(r.decision)})`);
|
|
78
121
|
}
|
|
79
122
|
}
|
|
123
|
+
const hostPort = (e) => `${e.host}:${String(e.port)}`;
|
|
124
|
+
/** The `host:port` strings a run attempted, e.g. `["registry.npmjs.org:443"]`. */
|
|
125
|
+
function egressHosts(r) {
|
|
126
|
+
return r.egress.map(hostPort);
|
|
127
|
+
}
|
|
128
|
+
/** Assert the confined run made NO network egress attempt at all. */
|
|
129
|
+
function assertNoEgress(r) {
|
|
130
|
+
if (r.egress.length > 0) {
|
|
131
|
+
fail(`expected no egress, but it tried to reach: ${egressHosts(r).join(", ")}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Assert every egress attempt went to an allowed host. `allowed` matches a host
|
|
136
|
+
* (exact string or regex), or a specific `host:port`. Any attempt outside the
|
|
137
|
+
* allowlist fails, naming the offender — exfil / unexpected-registry detection.
|
|
138
|
+
*/
|
|
139
|
+
function assertEgressOnly(r, allowed) {
|
|
140
|
+
const ok = (e) => allowed.some((a) => typeof a === "string"
|
|
141
|
+
? a === e.host || a === hostPort(e)
|
|
142
|
+
: a.test(e.host) || a.test(hostPort(e)));
|
|
143
|
+
const bad = r.egress.filter((e) => !ok(e));
|
|
144
|
+
if (bad.length > 0) {
|
|
145
|
+
fail(`egress to non-allowlisted host(s): ${bad.map(hostPort).join(", ")}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const matches = (f, p) => typeof p === "string" ? f.includes(p) : p.test(f);
|
|
149
|
+
/** Assert the run wrote NO file matching `pattern` (substring or regex). */
|
|
150
|
+
function assertNoWrite(r, pattern) {
|
|
151
|
+
const bad = r.filesWritten.filter((f) => matches(f, pattern));
|
|
152
|
+
if (bad.length > 0) {
|
|
153
|
+
fail(`expected no write matching ${String(pattern)}, but wrote: ${bad.join(", ")}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Assert every file the run wrote matches one of `allowed` (substring or regex). */
|
|
157
|
+
function assertWroteOnly(r, allowed) {
|
|
158
|
+
const bad = r.filesWritten.filter((f) => !allowed.some((a) => matches(f, a)));
|
|
159
|
+
if (bad.length > 0) {
|
|
160
|
+
fail(`run wrote file(s) outside the allowlist: ${bad.join(", ")}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// --- subagent railway outcome (parse the worker's result block) ------------
|
|
164
|
+
//
|
|
165
|
+
// A subagent with a result() contract ends its turn with a vigiles:ok/err block.
|
|
166
|
+
// These wrap parseAgentResult so a test can assert the worker's *outcome* the
|
|
167
|
+
// same way it asserts a hook decision — the testing-framework payoff of the
|
|
168
|
+
// railway contract: `assertAgentOk(r.output)` instead of substring-matching prose.
|
|
169
|
+
/**
|
|
170
|
+
* Assert the worker's output is a success result, and return its `value`. With a
|
|
171
|
+
* `contract`, the value is validated against the success shape (a wrong/missing
|
|
172
|
+
* field fails the assertion). A malformed or error result throws.
|
|
173
|
+
*/
|
|
174
|
+
function assertAgentOk(output, contract) {
|
|
175
|
+
const r = (0, agent_result_js_1.parseAgentResult)(output, contract);
|
|
176
|
+
if (r.kind === "ok")
|
|
177
|
+
return r.value;
|
|
178
|
+
const why = r.kind === "err" ? "returned an error result" : r.reason;
|
|
179
|
+
return fail(`expected a success result from the subagent, but ${why}`);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Assert the worker's output is an error result, and return its `error`. The
|
|
183
|
+
* railway's error track — proves the worker reported failure with rich detail
|
|
184
|
+
* (not that it crashed or returned prose). A malformed or success result throws.
|
|
185
|
+
*/
|
|
186
|
+
function assertAgentErr(output, contract) {
|
|
187
|
+
const r = (0, agent_result_js_1.parseAgentResult)(output, contract);
|
|
188
|
+
if (r.kind === "err")
|
|
189
|
+
return r.error;
|
|
190
|
+
const why = r.kind === "ok" ? "returned a success result" : r.reason;
|
|
191
|
+
return fail(`expected an error result from the subagent, but ${why}`);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Assert the parsed result satisfies `predicate` — the general form, for
|
|
195
|
+
* checking rich detail (e.g. `(r) => r.kind === "ok" && r.value.files.length > 0`).
|
|
196
|
+
*/
|
|
197
|
+
function assertAgentResult(output, predicate, contract) {
|
|
198
|
+
const r = (0, agent_result_js_1.parseAgentResult)(output, contract);
|
|
199
|
+
if (!predicate(r)) {
|
|
200
|
+
const detail = r.kind === "malformed" ? ` (${r.reason})` : "";
|
|
201
|
+
fail(`subagent result did not satisfy the predicate: ${r.kind}${detail}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
80
204
|
function nameMatches(name, pat) {
|
|
81
205
|
return typeof pat === "string" ? name === pat : pat.test(name);
|
|
82
206
|
}
|
|
83
|
-
function toolNames(
|
|
84
|
-
return
|
|
207
|
+
function toolNames(trace) {
|
|
208
|
+
return trace.toolCalls.map((c) => c.name).join(", ") || "none";
|
|
209
|
+
}
|
|
210
|
+
// --- bare predicates over a Trace (the shared vocabulary, no throw) ---------
|
|
211
|
+
//
|
|
212
|
+
// Pure fns returning a value, so the SAME vocabulary runs in both consumers:
|
|
213
|
+
// the throwing `assert*` helpers below wrap them for the testing tier, and an
|
|
214
|
+
// eval `measure` reuses them directly as metrics (`measure: (t) => ({ safe:
|
|
215
|
+
// !usedTool(t, /merge|delete/) })`). Never one dual-purpose function.
|
|
216
|
+
/**
|
|
217
|
+
* Did the agent invoke a tool whose name matches `name` (string = exact,
|
|
218
|
+
* RegExp = test)? The predicate behind `assertToolUsed` / `assertToolNotUsed`.
|
|
219
|
+
*/
|
|
220
|
+
function usedTool(trace, name) {
|
|
221
|
+
return trace.toolCalls.some((c) => nameMatches(c.name, name));
|
|
222
|
+
}
|
|
223
|
+
/** How many tools matching `name` the agent invoked. Behind `assertToolCount`. */
|
|
224
|
+
function toolCount(trace, name) {
|
|
225
|
+
return trace.toolCalls.filter((c) => nameMatches(c.name, name)).length;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Did the `Skill` tool resolve `skill` (e.g. `"superpowers:test-driven-development"`)
|
|
229
|
+
* without error? The skill-activation predicate behind `assertSkillResolved`.
|
|
230
|
+
*/
|
|
231
|
+
function skillResolved(trace, skill) {
|
|
232
|
+
const call = trace.toolCalls.find((c) => c.name === "Skill" && c.input?.skill === skill);
|
|
233
|
+
return call !== undefined && !call.isError;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Did the agent invoke a tool matching `name` whose INPUT satisfies
|
|
237
|
+
* `inputMatcher` — a tool-ARGUMENT predicate (DeepEval-style), e.g. an `Edit`
|
|
238
|
+
* that targeted the right file. The predicate behind `assertToolUsedWith`.
|
|
239
|
+
*/
|
|
240
|
+
function toolUsedWith(trace, name, inputMatcher) {
|
|
241
|
+
return trace.toolCalls.some((c) => nameMatches(c.name, name) && inputMatcher(c.input));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Does the agent's final answer (`trace.output`) contain `needle` (string =
|
|
245
|
+
* substring, RegExp = test)? The output predicate behind `assertOutputContains`
|
|
246
|
+
* — the DeepEval-style "what did the agent actually say" check.
|
|
247
|
+
*/
|
|
248
|
+
function outputContains(trace, needle) {
|
|
249
|
+
return typeof needle === "string"
|
|
250
|
+
? trace.output.includes(needle)
|
|
251
|
+
: needle.test(trace.output);
|
|
252
|
+
}
|
|
253
|
+
/** All text the model received across every request (system + every message). */
|
|
254
|
+
function requestText(trace) {
|
|
255
|
+
return trace.modelRequests
|
|
256
|
+
.map((r) => [r.system, ...r.messages.map((m) => m.text)].join("\n"))
|
|
257
|
+
.join("\n");
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Did ANY request the model received contain `needle` — searching the system
|
|
261
|
+
* prompt and every message across all requests? The predicate that proves
|
|
262
|
+
* injected context *reached the model*: a SessionStart hook's `additionalContext`
|
|
263
|
+
* or a slash command's expansion. Harness tier only — the eval tier drives the
|
|
264
|
+
* real API, so its `modelRequests` (and this) is empty. Behind `assertRequestContains`.
|
|
265
|
+
*/
|
|
266
|
+
function requestContains(trace, needle) {
|
|
267
|
+
const text = requestText(trace);
|
|
268
|
+
return typeof needle === "string" ? text.includes(needle) : needle.test(text);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Did a hook matching `name` fire? Matches against both the hook label
|
|
272
|
+
* (`"PreToolUse:Edit"`) and the bare event (`"PreToolUse"`), so `/PreToolUse/`
|
|
273
|
+
* or `"PreToolUse:Edit"` both work. The predicate behind `assertHookFired`.
|
|
274
|
+
*/
|
|
275
|
+
function hookFired(trace, name) {
|
|
276
|
+
return trace.hooks.some((h) => nameMatches(h.name, name) || nameMatches(h.event, name));
|
|
277
|
+
}
|
|
278
|
+
/** Did a hook matching `name` fire AND block (exit ≠ 0 / outcome "error")? */
|
|
279
|
+
function hookBlocked(trace, name) {
|
|
280
|
+
return trace.hooks.some((h) => (nameMatches(h.name, name) || nameMatches(h.event, name)) && h.blocked);
|
|
85
281
|
}
|
|
86
282
|
/**
|
|
87
283
|
* Assert the agent invoked a tool whose name matches `name` (string = exact,
|
|
@@ -89,9 +285,9 @@ function toolNames(r) {
|
|
|
89
285
|
* a subagent (`"Task"`). Needs `transcript: true`. The action invariant the
|
|
90
286
|
* skill/MCP/command surfaces are really about.
|
|
91
287
|
*/
|
|
92
|
-
function assertToolUsed(
|
|
93
|
-
if (!
|
|
94
|
-
fail(`expected a tool matching ${String(name)} to be used; tools used: [${toolNames(
|
|
288
|
+
function assertToolUsed(trace, name) {
|
|
289
|
+
if (!usedTool(trace, name)) {
|
|
290
|
+
fail(`expected a tool matching ${String(name)} to be used; tools used: [${toolNames(trace)}] (did you set transcript:true?)`);
|
|
95
291
|
}
|
|
96
292
|
}
|
|
97
293
|
/**
|
|
@@ -99,8 +295,9 @@ function assertToolUsed(r, name) {
|
|
|
99
295
|
* (e.g. a destructive MCP tool was never called). "File unchanged" can pass by
|
|
100
296
|
* accident; "the tool was never used" is the real invariant. Needs `transcript`.
|
|
101
297
|
*/
|
|
102
|
-
function assertToolNotUsed(
|
|
103
|
-
|
|
298
|
+
function assertToolNotUsed(trace, name) {
|
|
299
|
+
// `find` is the negative of `usedTool` and narrows the hit for the message.
|
|
300
|
+
const hit = trace.toolCalls.find((c) => nameMatches(c.name, name));
|
|
104
301
|
if (hit) {
|
|
105
302
|
fail(`expected no tool matching ${String(name)} to be used, but ${hit.name} was`);
|
|
106
303
|
}
|
|
@@ -109,17 +306,74 @@ function assertToolNotUsed(r, name) {
|
|
|
109
306
|
* Assert the `Skill` tool resolved `skill` (e.g. `"superpowers:test-driven-development"`)
|
|
110
307
|
* without error — the correct skill-activation invariant, vs. grepping the body.
|
|
111
308
|
*/
|
|
112
|
-
function assertSkillResolved(
|
|
113
|
-
|
|
309
|
+
function assertSkillResolved(trace, skill) {
|
|
310
|
+
if (skillResolved(trace, skill))
|
|
311
|
+
return;
|
|
312
|
+
// skillResolved is false → either no matching Skill call, or it errored.
|
|
313
|
+
// Reconstruct which, for a useful message.
|
|
314
|
+
const call = trace.toolCalls.find((c) => c.name === "Skill" && c.input?.skill === skill);
|
|
114
315
|
if (!call) {
|
|
115
|
-
const seen =
|
|
316
|
+
const seen = trace.toolCalls
|
|
116
317
|
.filter((c) => c.name === "Skill")
|
|
117
318
|
.map((c) => c.input?.skill ?? "?")
|
|
118
319
|
.join(", ");
|
|
119
320
|
fail(`expected the Skill tool to resolve "${skill}"; Skill calls: [${seen || "none"}]`);
|
|
120
321
|
}
|
|
121
|
-
|
|
122
|
-
|
|
322
|
+
fail(`the Skill "${skill}" was invoked but errored: ${call.resultText.slice(0, 200)}`);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Assert the agent invoked a tool matching `name` whose INPUT satisfies
|
|
326
|
+
* `inputMatcher` — a tool-ARGUMENT invariant (DeepEval-style). Asserts not just
|
|
327
|
+
* *that* a tool ran but *with what args*, e.g. an `Edit` that targeted the right
|
|
328
|
+
* file: `assertToolUsedWith(r, "Edit", (i) => (i as { file_path?: string })
|
|
329
|
+
* .file_path === "src/x.ts")`. Needs `transcript`.
|
|
330
|
+
*/
|
|
331
|
+
function assertToolUsedWith(trace, name, inputMatcher, message) {
|
|
332
|
+
if (!toolUsedWith(trace, name, inputMatcher)) {
|
|
333
|
+
const seen = trace.toolCalls
|
|
334
|
+
.filter((c) => nameMatches(c.name, name))
|
|
335
|
+
.map((c) => JSON.stringify(c.input))
|
|
336
|
+
.join(", ");
|
|
337
|
+
fail(message ??
|
|
338
|
+
`expected a ${String(name)} call whose input matches; ${String(name)} inputs: [${seen || "none"}]`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/** Assert the agent's final answer contains `needle` (string substring / RegExp). */
|
|
342
|
+
function assertOutputContains(trace, needle) {
|
|
343
|
+
if (!outputContains(trace, needle)) {
|
|
344
|
+
const shown = trace.output.slice(0, 200) || "(empty)";
|
|
345
|
+
fail(`expected the agent's final answer to contain ${String(needle)}; got: ${shown}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Assert some request the model received contained `needle` — the "did the
|
|
350
|
+
* injected context land" invariant (SessionStart `additionalContext`, slash
|
|
351
|
+
* command expansion). Harness tier only; a zero-request trace fails with a hint
|
|
352
|
+
* that the eval tier can't capture requests.
|
|
353
|
+
*/
|
|
354
|
+
function assertRequestContains(trace, needle) {
|
|
355
|
+
if (!requestContains(trace, needle)) {
|
|
356
|
+
const n = trace.modelRequests.length;
|
|
357
|
+
const hint = n === 0
|
|
358
|
+
? " (no requests captured — modelRequests is harness-tier only)"
|
|
359
|
+
: "";
|
|
360
|
+
fail(`expected a model request to contain ${String(needle)}; ${String(n)} request(s) captured${hint}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function hookNames(trace) {
|
|
364
|
+
return trace.hooks.map((h) => h.name).join(", ") || "none";
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Assert a hook matching `name` fired (and, with `{ blocked: true }`, that it
|
|
368
|
+
* blocked) — the honest hook-firing check, recorded from the run's stream rather
|
|
369
|
+
* than inferred from a marker file the hook had to write. Needs `transcript`.
|
|
370
|
+
*/
|
|
371
|
+
function assertHookFired(trace, name, opts = {}) {
|
|
372
|
+
if (!hookFired(trace, name)) {
|
|
373
|
+
fail(`expected a hook matching ${String(name)} to fire; hooks fired: [${hookNames(trace)}] (did you set transcript:true?)`);
|
|
374
|
+
}
|
|
375
|
+
if (opts.blocked === true && !hookBlocked(trace, name)) {
|
|
376
|
+
fail(`expected a hook matching ${String(name)} to block, but none did; hooks fired: [${hookNames(trace)}]`);
|
|
123
377
|
}
|
|
124
378
|
}
|
|
125
379
|
// --- sequence / budget invariants over the agent's actions -----------------
|
|
@@ -128,13 +382,13 @@ function assertSkillResolved(r, skill) {
|
|
|
128
382
|
* budget invariant (e.g. `{ max: 1 }` = "at most one Write", `{ exactly: 0 }` =
|
|
129
383
|
* "never touched it"). Catches runaway loops and wasted work. Needs `transcript`.
|
|
130
384
|
*/
|
|
131
|
-
function assertToolCount(
|
|
132
|
-
const n =
|
|
385
|
+
function assertToolCount(trace, name, bounds) {
|
|
386
|
+
const n = toolCount(trace, name);
|
|
133
387
|
const ok = (bounds.exactly === undefined || n === bounds.exactly) &&
|
|
134
388
|
(bounds.min === undefined || n >= bounds.min) &&
|
|
135
389
|
(bounds.max === undefined || n <= bounds.max);
|
|
136
390
|
if (!ok) {
|
|
137
|
-
fail(`expected count of ${String(name)} to satisfy ${JSON.stringify(bounds)}, got ${String(n)} (tools: [${toolNames(
|
|
391
|
+
fail(`expected count of ${String(name)} to satisfy ${JSON.stringify(bounds)}, got ${String(n)} (tools: [${toolNames(trace)}])`);
|
|
138
392
|
}
|
|
139
393
|
}
|
|
140
394
|
/**
|
|
@@ -143,15 +397,15 @@ function assertToolCount(r, name, bounds) {
|
|
|
143
397
|
* Edit. For a stricter rule (every Edit preceded by a Read), use `assertToolCalls`.
|
|
144
398
|
* Needs `transcript`.
|
|
145
399
|
*/
|
|
146
|
-
function assertToolSequence(
|
|
400
|
+
function assertToolSequence(trace, names) {
|
|
147
401
|
let i = 0;
|
|
148
|
-
for (const c of
|
|
402
|
+
for (const c of trace.toolCalls) {
|
|
149
403
|
const want = names[i];
|
|
150
404
|
if (want !== undefined && nameMatches(c.name, want))
|
|
151
405
|
i++;
|
|
152
406
|
}
|
|
153
407
|
if (i < names.length) {
|
|
154
|
-
fail(`expected tools in order [${names.map((n) => String(n)).join(" → ")}]; got [${toolNames(
|
|
408
|
+
fail(`expected tools in order [${names.map((n) => String(n)).join(" → ")}]; got [${toolNames(trace)}]`);
|
|
155
409
|
}
|
|
156
410
|
}
|
|
157
411
|
/**
|
|
@@ -159,9 +413,27 @@ function assertToolSequence(r, names) {
|
|
|
159
413
|
* the agent made — for rules the helpers above don't express, e.g. "every Edit
|
|
160
414
|
* was preceded by a Read of that file". Needs `transcript`.
|
|
161
415
|
*/
|
|
162
|
-
function assertToolCalls(
|
|
163
|
-
if (!predicate(
|
|
164
|
-
fail(`${message}; tools used: [${toolNames(
|
|
416
|
+
function assertToolCalls(trace, predicate, message = "tool-call invariant failed") {
|
|
417
|
+
if (!predicate(trace.toolCalls)) {
|
|
418
|
+
fail(`${message}; tools used: [${toolNames(trace)}]`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Did `arm` succeed on EVERY trial for `metric` — τ-bench pass^k = 1? The
|
|
423
|
+
* reliability predicate over an eval report (vs. `improvement`, which reads the
|
|
424
|
+
* mean gap). Reads `report.arms[arm].stats[metric].passK`.
|
|
425
|
+
*/
|
|
426
|
+
function reliable(report, arm, metric) {
|
|
427
|
+
return report.arms[arm]?.stats[metric]?.passK === 1;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Assert `arm` passed `metric` on every trial (pass^k = 1) — the reliability
|
|
431
|
+
* gate for a non-deterministic harness ("worked every time", not "on average").
|
|
432
|
+
*/
|
|
433
|
+
function assertReliable(report, opts) {
|
|
434
|
+
if (!reliable(report, opts.arm, opts.metric)) {
|
|
435
|
+
const pk = report.arms[opts.arm]?.stats[opts.metric]?.passK;
|
|
436
|
+
fail(`expected ${opts.arm} to pass ${opts.metric} on every trial (pass^k=1), got pass^k=${String(pk ?? "n/a")}`);
|
|
165
437
|
}
|
|
166
438
|
}
|
|
167
439
|
/** The gap on `metric` between two arms (arm − baseline). */
|
|
@@ -171,17 +443,78 @@ function improvement(report, baseline, arm, metric) {
|
|
|
171
443
|
return a - b;
|
|
172
444
|
}
|
|
173
445
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
446
|
+
* Did `arm` *significantly* beat `baseline` on `metric` — a positive gap whose
|
|
447
|
+
* two-sided Welch t-test p-value is below `alpha` (default 0.05)? The grounded
|
|
448
|
+
* upgrade over `improvement`: the noise floor is computed from the arms' spread,
|
|
449
|
+
* not hand-fed. False when either arm/metric is missing. See `src/stats.ts`.
|
|
450
|
+
*/
|
|
451
|
+
// eslint-disable-next-line max-params -- positional predicate mirrors `improvement` + alpha
|
|
452
|
+
function significantlyBeats(report, baseline, arm, metric, alpha = 0.05) {
|
|
453
|
+
const c = (0, stats_js_1.compareArms)(report, baseline, arm, metric, alpha);
|
|
454
|
+
return c !== null && c.delta > 0 && c.significant;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Assert `arm` significantly beats `baseline` on `metric` (positive gap, p < α).
|
|
458
|
+
* The statistical gate for a non-deterministic A/B — "the gap clears the noise",
|
|
459
|
+
* with the noise floor computed, not supplied. The honest version of
|
|
460
|
+
* `assertImproves(..., { by: se })`.
|
|
461
|
+
*/
|
|
462
|
+
function assertSignificant(report, opts) {
|
|
463
|
+
const c = (0, stats_js_1.compareArms)(report, opts.baseline, opts.arm, opts.metric, opts.alpha);
|
|
464
|
+
if (c === null) {
|
|
465
|
+
fail(`no data to compare ${opts.arm} vs ${opts.baseline} on ${opts.metric}`);
|
|
466
|
+
}
|
|
467
|
+
const alpha = opts.alpha ?? 0.05;
|
|
468
|
+
if (!(c.delta > 0 && c.significant)) {
|
|
469
|
+
fail(`expected ${opts.arm} to significantly beat ${opts.baseline} on ${opts.metric} (α=${String(alpha)}); Δ=${c.delta.toFixed(3)}, p=${c.pValue.toFixed(3)}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Assert `arm` beats `baseline` on `metric`. By default just a positive gap > `by`
|
|
474
|
+
* (pass the combined se to clear the noise floor by hand). Pass `{ significant:
|
|
475
|
+
* true }` to demand a Welch t-test at `alpha` instead — the computed noise floor.
|
|
177
476
|
*/
|
|
178
477
|
function assertImproves(report, opts) {
|
|
478
|
+
if (opts.significant === true) {
|
|
479
|
+
assertSignificant(report, opts);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
179
482
|
const by = opts.by ?? 0;
|
|
180
483
|
const delta = improvement(report, opts.baseline, opts.arm, opts.metric);
|
|
181
484
|
if (delta <= by) {
|
|
182
485
|
fail(`expected ${opts.arm} to beat ${opts.baseline} on ${opts.metric} by > ${String(by)}, got ${delta.toFixed(3)}`);
|
|
183
486
|
}
|
|
184
487
|
}
|
|
488
|
+
/**
|
|
489
|
+
* Assert the current run has not *regressed* against a committed baseline — the
|
|
490
|
+
* CI gate (Phase C). A regression is an arm×metric that moved **significantly in
|
|
491
|
+
* the bad direction** vs. `baseline` (Welch t-test, so sampling noise doesn't
|
|
492
|
+
* trip it; see `src/eval-baseline.ts`). Higher is better by default; list
|
|
493
|
+
* `lowerIsBetter` metrics (cost/latency) to flip them. Load the baseline with
|
|
494
|
+
* `readBaseline(path)` and record a fresh one with `writeBaseline(path, reports)`.
|
|
495
|
+
*/
|
|
496
|
+
function assertNoRegression(current, baseline, opts) {
|
|
497
|
+
const reports = Array.isArray(current)
|
|
498
|
+
? current
|
|
499
|
+
: [current];
|
|
500
|
+
const diff = (0, eval_baseline_js_1.diffReports)(baseline, reports, opts);
|
|
501
|
+
if (!diff.passed) {
|
|
502
|
+
const detail = diff.regressions
|
|
503
|
+
.map((r) => `${r.report}/${r.arm}/${r.metric} Δ=${r.comparison.delta.toFixed(3)} p=${r.comparison.pValue.toFixed(3)}`)
|
|
504
|
+
.join("; ");
|
|
505
|
+
fail(`regression vs baseline: ${detail}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Assert a skill/behaviour triggered on at least `min` (0..1) of its runs — the
|
|
510
|
+
* reliability gate for a skill's *activation* (does its description fire on the
|
|
511
|
+
* task), over a {@link TriggerRateReport} from `measureTriggerRate`.
|
|
512
|
+
*/
|
|
513
|
+
function assertTriggerRate(report, opts) {
|
|
514
|
+
if (report.rate < opts.min) {
|
|
515
|
+
fail(`expected a trigger rate ≥ ${String(opts.min)}, got ${report.rate.toFixed(2)} (${String(report.n)} runs)`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
185
518
|
/**
|
|
186
519
|
* Custom matchers compatible with both vitest and jest. Register once:
|
|
187
520
|
*
|
package/dist/harness-test.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { type ModelTurn } from "./mock-model.js";
|
|
2
|
-
|
|
1
|
+
import { type ModelTurn, type ModelRequest } from "./mock-model.js";
|
|
2
|
+
import { type SandboxMode } from "./sandbox.js";
|
|
3
|
+
export { scriptModel, type ModelTurn, type ModelRequest, } from "./mock-model.js";
|
|
3
4
|
export { loadPlugin, resolveHarness } from "./plugin-loader.js";
|
|
5
|
+
export { decideSandbox, specTrusted, sandboxAvailable, type SandboxMode, } from "./sandbox.js";
|
|
4
6
|
export interface HarnessTestSpec {
|
|
5
7
|
/** Fixture files to write in a fresh temp working dir (path → contents). */
|
|
6
8
|
readonly files?: Record<string, string>;
|
|
@@ -37,8 +39,80 @@ export interface HarnessTestSpec {
|
|
|
37
39
|
readonly transcript?: boolean;
|
|
38
40
|
/** Per-run wall-clock timeout in ms. Default 60000. */
|
|
39
41
|
readonly timeoutMs?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Confinement policy for the code this run executes (`src/sandbox.ts`).
|
|
44
|
+
* Default `"auto"` is safe-by-default: an inline-only spec (you authored it)
|
|
45
|
+
* runs directly, but an external `plugin` / `pluginDir` brings in untrusted
|
|
46
|
+
* third-party hooks and is run under bubblewrap — or, if no sandbox is
|
|
47
|
+
* available, the run REFUSES rather than executing unconfined. Pass `false` to
|
|
48
|
+
* opt out and run unconfined (you audited the code, or trust the outer
|
|
49
|
+
* container); `"strict"` to force confinement even for trusted code.
|
|
50
|
+
*
|
|
51
|
+
* NOTE: confined execution is **Linux only** (bubblewrap is a Linux tool). On
|
|
52
|
+
* macOS / Windows no sandbox is available, so an untrusted run will REFUSE
|
|
53
|
+
* under `"auto"`/`"strict"` — use `sandbox: false` there if you trust the code.
|
|
54
|
+
*/
|
|
55
|
+
readonly sandbox?: SandboxMode;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A hook invocation observed during the run, recorded (not inferred) from the
|
|
59
|
+
* `hook_response` system events the CLI emits in the stream — so a test can
|
|
60
|
+
* assert which hook fired and whether it blocked, instead of inferring it from a
|
|
61
|
+
* marker file the hook had to write.
|
|
62
|
+
*/
|
|
63
|
+
export interface HookFire {
|
|
64
|
+
/** The hook label, e.g. `"PreToolUse:Edit"` (`Event:Matcher`). */
|
|
65
|
+
readonly name: string;
|
|
66
|
+
/** The hook event, e.g. `"PreToolUse"`, `"PostToolUse"`, `"Stop"`. */
|
|
67
|
+
readonly event: string;
|
|
68
|
+
/** The hook process exit code (2 = block), or undefined if not reported. */
|
|
69
|
+
readonly exitCode: number | undefined;
|
|
70
|
+
/** Whether the hook blocked / errored (exit ≠ 0 or outcome "error"). */
|
|
71
|
+
readonly blocked: boolean;
|
|
72
|
+
/** What the hook printed (its block reason / diagnostic), or "". */
|
|
73
|
+
readonly output: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The observable record of ONE run — the unified shape produced by BOTH testing
|
|
77
|
+
* tiers: `runHarnessTest`'s result and `runEval`'s `measure` ctx (`eval.ts`)
|
|
78
|
+
* both satisfy it. That's what lets the bare predicates in `harness-assert.ts`
|
|
79
|
+
* (`usedTool` / `skillResolved` / `toolCount` / `toolUsedWith` / `hookFired` /
|
|
80
|
+
* `outputContains`) run over either, with the testing helpers asserting and eval
|
|
81
|
+
* measuring over the same vocabulary.
|
|
82
|
+
*/
|
|
83
|
+
export interface Trace {
|
|
84
|
+
/**
|
|
85
|
+
* The tools the agent invoked, each paired with its result — parsed from the
|
|
86
|
+
* transcript. Empty unless the run captured the stream (`transcript: true` on
|
|
87
|
+
* the harness tier; always on the eval tier). Lets a test assert on the
|
|
88
|
+
* agent's *actions* (skills, MCP tools, subagents) instead of grepping stdout.
|
|
89
|
+
*/
|
|
90
|
+
readonly toolCalls: readonly ToolCall[];
|
|
91
|
+
/**
|
|
92
|
+
* The hooks that fired during the run, each with its decision — parsed from
|
|
93
|
+
* the CLI's `hook_response` stream events. Same capture requirement as
|
|
94
|
+
* `toolCalls` (empty without the stream). Lets a test assert hook firing
|
|
95
|
+
* honestly instead of via a marker file.
|
|
96
|
+
*/
|
|
97
|
+
readonly hooks: readonly HookFire[];
|
|
98
|
+
/** The agent's final answer text (the terminal `result` event), or "". */
|
|
99
|
+
readonly output: string;
|
|
100
|
+
/**
|
|
101
|
+
* The requests the model received, captured by the scripted mock — each with
|
|
102
|
+
* its `system` prompt and `messages`, flattened to text. Lets a test assert
|
|
103
|
+
* what actually reached the model (a SessionStart hook's injected context, a
|
|
104
|
+
* slash command's expansion), not just that a hook fired. **Harness tier
|
|
105
|
+
* only**: the mock sees the requests, so this is populated by `runHarnessTest`
|
|
106
|
+
* (with or without `transcript`); the eval tier drives the real API, so its
|
|
107
|
+
* `modelRequests` is always empty.
|
|
108
|
+
*/
|
|
109
|
+
readonly modelRequests: readonly ModelRequest[];
|
|
110
|
+
/** Number of model turns. */
|
|
111
|
+
readonly turns: number;
|
|
112
|
+
/** Final contents of a file under the working dir, or null if absent. */
|
|
113
|
+
file(path: string): string | null;
|
|
40
114
|
}
|
|
41
|
-
export interface HarnessTestResult {
|
|
115
|
+
export interface HarnessTestResult extends Trace {
|
|
42
116
|
readonly exitCode: number;
|
|
43
117
|
readonly stdout: string;
|
|
44
118
|
/** Hook block messages and diagnostics land here. */
|
|
@@ -47,14 +121,6 @@ export interface HarnessTestResult {
|
|
|
47
121
|
readonly cwd: string;
|
|
48
122
|
/** Number of model turns the agent took (mock turns served). */
|
|
49
123
|
readonly turns: number;
|
|
50
|
-
/**
|
|
51
|
-
* The tools the agent invoked, each paired with its result — parsed from the
|
|
52
|
-
* transcript. Empty unless `transcript: true`. Lets a test assert on the
|
|
53
|
-
* agent's *actions* (skills, MCP tools, subagents) instead of grepping stdout.
|
|
54
|
-
*/
|
|
55
|
-
readonly toolCalls: readonly ToolCall[];
|
|
56
|
-
/** Final contents of a file under the working dir, or null if absent. */
|
|
57
|
-
file(path: string): string | null;
|
|
58
124
|
/** Remove the temp working dir. */
|
|
59
125
|
cleanup(): void;
|
|
60
126
|
}
|
|
@@ -74,11 +140,31 @@ export interface ToolCall {
|
|
|
74
140
|
* actions, not a brittle stdout substring.
|
|
75
141
|
*/
|
|
76
142
|
export declare function parseToolCalls(streamJson: string): ToolCall[];
|
|
143
|
+
/**
|
|
144
|
+
* The terminal `result` event — present in BOTH `--output-format` shapes (a
|
|
145
|
+
* `{type:"result", …}` line in stream-json, the single object in `json`), or
|
|
146
|
+
* null. The seam for the final answer + turn count without parsing twice.
|
|
147
|
+
*/
|
|
148
|
+
export declare function parseResultEvent(stdout: string): Record<string, unknown> | null;
|
|
149
|
+
/** The agent's final answer text from a transcript / result object, or "". */
|
|
150
|
+
export declare function parseOutput(stdout: string): string;
|
|
151
|
+
export declare function parseHooks(stdout: string): HookFire[];
|
|
152
|
+
/**
|
|
153
|
+
* The `claude` CLI argv for a harness run (shared by the direct and sandboxed
|
|
154
|
+
* paths). `ANTHROPIC_BASE_URL` is set by the caller's environment / wrapper, not
|
|
155
|
+
* here. Pure, so the arg shape is unit-tested.
|
|
156
|
+
*/
|
|
157
|
+
export declare function buildClaudeArgs(spec: HarnessTestSpec, hasSettings: boolean): string[];
|
|
77
158
|
/** Whether the `claude` CLI is available — harness tests need it. */
|
|
78
159
|
export declare function claudeAvailable(): boolean;
|
|
79
160
|
/**
|
|
80
161
|
* Run the real `claude` CLI against a scripted mock model, with the given
|
|
81
162
|
* fixture and settings (hooks). Deterministic — same script, same result.
|
|
163
|
+
*
|
|
164
|
+
* Safe by default: an external `plugin` / `pluginDir` brings in untrusted
|
|
165
|
+
* third-party hooks and is confined under bubblewrap (`spec.sandbox`, default
|
|
166
|
+
* `"auto"`); if no sandbox is available the run REFUSES rather than executing
|
|
167
|
+
* unconfined. See `src/sandbox.ts`.
|
|
82
168
|
*/
|
|
83
169
|
export declare function runHarnessTest(spec: HarnessTestSpec): Promise<HarnessTestResult>;
|
|
84
170
|
//# sourceMappingURL=harness-test.d.ts.map
|