vigiles 34.0.0 → 35.0.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/dist/adapters/codex/driver.js +2 -1
- package/dist/adapters/codex/mock-model.d.ts +3 -2
- package/dist/adapters/codex/mock-model.js +15 -3
- package/dist/cli-main.js +76 -52
- package/dist/core/harness-driver.d.ts +5 -2
- package/dist/core/script-overrun.d.ts +32 -0
- package/dist/core/script-overrun.js +42 -0
- package/dist/doc-test-script-coverage.d.ts +1 -1
- package/dist/doc-test-script-coverage.js +1 -1
- package/dist/eval-lock.d.ts +5 -6
- package/dist/eval-lock.js +5 -6
- package/dist/harness-test.d.ts +35 -1
- package/dist/harness-test.js +100 -10
- package/dist/mock-model.d.ts +4 -1
- package/dist/mock-model.js +42 -11
- package/package.json +1 -2
|
@@ -75,7 +75,8 @@ async function startCodexMockHandle(script) {
|
|
|
75
75
|
}));
|
|
76
76
|
},
|
|
77
77
|
get count() {
|
|
78
|
-
|
|
78
|
+
// Turns SERVED: a request past the end of the script gets a 400.
|
|
79
|
+
return Math.min(mock.requests.length, script.length);
|
|
79
80
|
},
|
|
80
81
|
close: () => mock.close(),
|
|
81
82
|
};
|
|
@@ -42,8 +42,9 @@ export declare function parseResponsesRequest(body: string): {
|
|
|
42
42
|
};
|
|
43
43
|
/**
|
|
44
44
|
* Start the scripted Codex Responses mock on a free port. Each
|
|
45
|
-
* `POST /v1/responses` consumes the next scripted turn
|
|
46
|
-
*
|
|
45
|
+
* `POST /v1/responses` consumes the next scripted turn; one past the end of the
|
|
46
|
+
* script is a 400, never a copy of the last turn (#340); HEAD and any other path
|
|
47
|
+
* get `{}`. Resolves to a handle
|
|
47
48
|
* with the base `url`, the `port`, the recorded `requests`, and `close()`.
|
|
48
49
|
*/
|
|
49
50
|
export declare function startCodexMock(script: readonly CodexTurn[], opts?: {
|
|
@@ -22,6 +22,7 @@ exports.startCodexMock = startCodexMock;
|
|
|
22
22
|
* `src/adapters/codex/runtime.ts` (`codexMockArgs`/`codexMockEnv`).
|
|
23
23
|
*/
|
|
24
24
|
const node_http_1 = __importDefault(require("node:http"));
|
|
25
|
+
const script_overrun_js_1 = require("../../core/script-overrun.js");
|
|
25
26
|
const MSG_ID = "msg_mock_1";
|
|
26
27
|
const RESP_ID = "resp_mock_1";
|
|
27
28
|
/**
|
|
@@ -183,8 +184,9 @@ function parseResponsesRequest(body) {
|
|
|
183
184
|
}
|
|
184
185
|
/**
|
|
185
186
|
* Start the scripted Codex Responses mock on a free port. Each
|
|
186
|
-
* `POST /v1/responses` consumes the next scripted turn
|
|
187
|
-
*
|
|
187
|
+
* `POST /v1/responses` consumes the next scripted turn; one past the end of the
|
|
188
|
+
* script is a 400, never a copy of the last turn (#340); HEAD and any other path
|
|
189
|
+
* get `{}`. Resolves to a handle
|
|
188
190
|
* with the base `url`, the `port`, the recorded `requests`, and `close()`.
|
|
189
191
|
*/
|
|
190
192
|
function startCodexMock(script, opts = {}) {
|
|
@@ -204,7 +206,17 @@ function startCodexMock(script, opts = {}) {
|
|
|
204
206
|
const parsed = parseResponsesRequest(body);
|
|
205
207
|
requests.push(parsed);
|
|
206
208
|
opts.onRequest?.(parsed);
|
|
207
|
-
const turn = script[
|
|
209
|
+
const turn = script[i];
|
|
210
|
+
if (turn === undefined) {
|
|
211
|
+
const message = (0, script_overrun_js_1.scriptOverrunMessage)({
|
|
212
|
+
scripted: script.length,
|
|
213
|
+
request: script.length + 1,
|
|
214
|
+
lastMessage: parsed.prompt,
|
|
215
|
+
});
|
|
216
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
217
|
+
res.end(JSON.stringify({ error: { type: "invalid_request_error", message } }));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
208
220
|
i++;
|
|
209
221
|
const model = parsed.model || "gpt-5-codex";
|
|
210
222
|
res.writeHead(200, {
|
package/dist/cli-main.js
CHANGED
|
@@ -2671,17 +2671,21 @@ function vigilesWorkflow(plan, harnesses, hasPackageJson) {
|
|
|
2671
2671
|
eval-check:
|
|
2672
2672
|
# Eval staleness gate — real-model evals run LOCALLY on your subscription
|
|
2673
2673
|
# (\`npx vigiles eval --update\`, which commits a lock); this job VERIFIES those
|
|
2674
|
-
# committed results against the current inputs with NO model call.
|
|
2675
|
-
#
|
|
2674
|
+
# committed results against the current inputs with NO model call.
|
|
2675
|
+
# \`--min=0\` is deliberate: a repo with no eval file yet matches nothing, and
|
|
2676
|
+
# \`vigiles eval --check\` fails on an empty match by default (a gate that
|
|
2677
|
+
# verified nothing must not read as a pass). With \`--min=0\` this job is green
|
|
2678
|
+
# until the first \`*.eval.*\` file exists; from then on it is red until that
|
|
2679
|
+
# eval's lock is committed. Raise the floor (\`--min=N\`) once you want
|
|
2680
|
+
# a vanished eval file to fail it. See docs/harness-testing.md.
|
|
2676
2681
|
runs-on: ubuntu-latest
|
|
2677
2682
|
steps:
|
|
2678
2683
|
- uses: actions/checkout@v4
|
|
2679
2684
|
- uses: actions/setup-node@v4
|
|
2680
2685
|
with:
|
|
2681
2686
|
node-version: "20"
|
|
2682
|
-
-
|
|
2683
|
-
|
|
2684
|
-
command: eval-check
|
|
2687
|
+
- run: npm install
|
|
2688
|
+
- run: npx vigiles eval --check --min=0
|
|
2685
2689
|
`
|
|
2686
2690
|
: "";
|
|
2687
2691
|
// No selectable jobs (e.g. `--no-lint --no-test`, or `--test` on a repo with no
|
|
@@ -5195,10 +5199,11 @@ async function promptYesNo(question) {
|
|
|
5195
5199
|
* Resolve the eval LOCK env from the `eval` flags. `--update` records each named
|
|
5196
5200
|
* eval's report to a committed `.vigiles/eval-locks/<name>.lock.json` (run locally
|
|
5197
5201
|
* on your subscription); `--check` (CI) verifies the committed result against the
|
|
5198
|
-
* current inputs WITHOUT a model call. `--check`
|
|
5199
|
-
*
|
|
5200
|
-
*
|
|
5201
|
-
*
|
|
5202
|
+
* current inputs WITHOUT a model call. `--check` with no lock committed at all is
|
|
5203
|
+
* a FAILURE, not a green no-op ({@link refuseEvalCheckWithoutLocks}, #197): a gate
|
|
5204
|
+
* that has verified nothing must not read as one that passed.
|
|
5205
|
+
* `--check`+`--update` together is a usage error (exit 2). The behavior epoch
|
|
5206
|
+
* comes from `.vigilesrc.json` `eval.apiVersion` (committed).
|
|
5202
5207
|
*/
|
|
5203
5208
|
function resolveEvalLockEnv(args) {
|
|
5204
5209
|
const wantCheck = args.includes("--check");
|
|
@@ -5207,13 +5212,6 @@ function resolveEvalLockEnv(args) {
|
|
|
5207
5212
|
console.error("vigiles eval: --check and --update are mutually exclusive (one verifies, one records).");
|
|
5208
5213
|
process.exit(2);
|
|
5209
5214
|
}
|
|
5210
|
-
if (wantCheck &&
|
|
5211
|
-
!(0, eval_lock_js_1.anyLocksCommitted)((0, node_path_1.resolve)(process.cwd(), eval_lock_js_1.DEFAULT_LOCK_DIR))) {
|
|
5212
|
-
console.log("ℹ vigiles eval --check: no committed eval locks found — nothing to verify.\n" +
|
|
5213
|
-
" Run `vigiles eval --update` locally (on your subscription) and commit the\n" +
|
|
5214
|
-
" lock to enable the CI staleness gate.");
|
|
5215
|
-
return "skip";
|
|
5216
|
-
}
|
|
5217
5215
|
const env = {};
|
|
5218
5216
|
if (wantCheck)
|
|
5219
5217
|
env.VIGILES_EVAL_LOCK = "check";
|
|
@@ -5226,6 +5224,22 @@ function resolveEvalLockEnv(args) {
|
|
|
5226
5224
|
}
|
|
5227
5225
|
return env;
|
|
5228
5226
|
}
|
|
5227
|
+
/**
|
|
5228
|
+
* `eval --check` over eval files when no lock is committed anywhere: every one of
|
|
5229
|
+
* them has no recorded result to compare the current inputs against, so the gate
|
|
5230
|
+
* would verify nothing and pass. It used to return green BEFORE discovery, which
|
|
5231
|
+
* also bypassed the empty-match failure. Once any lock exists, a file without one
|
|
5232
|
+
* already fails as "lock missing" in the eval itself. Exits 1; returns otherwise.
|
|
5233
|
+
*/
|
|
5234
|
+
function refuseEvalCheckWithoutLocks(args, evalCount) {
|
|
5235
|
+
if (!args.includes("--check"))
|
|
5236
|
+
return;
|
|
5237
|
+
if ((0, eval_lock_js_1.anyLocksCommitted)((0, node_path_1.resolve)(process.cwd(), eval_lock_js_1.DEFAULT_LOCK_DIR)))
|
|
5238
|
+
return;
|
|
5239
|
+
console.error(`✗ vigiles eval --check: ${String(evalCount)} eval file(s) have no recorded result — no eval lock is committed, so there is nothing to verify the current inputs against.\n` +
|
|
5240
|
+
" Run `vigiles eval --update` locally (on your subscription) and commit the lock(s).");
|
|
5241
|
+
process.exit(1);
|
|
5242
|
+
}
|
|
5229
5243
|
/**
|
|
5230
5244
|
* Turn a completed run into `.vigiles/coverage.json` — the composition root for
|
|
5231
5245
|
* the execution tier of coverage (`coverage-artifact.ts`).
|
|
@@ -5400,6 +5414,31 @@ function gitHead(cwd) {
|
|
|
5400
5414
|
return "";
|
|
5401
5415
|
}
|
|
5402
5416
|
}
|
|
5417
|
+
/**
|
|
5418
|
+
* Why a `vigiles test` / `vigiles eval` run matched no file — the message names
|
|
5419
|
+
* what was looked for, so a stale path, a wrong glob and an empty default
|
|
5420
|
+
* discovery each say so. A DIRECTORY is the one stale-looking target whose cause
|
|
5421
|
+
* we actually know, so it is said instead of the three guesses: `vigiles test .`
|
|
5422
|
+
* used to reach `spawn("node", ["."])` and surface Node's module-resolution stack.
|
|
5423
|
+
*/
|
|
5424
|
+
function emptyMatchMessage(kind, targets, defaultGlob) {
|
|
5425
|
+
const dirs = targets.filter((a) => (0, node_fs_1.lstatSync)(a, { throwIfNoEntry: false })?.isDirectory() === true);
|
|
5426
|
+
if (dirs.length > 0) {
|
|
5427
|
+
return (`✗ vigiles ${kind}: ${dirs.join(", ")} ${dirs.length === 1 ? "is a directory" : "are directories"} — ` +
|
|
5428
|
+
`pass a file, or a glob like "${defaultGlob}".\n` +
|
|
5429
|
+
` A directory is not a script; nothing ran.`);
|
|
5430
|
+
}
|
|
5431
|
+
const expected = ` If an empty match is expected here, say so with --min=0.`;
|
|
5432
|
+
if (targets.length === 0) {
|
|
5433
|
+
return (`✗ vigiles ${kind}: NOTHING matched the default glob ${defaultGlob}\n` +
|
|
5434
|
+
` Nothing ran. An empty run is not a pass: a renamed or moved file looks like this.\n` +
|
|
5435
|
+
expected);
|
|
5436
|
+
}
|
|
5437
|
+
return (`✗ vigiles ${kind}: ${String(targets.length)} target(s) given and NOTHING matched — ` +
|
|
5438
|
+
`${targets.join(", ")}\n` +
|
|
5439
|
+
` Nothing ran. A stale path, a wrong glob, or a moved file all look like this.\n` +
|
|
5440
|
+
expected);
|
|
5441
|
+
}
|
|
5403
5442
|
async function handleRunScripts(kind, args, restArgs, excludes) {
|
|
5404
5443
|
const cwd = process.cwd();
|
|
5405
5444
|
// The ignore file keeps NEW copies of `.vigiles/` local files out of git, but
|
|
@@ -5420,14 +5459,10 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
|
|
|
5420
5459
|
// Harness/eval scripts may be authored in JS or TS (see run-scripts.ts).
|
|
5421
5460
|
const defaultGlob = (0, run_scripts_js_1.scriptGlob)(kind === "test" ? "harness" : "eval");
|
|
5422
5461
|
// The eval LOCK flags (`--check`/`--update`) are resolved BEFORE file discovery
|
|
5423
|
-
// so mutual-exclusion
|
|
5424
|
-
// count. Returns the env to thread to scripts, or `"skip"` to exit green now.
|
|
5462
|
+
// so mutual-exclusion (exit 2) is honored regardless of file count.
|
|
5425
5463
|
let lockEnv = {};
|
|
5426
5464
|
if (kind === "eval") {
|
|
5427
|
-
|
|
5428
|
-
if (r === "skip")
|
|
5429
|
-
return;
|
|
5430
|
-
lockEnv = r;
|
|
5465
|
+
lockEnv = resolveEvalLockEnv(args);
|
|
5431
5466
|
}
|
|
5432
5467
|
// A script under an excluded path is not DISCOVERED (a vendored corpus's own
|
|
5433
5468
|
// harness must not run as ours), but a script you NAME still runs (#192).
|
|
@@ -5449,44 +5484,33 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
|
|
|
5449
5484
|
process.exit(1);
|
|
5450
5485
|
}
|
|
5451
5486
|
if (files.length === 0) {
|
|
5452
|
-
// 🔴
|
|
5453
|
-
//
|
|
5454
|
-
//
|
|
5487
|
+
// 🔴 A RUN THAT MATCHES NO FILE IS A FAILURE, NAMED OR NOT.
|
|
5488
|
+
// An empty match used to be one silent exit 0, and it cost a real repository three
|
|
5489
|
+
// days of green CI verifying zero files: a named step ran
|
|
5455
5490
|
// `vigiles test .claude/pipeline/skills.harness.mjs` after that file had been split
|
|
5456
5491
|
// into one-per-skill, printed "No **/*.harness.* files found" and passed, right next
|
|
5457
|
-
// to a step that was red for the same root cause.
|
|
5458
|
-
//
|
|
5459
|
-
//
|
|
5460
|
-
//
|
|
5461
|
-
// reached its target, and every one of those is a defect. Bare discovery finding
|
|
5462
|
-
// nothing is just an empty repository, which is a legitimate place to stand and must
|
|
5463
|
-
// stay quiet.
|
|
5492
|
+
// to a step that was red for the same root cause. The first repair failed only the
|
|
5493
|
+
// NAMED case and kept bare discovery quiet ("an empty repository is a legitimate
|
|
5494
|
+
// place to stand") — and bare `vigiles test` is the line `init` writes into CI, so
|
|
5495
|
+
// the same green no-op was one rename away from the default setup (#197).
|
|
5464
5496
|
//
|
|
5465
|
-
// This is the default the field settled on: Jest and Vitest FAIL on no tests found
|
|
5466
|
-
// make you opt in with `--passWithNoTests`; pytest exits 5.
|
|
5467
|
-
//
|
|
5468
|
-
// `
|
|
5469
|
-
//
|
|
5470
|
-
// the
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
// the generic message above would now be true but unhelpful.
|
|
5476
|
-
const dirs = restArgs.filter((a) => (0, node_fs_1.lstatSync)(a, { throwIfNoEntry: false })?.isDirectory() === true);
|
|
5477
|
-
console.error(dirs.length > 0
|
|
5478
|
-
? `✗ vigiles ${kind}: ${dirs.join(", ")} ${dirs.length === 1 ? "is a directory" : "are directories"} — ` +
|
|
5479
|
-
`pass a file, or a glob like "${defaultGlob}".\n` +
|
|
5480
|
-
` A directory is not a script; nothing ran.`
|
|
5481
|
-
: `✗ vigiles ${kind}: ${String(restArgs.length)} target(s) given and NOTHING matched — ` +
|
|
5482
|
-
`${restArgs.join(", ")}\n` +
|
|
5483
|
-
` Nothing ran. A stale path, a wrong glob, or a moved file all look like this.\n` +
|
|
5484
|
-
` If an empty match is expected here, say so with --min=0.`);
|
|
5497
|
+
// This is the default the field settled on: Jest and Vitest FAIL on no tests found
|
|
5498
|
+
// and make you opt in with `--passWithNoTests`; pytest exits 5. Here there is no
|
|
5499
|
+
// such flag (a person who wants an empty run does not call the command), and
|
|
5500
|
+
// `--min=0` — the existing floor, spelled as zero — is the only way to say an empty
|
|
5501
|
+
// match is expected. `minFlag`, not `minRequired`: 0 is both the DEFAULT and that
|
|
5502
|
+
// explicit opt-out, so the VALUE cannot tell them apart — only the flag's presence
|
|
5503
|
+
// can. (Caught by a control: the first version read `minRequired === 0` and made
|
|
5504
|
+
// `--min=0` do nothing.)
|
|
5505
|
+
if (minFlag === undefined) {
|
|
5506
|
+
console.error(emptyMatchMessage(kind, restArgs, defaultGlob));
|
|
5485
5507
|
process.exit(1);
|
|
5486
5508
|
}
|
|
5487
5509
|
console.log(`No ${defaultGlob} files found.`);
|
|
5488
5510
|
return;
|
|
5489
5511
|
}
|
|
5512
|
+
if (kind === "eval")
|
|
5513
|
+
refuseEvalCheckWithoutLocks(args, files.length);
|
|
5490
5514
|
// Consent gate for a bare `vigiles eval`: it runs the REAL model on your
|
|
5491
5515
|
// subscription, and a no-target run discovered the whole tree — so never fan out
|
|
5492
5516
|
// over an unbounded glob without explicit intent. Mirrors audit's read-vs-run
|
|
@@ -107,8 +107,11 @@ export interface HarnessDriverContext {
|
|
|
107
107
|
readonly cwd: string;
|
|
108
108
|
/** Whether a settings file was written (CC: pass `--settings`). */
|
|
109
109
|
readonly hasSettings: boolean;
|
|
110
|
-
/**
|
|
111
|
-
|
|
110
|
+
/**
|
|
111
|
+
* The ONLY tools the agent has, when the spec lists them; undefined = no
|
|
112
|
+
* restriction (every tool is offered, the default four are pre-approved).
|
|
113
|
+
*/
|
|
114
|
+
readonly tools?: readonly string[];
|
|
112
115
|
/** Capture the full event transcript instead of just the final result. */
|
|
113
116
|
readonly transcript: boolean;
|
|
114
117
|
/** Path to a plugin dir to install natively, if any. */
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running past the end of a scripted model is an error.
|
|
3
|
+
*
|
|
4
|
+
* A harness test scripts the turns the agent should take, then asserts on what
|
|
5
|
+
* happened. When the agent asks for more turns than the script has, the mock
|
|
6
|
+
* used to answer with a copy of the LAST turn: a looping agent (a Stop hook
|
|
7
|
+
* that keeps blocking, a retry, a reaction to a tool result) then ran on turns
|
|
8
|
+
* nobody scripted, a final `{ tool }` re-ran its side effect, and the test went
|
|
9
|
+
* green over a run it never described (#340). Pure and shared by both mocks
|
|
10
|
+
* (Anthropic Messages, OpenAI Responses) and the runner that fails the test.
|
|
11
|
+
*/
|
|
12
|
+
import type { ModelRequest } from "./harness-driver.js";
|
|
13
|
+
/** The first request the script could not answer. */
|
|
14
|
+
export interface ScriptOverrun {
|
|
15
|
+
/** How many turns the script had. */
|
|
16
|
+
readonly scripted: number;
|
|
17
|
+
/** 1-based number of the agent request that had no turn left. */
|
|
18
|
+
readonly request: number;
|
|
19
|
+
/** The last message that request carried (what the agent was reacting to). */
|
|
20
|
+
readonly lastMessage: string;
|
|
21
|
+
}
|
|
22
|
+
/** Why a test failed because the agent outran its script. */
|
|
23
|
+
export declare function scriptOverrunMessage(o: ScriptOverrun): string;
|
|
24
|
+
/**
|
|
25
|
+
* The first agent request past the end of a script of `scripted` turns, read
|
|
26
|
+
* off the recorded requests (side-channel calls never consume a turn, so they
|
|
27
|
+
* do not count). Undefined when the agent stayed inside the script.
|
|
28
|
+
*/
|
|
29
|
+
export declare function findScriptOverrun(scripted: number, requests: readonly ModelRequest[]): ScriptOverrun | undefined;
|
|
30
|
+
/** {@link findScriptOverrun} and {@link scriptOverrunMessage} in one: the message, or undefined. */
|
|
31
|
+
export declare function overrunMessageFor(scripted: number, requests: readonly ModelRequest[]): string | undefined;
|
|
32
|
+
//# sourceMappingURL=script-overrun.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scriptOverrunMessage = scriptOverrunMessage;
|
|
4
|
+
exports.findScriptOverrun = findScriptOverrun;
|
|
5
|
+
exports.overrunMessageFor = overrunMessageFor;
|
|
6
|
+
const MAX_QUOTED = 160;
|
|
7
|
+
const quote = (text) => {
|
|
8
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
9
|
+
return flat.length > MAX_QUOTED ? `${flat.slice(0, MAX_QUOTED)}...` : flat;
|
|
10
|
+
};
|
|
11
|
+
/** Why a test failed because the agent outran its script. */
|
|
12
|
+
function scriptOverrunMessage(o) {
|
|
13
|
+
const last = o.lastMessage.trim() === ""
|
|
14
|
+
? ""
|
|
15
|
+
: ` The request carried this last message: "${quote(o.lastMessage)}".`;
|
|
16
|
+
return (`the agent asked for more model turns than the script has: ${String(o.scripted)} turn(s) scripted, ` +
|
|
17
|
+
`and agent request #${String(o.request)} had none left.${last} ` +
|
|
18
|
+
"The mock does not repeat the last turn, because a looping agent would then pass on turns nobody scripted. " +
|
|
19
|
+
"Script every turn the agent should take.");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The first agent request past the end of a script of `scripted` turns, read
|
|
23
|
+
* off the recorded requests (side-channel calls never consume a turn, so they
|
|
24
|
+
* do not count). Undefined when the agent stayed inside the script.
|
|
25
|
+
*/
|
|
26
|
+
function findScriptOverrun(scripted, requests) {
|
|
27
|
+
const agent = requests.filter((r) => r.sideChannel !== true);
|
|
28
|
+
const first = agent[scripted];
|
|
29
|
+
if (first === undefined)
|
|
30
|
+
return undefined;
|
|
31
|
+
return {
|
|
32
|
+
scripted,
|
|
33
|
+
request: scripted + 1,
|
|
34
|
+
lastMessage: first.messages.at(-1)?.text ?? "",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** {@link findScriptOverrun} and {@link scriptOverrunMessage} in one: the message, or undefined. */
|
|
38
|
+
function overrunMessageFor(scripted, requests) {
|
|
39
|
+
const overrun = findScriptOverrun(scripted, requests);
|
|
40
|
+
return overrun === undefined ? undefined : scriptOverrunMessage(overrun);
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=script-overrun.js.map
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* anywhere in the prose counts, because a name like `test:harness` cannot
|
|
21
21
|
* collide with an English word the way `test` and `audit` do. The only thing the
|
|
22
22
|
* lookarounds buy is that a LONGER script name is never read as a shorter one
|
|
23
|
-
* (`test:
|
|
23
|
+
* (`test:unit-extra` does not document `test:unit`).
|
|
24
24
|
*
|
|
25
25
|
* Scope is the `test:` PREFIX, deliberately. Bare `npm test` is the vitest
|
|
26
26
|
* suite, named in the map as prose; every other script (`build`, `lint`,
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* anywhere in the prose counts, because a name like `test:harness` cannot
|
|
22
22
|
* collide with an English word the way `test` and `audit` do. The only thing the
|
|
23
23
|
* lookarounds buy is that a LONGER script name is never read as a shorter one
|
|
24
|
-
* (`test:
|
|
24
|
+
* (`test:unit-extra` does not document `test:unit`).
|
|
25
25
|
*
|
|
26
26
|
* Scope is the `test:` PREFIX, deliberately. Bare `npm test` is the vitest
|
|
27
27
|
* suite, named in the map as prose; every other script (`build`, `lint`,
|
package/dist/eval-lock.d.ts
CHANGED
|
@@ -116,12 +116,11 @@ export declare function lockPath(dir: string, name: string): string;
|
|
|
116
116
|
*/
|
|
117
117
|
export declare function readLock(dir: string, name: string): EvalLock | null;
|
|
118
118
|
/**
|
|
119
|
-
* Whether ANY lock has been committed under `dir`.
|
|
120
|
-
* (
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* opt-in-by-committing behavior that keeps a fresh `init` from going red.
|
|
119
|
+
* Whether ANY lock has been committed under `dir`. `eval --check` fails when none
|
|
120
|
+
* is (#197): with no lock there is no recorded result to verify the current inputs
|
|
121
|
+
* against, so a pass would mean nothing was checked. Once the first lock is
|
|
122
|
+
* committed, every named eval is held to having a fresh one (a new unlocked eval
|
|
123
|
+
* then reads as stale). The edit-time nudge also stays silent until then.
|
|
125
124
|
*/
|
|
126
125
|
export declare function anyLocksCommitted(dir: string): boolean;
|
|
127
126
|
/**
|
package/dist/eval-lock.js
CHANGED
|
@@ -130,12 +130,11 @@ function readLock(dir, name) {
|
|
|
130
130
|
return obj;
|
|
131
131
|
}
|
|
132
132
|
/**
|
|
133
|
-
* Whether ANY lock has been committed under `dir`.
|
|
134
|
-
* (
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* opt-in-by-committing behavior that keeps a fresh `init` from going red.
|
|
133
|
+
* Whether ANY lock has been committed under `dir`. `eval --check` fails when none
|
|
134
|
+
* is (#197): with no lock there is no recorded result to verify the current inputs
|
|
135
|
+
* against, so a pass would mean nothing was checked. Once the first lock is
|
|
136
|
+
* committed, every named eval is held to having a fresh one (a new unlocked eval
|
|
137
|
+
* then reads as stale). The edit-time nudge also stays silent until then.
|
|
139
138
|
*/
|
|
140
139
|
function anyLocksCommitted(dir) {
|
|
141
140
|
return countLocks(dir) > 0;
|
package/dist/harness-test.d.ts
CHANGED
|
@@ -52,7 +52,18 @@ export interface HarnessTestSpec {
|
|
|
52
52
|
readonly model: readonly ModelTurn[];
|
|
53
53
|
/** The user prompt. Default: "go". */
|
|
54
54
|
readonly prompt?: string;
|
|
55
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* The ONLY tools the agent has, when you list them: a tool left out is not
|
|
57
|
+
* offered (`claude --tools`, Claude Code 2.0.31+), `[]` is refused, and a
|
|
58
|
+
* scripted call to a tool the list leaves out throws before the run starts.
|
|
59
|
+
* Omitted, nothing is withheld: every tool is offered and Read, Edit, Write and
|
|
60
|
+
* Bash are pre-approved. Each listed tool is also pre-approved
|
|
61
|
+
* (`--allowedTools`), so it does not stop on a permission prompt; a permission
|
|
62
|
+
* rule such as `Bash(git *)` keeps its specifier for the approval and is
|
|
63
|
+
* offered as plain `Bash`. An MCP tool is not a built-in: left out, it is still
|
|
64
|
+
* offered but not approved, so a call to it is refused for permission rather
|
|
65
|
+
* than as "No such tool".
|
|
66
|
+
*/
|
|
56
67
|
readonly allowedTools?: readonly string[];
|
|
57
68
|
/**
|
|
58
69
|
* Capture the full event transcript (`--output-format stream-json`) into
|
|
@@ -119,6 +130,27 @@ export declare function parseResultEvent(stdout: string): Record<string, unknown
|
|
|
119
130
|
/** The agent's final answer text from a transcript / result object, or "". */
|
|
120
131
|
export declare function parseOutput(stdout: string): string;
|
|
121
132
|
export declare function parseHooks(stdout: string): HookFire[];
|
|
133
|
+
/**
|
|
134
|
+
* The names `--tools` should offer for an `allowedTools` list. `--allowedTools`
|
|
135
|
+
* takes permission rules (`Bash(git *)`); `--tools` takes tool NAMES, and a rule
|
|
136
|
+
* with a specifier there makes the CLI offer no tools at all. So the specifier is
|
|
137
|
+
* dropped (`Bash(git *)` → `Bash`) and repeats collapse. Pure.
|
|
138
|
+
*/
|
|
139
|
+
export declare function toolAvailabilityList(allowed: readonly string[]): readonly string[];
|
|
140
|
+
/**
|
|
141
|
+
* A scripted call to a tool an EXPLICIT `allowedTools` does not offer. The CLI
|
|
142
|
+
* answers it with "No such tool available" and the run goes on, so a test that
|
|
143
|
+
* scripts the call and then asserts on something else passes over a step that
|
|
144
|
+
* never happened. Returns the message to throw, or undefined. MCP tools are not
|
|
145
|
+
* built-ins, so `--tools` does not withhold them and they are skipped. Pure.
|
|
146
|
+
*/
|
|
147
|
+
export declare function unofferedScriptedTool(model: readonly ModelTurn[], allowed: readonly string[] | undefined): string | undefined;
|
|
148
|
+
/**
|
|
149
|
+
* A run on a `claude` that predates `--tools` exits at argument parsing, and
|
|
150
|
+
* commander's "unknown option" says nothing about WHY vigiles passed it. Returns
|
|
151
|
+
* the message to throw, or undefined when the run did not die that way. Pure.
|
|
152
|
+
*/
|
|
153
|
+
export declare function unsupportedToolsFlag(stderr: string): string | undefined;
|
|
122
154
|
/**
|
|
123
155
|
* The `claude` CLI argv for a harness run (shared by the direct and sandboxed
|
|
124
156
|
* paths). `ANTHROPIC_BASE_URL` is set by the caller's environment / wrapper, not
|
|
@@ -170,6 +202,8 @@ export interface RunHarnessTestOptions {
|
|
|
170
202
|
*/
|
|
171
203
|
readonly adapter?: HarnessAdapter;
|
|
172
204
|
}
|
|
205
|
+
/** Say so on stderr when side-channel calls arrived and no script turn was served. */
|
|
206
|
+
export declare function warnUnconsumed(count: number, sideChannelCount: number): void;
|
|
173
207
|
/**
|
|
174
208
|
* Run the real agent CLI against a scripted mock model, with the given fixture
|
|
175
209
|
* and settings (hooks). Deterministic — same script, same result. Adapter-driven
|
package/dist/harness-test.js
CHANGED
|
@@ -6,10 +6,14 @@ exports.parseSubagents = parseSubagents;
|
|
|
6
6
|
exports.parseResultEvent = parseResultEvent;
|
|
7
7
|
exports.parseOutput = parseOutput;
|
|
8
8
|
exports.parseHooks = parseHooks;
|
|
9
|
+
exports.toolAvailabilityList = toolAvailabilityList;
|
|
10
|
+
exports.unofferedScriptedTool = unofferedScriptedTool;
|
|
11
|
+
exports.unsupportedToolsFlag = unsupportedToolsFlag;
|
|
9
12
|
exports.buildClaudeArgs = buildClaudeArgs;
|
|
10
13
|
exports.parseClaudeRun = parseClaudeRun;
|
|
11
14
|
exports.harnessSpawnEnv = harnessSpawnEnv;
|
|
12
15
|
exports.claudeAvailable = claudeAvailable;
|
|
16
|
+
exports.warnUnconsumed = warnUnconsumed;
|
|
13
17
|
exports.runHarnessTest = runHarnessTest;
|
|
14
18
|
exports.runHarnessTestIn = runHarnessTestIn;
|
|
15
19
|
exports.runHarness = runHarness;
|
|
@@ -49,6 +53,7 @@ const node_fs_1 = require("node:fs");
|
|
|
49
53
|
const node_path_1 = require("node:path");
|
|
50
54
|
const adapter_conformance_js_1 = require("./adapter-conformance.js");
|
|
51
55
|
const check_count_js_1 = require("./check-count.js");
|
|
56
|
+
const script_overrun_js_1 = require("./core/script-overrun.js");
|
|
52
57
|
const coverage_probe_js_1 = require("./coverage-probe.js");
|
|
53
58
|
const runtime_js_1 = require("./adapters/claude-code/runtime.js");
|
|
54
59
|
const mock_model_js_1 = require("./mock-model.js");
|
|
@@ -274,6 +279,51 @@ function parseHooks(stdout) {
|
|
|
274
279
|
}
|
|
275
280
|
return hooks;
|
|
276
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* The first `claude` that has `--tools` (print mode). Found by diffing the
|
|
284
|
+
* published `@anthropic-ai/claude-code` tarballs: 2.0.30 has no such option,
|
|
285
|
+
* 2.0.31 does (the changelog does not list it; its 2.1.0 entry only extends the
|
|
286
|
+
* flag to interactive mode).
|
|
287
|
+
*/
|
|
288
|
+
const TOOLS_FLAG_MIN_CLAUDE = "2.0.31";
|
|
289
|
+
/**
|
|
290
|
+
* The names `--tools` should offer for an `allowedTools` list. `--allowedTools`
|
|
291
|
+
* takes permission rules (`Bash(git *)`); `--tools` takes tool NAMES, and a rule
|
|
292
|
+
* with a specifier there makes the CLI offer no tools at all. So the specifier is
|
|
293
|
+
* dropped (`Bash(git *)` → `Bash`) and repeats collapse. Pure.
|
|
294
|
+
*/
|
|
295
|
+
function toolAvailabilityList(allowed) {
|
|
296
|
+
const names = allowed.map((rule) => rule.replace(/\(.*$/, "").trim());
|
|
297
|
+
return [...new Set(names.filter((n) => n !== ""))];
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* A scripted call to a tool an EXPLICIT `allowedTools` does not offer. The CLI
|
|
301
|
+
* answers it with "No such tool available" and the run goes on, so a test that
|
|
302
|
+
* scripts the call and then asserts on something else passes over a step that
|
|
303
|
+
* never happened. Returns the message to throw, or undefined. MCP tools are not
|
|
304
|
+
* built-ins, so `--tools` does not withhold them and they are skipped. Pure.
|
|
305
|
+
*/
|
|
306
|
+
function unofferedScriptedTool(model, allowed) {
|
|
307
|
+
if (allowed === undefined)
|
|
308
|
+
return undefined;
|
|
309
|
+
const offered = toolAvailabilityList(allowed);
|
|
310
|
+
const at = model.findIndex((t) => t.tool !== undefined &&
|
|
311
|
+
!t.tool.startsWith("mcp__") &&
|
|
312
|
+
!offered.includes(t.tool));
|
|
313
|
+
return at < 0
|
|
314
|
+
? undefined
|
|
315
|
+
: `scripted call to "${model[at]?.tool ?? ""}" on turn ${String(at + 1)}, but this run offers only: ${offered.join(", ")} — add it to allowedTools`;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* A run on a `claude` that predates `--tools` exits at argument parsing, and
|
|
319
|
+
* commander's "unknown option" says nothing about WHY vigiles passed it. Returns
|
|
320
|
+
* the message to throw, or undefined when the run did not die that way. Pure.
|
|
321
|
+
*/
|
|
322
|
+
function unsupportedToolsFlag(stderr) {
|
|
323
|
+
return /unknown option '--tools'/.test(stderr)
|
|
324
|
+
? `this \`claude\` does not know \`--tools\`, which runHarnessTest uses to withhold every tool not in \`allowedTools\` (#252). Update Claude Code to ${TOOLS_FLAG_MIN_CLAUDE} or newer. Running without it would hand the agent every tool while the test reads as if it were fenced.`
|
|
325
|
+
: undefined;
|
|
326
|
+
}
|
|
277
327
|
/**
|
|
278
328
|
* The `claude` CLI argv for a harness run (shared by the direct and sandboxed
|
|
279
329
|
* paths). `ANTHROPIC_BASE_URL` is set by the caller's environment / wrapper, not
|
|
@@ -293,8 +343,17 @@ function buildClaudeArgs(spec, hasSettings) {
|
|
|
293
343
|
? ["--plugin-dir", (0, node_path_1.resolve)(spec.pluginDir)]
|
|
294
344
|
: []),
|
|
295
345
|
...(hasSettings ? ["--settings", "settings.json"] : []),
|
|
296
|
-
//
|
|
297
|
-
//
|
|
346
|
+
// An EXPLICIT list restricts: `--tools` is the AVAILABILITY list, a tool left
|
|
347
|
+
// out is not offered at all. `--allowedTools` only PRE-APPROVES (it never
|
|
348
|
+
// restricts), so on its own it left every unnamed tool runnable (#252). An
|
|
349
|
+
// empty list is `--tools ""` = no tools. With no list, nothing is withheld:
|
|
350
|
+
// the default four are pre-approved and every other tool stays available.
|
|
351
|
+
...(spec.allowedTools === undefined
|
|
352
|
+
? []
|
|
353
|
+
: ["--tools", toolAvailabilityList(spec.allowedTools).join(",")]),
|
|
354
|
+
// Pre-approval for the offered tools, so none stops on a permission prompt
|
|
355
|
+
// (headless). An empty one approves nothing, so the flag is left out (a bare
|
|
356
|
+
// `--allowedTools` exits before any model turn).
|
|
298
357
|
...(tools.length === 0 ? [] : ["--allowedTools", ...tools]),
|
|
299
358
|
];
|
|
300
359
|
}
|
|
@@ -415,6 +474,26 @@ exports.claudeCodeDriver = {
|
|
|
415
474
|
parseRun: parseClaudeRun,
|
|
416
475
|
available: claudeAvailable,
|
|
417
476
|
};
|
|
477
|
+
/** Say so on stderr when side-channel calls arrived and no script turn was served. */
|
|
478
|
+
function warnUnconsumed(count, sideChannelCount) {
|
|
479
|
+
const unconsumed = (0, mock_model_js_1.scriptUnconsumedWarning)(count, sideChannelCount);
|
|
480
|
+
if (unconsumed !== undefined)
|
|
481
|
+
console.error(unconsumed);
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* A run that proves nothing about the script fails the test instead of coming
|
|
485
|
+
* back as a result: the `claude` on PATH predates `--tools` (#252), or the agent
|
|
486
|
+
* asked for a model turn the script did not have and the mock answered with an
|
|
487
|
+
* error rather than an invented turn (#340). Removes the run's directory, since
|
|
488
|
+
* no result is handed back to clean it up.
|
|
489
|
+
*/
|
|
490
|
+
function assertRunSound(cwd, stderr, scripted, modelRequests) {
|
|
491
|
+
const problem = unsupportedToolsFlag(stderr) ?? (0, script_overrun_js_1.overrunMessageFor)(scripted, modelRequests);
|
|
492
|
+
if (problem === undefined)
|
|
493
|
+
return;
|
|
494
|
+
(0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
|
|
495
|
+
throw new Error(problem);
|
|
496
|
+
}
|
|
418
497
|
/* v8 ignore start -- spawns the real agent CLI + filesystem; exercised by the
|
|
419
498
|
claude-backed + gated codex suites, excluded from the deterministic coverage
|
|
420
499
|
gate (the parse helpers above carry the testable logic). */
|
|
@@ -457,8 +536,22 @@ function makeResult(cwd, out, parsed, turns, modelRequests) {
|
|
|
457
536
|
* only — requesting confinement for another harness throws.
|
|
458
537
|
*/
|
|
459
538
|
async function runHarnessTest(spec, opts = {}) {
|
|
539
|
+
refuseUnrunnableSpec(spec);
|
|
460
540
|
return runHarnessTestIn(spec, opts, "inherit");
|
|
461
541
|
}
|
|
542
|
+
/**
|
|
543
|
+
* The scripted model tells an agent turn from the CLI's own bookkeeping calls by
|
|
544
|
+
* the tools the request declares (`isMainLoopRequest`). An agent with none is
|
|
545
|
+
* never served a script turn, and the run would decide on nothing.
|
|
546
|
+
*/
|
|
547
|
+
function refuseUnrunnableSpec(spec) {
|
|
548
|
+
if (spec.allowedTools?.length === 0) {
|
|
549
|
+
throw new Error("allowedTools: [] leaves the agent with no tools, and the scripted model only serves a turn to a request that declares tools — no script turn would be consumed. Name the tools the agent has.");
|
|
550
|
+
}
|
|
551
|
+
const unoffered = unofferedScriptedTool(spec.model, spec.allowedTools);
|
|
552
|
+
if (unoffered !== undefined)
|
|
553
|
+
throw new Error(unoffered);
|
|
554
|
+
}
|
|
462
555
|
/**
|
|
463
556
|
* {@link runHarnessTest} with the run's HOME chosen by the caller — see
|
|
464
557
|
* {@link HarnessRunHome}. Not on a public entry point: the one caller that needs
|
|
@@ -491,13 +584,12 @@ async function runHarnessTestIn(spec, opts, home) {
|
|
|
491
584
|
const { files, settings, check } = fixtureFor(spec, opts.adapter);
|
|
492
585
|
const cwd = (0, tmp_root_js_1.makeTmpDir)("harness");
|
|
493
586
|
writeFixture(cwd, files, settings);
|
|
494
|
-
const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"];
|
|
495
587
|
const timeoutMs = spec.timeoutMs ?? 60000;
|
|
496
588
|
const buildArgs = (mockArgs) => driver.buildArgs({
|
|
497
589
|
prompt: spec.prompt ?? "go",
|
|
498
590
|
cwd,
|
|
499
591
|
hasSettings: settings !== undefined,
|
|
500
|
-
tools,
|
|
592
|
+
tools: spec.allowedTools,
|
|
501
593
|
transcript: spec.transcript ?? false,
|
|
502
594
|
pluginDir: spec.pluginDir,
|
|
503
595
|
mockArgs,
|
|
@@ -519,9 +611,8 @@ async function runHarnessTestIn(spec, opts, home) {
|
|
|
519
611
|
// the direct one stays right. Same recovery feeds the unconsumed-script
|
|
520
612
|
// warning, which the sandbox path could not emit at all before.
|
|
521
613
|
const { count, sideChannelCount } = (0, mock_model_js_1.splitRequestCounts)(out.requests);
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
console.error(unconsumed);
|
|
614
|
+
warnUnconsumed(count, sideChannelCount);
|
|
615
|
+
assertRunSound(cwd, "", spec.model.length, out.requests);
|
|
525
616
|
return check(makeResult(cwd, out, parseClaudeRun(out.stdout), count, out.requests));
|
|
526
617
|
}
|
|
527
618
|
// Direct path: mock runs in this process; the agent reaches it over localhost.
|
|
@@ -539,9 +630,8 @@ async function runHarnessTestIn(spec, opts, home) {
|
|
|
539
630
|
// looks empty. `scriptUnconsumedWarning` names the one shape that produces
|
|
540
631
|
// it (every request arriving without tool declarations, so nothing looked
|
|
541
632
|
// like an agent turn) instead of leaving it to be rediscovered.
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
console.error(unconsumed);
|
|
633
|
+
warnUnconsumed(mock.count, mock.sideChannelCount ?? 0);
|
|
634
|
+
assertRunSound(cwd, out.stderr, spec.model.length, mock.requests);
|
|
545
635
|
return check(makeResult(cwd, out, driver.parseRun(out.stdout), mock.count, [
|
|
546
636
|
...mock.requests,
|
|
547
637
|
]));
|
package/dist/mock-model.d.ts
CHANGED
|
@@ -78,7 +78,10 @@ export declare function extractRequest(body: {
|
|
|
78
78
|
}): ModelRequest;
|
|
79
79
|
/**
|
|
80
80
|
* Start the scripted mock on a free port. Each MAIN-LOOP `/v1/messages` POST
|
|
81
|
-
* consumes the next turn
|
|
81
|
+
* consumes the next turn; one past the end of the script is answered with a
|
|
82
|
+
* non-retryable API error, never a copy of the last turn (#340 — a looping
|
|
83
|
+
* agent would pass on turns nobody scripted), and `runHarnessTest` then fails
|
|
84
|
+
* the test from the recorded requests ({@link findScriptOverrun});
|
|
82
85
|
* a SIDE-CHANNEL POST — the CLI's own bookkeeping calls, see
|
|
83
86
|
* {@link isMainLoopRequest} — is answered from outside the script and consumes
|
|
84
87
|
* nothing. Resolves to a handle with the base `url` and a `close()`.
|
package/dist/mock-model.js
CHANGED
|
@@ -28,6 +28,7 @@ exports.startMock = startMock;
|
|
|
28
28
|
* and echoing the requested model.
|
|
29
29
|
*/
|
|
30
30
|
const node_http_1 = __importDefault(require("node:http"));
|
|
31
|
+
const script_overrun_js_1 = require("./core/script-overrun.js");
|
|
31
32
|
/** Build a scripted model from an ordered list of turns. */
|
|
32
33
|
function scriptModel(turns) {
|
|
33
34
|
return [...turns];
|
|
@@ -388,9 +389,41 @@ function extractRequest(body) {
|
|
|
388
389
|
: [];
|
|
389
390
|
return { system: flattenContent(body.system), messages };
|
|
390
391
|
}
|
|
392
|
+
/**
|
|
393
|
+
* The 400 body for an agent request past the end of the script: an API error,
|
|
394
|
+
* because the CLI retries 429/5xx and an invented turn would let the agent act on
|
|
395
|
+
* a step nobody scripted (#340). Pure.
|
|
396
|
+
*/
|
|
397
|
+
function pastScriptBody(scripted, request) {
|
|
398
|
+
const message = (0, script_overrun_js_1.scriptOverrunMessage)({
|
|
399
|
+
scripted,
|
|
400
|
+
request: scripted + 1,
|
|
401
|
+
lastMessage: request.messages.at(-1)?.text ?? "",
|
|
402
|
+
});
|
|
403
|
+
return JSON.stringify({
|
|
404
|
+
type: "error",
|
|
405
|
+
error: { type: "invalid_request_error", message },
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
/** Does a message's content carry a tool result (the agent reacting to a tool)? */
|
|
409
|
+
function endsWithToolResult(content) {
|
|
410
|
+
return JSON.stringify(content ?? "").includes('"tool_result"');
|
|
411
|
+
}
|
|
412
|
+
/** The JSON body of a request, `{}` for the HEAD / health checks that have none. */
|
|
413
|
+
function parseReqBody(body) {
|
|
414
|
+
try {
|
|
415
|
+
return JSON.parse(body);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return {};
|
|
419
|
+
}
|
|
420
|
+
}
|
|
391
421
|
/**
|
|
392
422
|
* Start the scripted mock on a free port. Each MAIN-LOOP `/v1/messages` POST
|
|
393
|
-
* consumes the next turn
|
|
423
|
+
* consumes the next turn; one past the end of the script is answered with a
|
|
424
|
+
* non-retryable API error, never a copy of the last turn (#340 — a looping
|
|
425
|
+
* agent would pass on turns nobody scripted), and `runHarnessTest` then fails
|
|
426
|
+
* the test from the recorded requests ({@link findScriptOverrun});
|
|
394
427
|
* a SIDE-CHANNEL POST — the CLI's own bookkeeping calls, see
|
|
395
428
|
* {@link isMainLoopRequest} — is answered from outside the script and consumes
|
|
396
429
|
* nothing. Resolves to a handle with the base `url` and a `close()`.
|
|
@@ -406,13 +439,7 @@ function startMock(script, opts = {}) {
|
|
|
406
439
|
const url = req.url ?? "";
|
|
407
440
|
const isCount = url.includes("count_tokens");
|
|
408
441
|
const isMessages = url.includes("/v1/messages") && !isCount;
|
|
409
|
-
|
|
410
|
-
try {
|
|
411
|
-
reqBody = JSON.parse(body);
|
|
412
|
-
}
|
|
413
|
-
catch {
|
|
414
|
-
/* HEAD / health checks have no JSON body */
|
|
415
|
-
}
|
|
442
|
+
const reqBody = parseReqBody(body);
|
|
416
443
|
if (req.method === "HEAD" || (!isMessages && !isCount)) {
|
|
417
444
|
res.writeHead(200, { "content-type": "application/json" });
|
|
418
445
|
res.end("{}");
|
|
@@ -445,13 +472,17 @@ function startMock(script, opts = {}) {
|
|
|
445
472
|
jsonTurn(res, reply, model);
|
|
446
473
|
return;
|
|
447
474
|
}
|
|
448
|
-
const
|
|
475
|
+
const turn = script[i];
|
|
476
|
+
if (turn === undefined) {
|
|
477
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
478
|
+
res.end(pastScriptBody(script.length, request));
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
449
481
|
opts.onTurn?.({
|
|
450
482
|
n: i,
|
|
451
483
|
stream: reqBody.stream === true,
|
|
452
|
-
hasToolResult:
|
|
484
|
+
hasToolResult: endsWithToolResult(reqBody.messages?.at(-1)?.content),
|
|
453
485
|
});
|
|
454
|
-
const turn = script[Math.min(i, script.length - 1)] ?? { text: "" };
|
|
455
486
|
i++;
|
|
456
487
|
if (reqBody.stream === true)
|
|
457
488
|
streamTurn(res, turn, model);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "35.0.0",
|
|
4
4
|
"description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -83,7 +83,6 @@
|
|
|
83
83
|
"test:unit": "npm run build && vitest run --project unit",
|
|
84
84
|
"test:integration": "npm run build && vitest run --project integration",
|
|
85
85
|
"test:e2e": "npm run build && vitest run --project e2e",
|
|
86
|
-
"test:cli-e2e": "bash test/e2e/run.sh",
|
|
87
86
|
"test:harness": "npm run build && node dist/cli.js test",
|
|
88
87
|
"test:eval": "npm run build && node dist/cli.js eval --all",
|
|
89
88
|
"test:vitest": "npm run build && vitest run --project runners",
|