Enforces the five-positive standard this PR established rather than only describing it. `validateEvalCases` checked `positives === 0`, so a skill could regress to a single prompt and still pass the gate. At RUNS=5 one case is 20 points of that skill's reading, so a one- or two-case skill reports a rate a single flake can move across the threshold — well-formed to the checker, meaningless as a measurement. Zero positives keeps its own clearer message so the common mistake keeps its diagnosis. Scopes the eval requirement to model-invoked skills in the checklist. A name-only skill has no eval file because nothing can measure a trigger only a human types, so an eval there is unrunnable rather than redundant. Aligns checklist item 1 with the `#` hazard from round 1; it named only the unquoted colon, which is the louder of the two failure modes. Four test fixtures asserted on one-positive eval files and legitimately went red; they now build positives from the exported MIN_POSITIVE_CASES. The all-positive row keeps testing the missing-negative path, with a new row for the below-floor case. 388/388 script tests pass. No skill description or eval case changed, so trigger behaviour is unaffected and skills:eval is unchanged from the previous round. Signed-off-by: cliffhall <cliff@futurescale.com>
394 lines
14 KiB
JavaScript
394 lines
14 KiB
JavaScript
// Tests for the pure halves of the skill trigger eval (#2163).
|
|
//
|
|
// Everything else in `skill-eval.mjs` spawns a real `claude` CLI, so its stream
|
|
// parsing and its process-outcome handling are unreachable from an eval run's
|
|
// happy path — they would only ever be exercised by the very measurement they
|
|
// are supposed to make trustworthy. A nonzero exit read as "no skill invoked"
|
|
// (the bug these cases pin) passes every negative case and reads as a trigger
|
|
// miss on every positive one, so a rate-limited run reports a plausible number
|
|
// instead of failing.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { EventEmitter } from "node:events";
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
collectCases,
|
|
collectSkillInvocations,
|
|
runRejection,
|
|
invokedSkillNames,
|
|
runPrompt,
|
|
sampleHit,
|
|
} from "./skill-eval.mjs";
|
|
import { MIN_POSITIVE_CASES } from "./lib/skill-manifest.mjs";
|
|
|
|
const assistant = (...blocks) =>
|
|
JSON.stringify({ type: "assistant", message: { content: blocks } });
|
|
const skillUse = (name) => ({
|
|
type: "tool_use",
|
|
name: "Skill",
|
|
input: { skill: name },
|
|
});
|
|
|
|
test("collectSkillInvocations finds Skill tool_use payloads", () => {
|
|
const { invoked } = collectSkillInvocations(
|
|
assistant(skillUse("testing")) + "\n",
|
|
);
|
|
assert.equal(invoked.size, 1);
|
|
assert.ok([...invoked][0].includes("testing"));
|
|
});
|
|
|
|
test("collectSkillInvocations ignores other tools and other event types", () => {
|
|
const text =
|
|
assistant({ type: "tool_use", name: "Read", input: { file_path: "a" } }) +
|
|
"\n" +
|
|
JSON.stringify({ type: "result", result: "Skill" }) +
|
|
"\n";
|
|
assert.equal(collectSkillInvocations(text).invoked.size, 0);
|
|
});
|
|
|
|
test("collectSkillInvocations survives malformed and blank lines", () => {
|
|
const text = "not json\n\n" + assistant(skillUse("local-dev")) + "\n";
|
|
const { invoked } = collectSkillInvocations(text);
|
|
assert.equal(invoked.size, 1);
|
|
});
|
|
|
|
test("collectSkillInvocations holds back a trailing partial line", () => {
|
|
const whole = assistant(skillUse("local-dev"));
|
|
const first = collectSkillInvocations(whole.slice(0, 20));
|
|
assert.equal(first.invoked.size, 0);
|
|
assert.equal(first.rest, whole.slice(0, 20));
|
|
// Feeding the remainder back with the held-over prefix completes the event.
|
|
const second = collectSkillInvocations(first.rest + whole.slice(20) + "\n");
|
|
assert.equal(second.invoked.size, 1);
|
|
});
|
|
|
|
test("collectSkillInvocations tolerates a tool_use with no input", () => {
|
|
const text = assistant({ type: "tool_use", name: "Skill" }) + "\n";
|
|
assert.deepEqual([...collectSkillInvocations(text).invoked], ["{}"]);
|
|
});
|
|
|
|
test("sampleHit scores positive and negative cases", () => {
|
|
const fired = new Set(['{"skill":"testing"}']);
|
|
const none = new Set();
|
|
assert.equal(sampleHit("testing", fired), true);
|
|
assert.equal(sampleHit("local-dev", fired), false);
|
|
assert.equal(sampleHit(null, none), true);
|
|
assert.equal(sampleHit(null, fired), false);
|
|
assert.equal(sampleHit("testing", none), false);
|
|
});
|
|
|
|
test("invokedSkillNames matches structurally, not by substring", () => {
|
|
// `{"skill":"not-testing"}` contains "testing" and must NOT count — a
|
|
// substring match inflates the measured hit rate with invocations of a
|
|
// different skill.
|
|
assert.deepEqual(invokedSkillNames('{"skill":"not-testing"}'), [
|
|
"not-testing",
|
|
]);
|
|
assert.equal(
|
|
sampleHit("testing", new Set(['{"skill":"not-testing"}'])),
|
|
false,
|
|
);
|
|
assert.equal(sampleHit("testing", new Set(['{"skill":"testing"}'])), true);
|
|
// The field name is not assumed, so any string value is a candidate.
|
|
assert.equal(
|
|
sampleHit("testing", new Set(['{"name":"testing","args":""}'])),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("invokedSkillNames tolerates payloads it cannot read", () => {
|
|
for (const payload of ["not json", "[1,2]", "null", '"testing"']) {
|
|
assert.deepEqual(invokedSkillNames(payload), []);
|
|
}
|
|
});
|
|
|
|
/** Minimal stand-in for a ChildProcess: emits stdout chunks, then closes. */
|
|
function fakeSpawn({ chunks = [], code = 0, error = null }) {
|
|
return () => {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
// The prompt is written to stdin rather than passed in argv, so the
|
|
// stand-in needs one or `runPrompt` throws before the child can close.
|
|
child.stdin = { end: () => {} };
|
|
queueMicrotask(() => {
|
|
if (error) {
|
|
child.emit("error", error);
|
|
return;
|
|
}
|
|
for (const c of chunks) child.stdout.emit("data", Buffer.from(c));
|
|
child.emit("close", code);
|
|
});
|
|
return child;
|
|
};
|
|
}
|
|
|
|
const resultEvent = (subtype) =>
|
|
JSON.stringify({ type: "result", subtype }) + "\n";
|
|
|
|
test("collectSkillInvocations reports the terminal result subtype", () => {
|
|
assert.equal(
|
|
collectSkillInvocations(resultEvent("success")).result,
|
|
"success",
|
|
);
|
|
assert.equal(collectSkillInvocations("").result, null);
|
|
});
|
|
|
|
test("runRejection accepts a run that hit the turn limit", () => {
|
|
// With `--max-turns 1`, a run in which a skill FIRES necessarily hits the
|
|
// limit and the CLI exits 1. Rejecting on the exit code alone would throw away
|
|
// exactly the observations the eval exists to count — verified against the
|
|
// real CLI, which ends such a run `error_max_turns` with `num_turns: 2`.
|
|
assert.equal(runRejection({ result: "error_max_turns", code: 1 }), null);
|
|
assert.equal(runRejection({ result: "success", code: 0 }), null);
|
|
});
|
|
|
|
test("runRejection rejects a run that observed nothing", () => {
|
|
// An auth failure, a rate limit, or a CLI that never started. Counting these
|
|
// as "no skill invoked" passes every negative case and reads as a trigger
|
|
// miss on every positive one.
|
|
assert.match(runRejection({ result: null, code: 1 }) ?? "", /no terminal/);
|
|
assert.match(runRejection({ result: null, code: 0 }) ?? "", /no terminal/);
|
|
assert.match(
|
|
runRejection({ result: "error_during_execution", code: 1 }) ?? "",
|
|
/ended `error_during_execution`/,
|
|
);
|
|
});
|
|
|
|
test("runPrompt collects invocations across chunk boundaries", async () => {
|
|
const whole =
|
|
JSON.stringify({
|
|
type: "assistant",
|
|
message: {
|
|
content: [
|
|
{ type: "tool_use", name: "Skill", input: { skill: "testing" } },
|
|
],
|
|
},
|
|
}) + "\n";
|
|
const invoked = await runPrompt("p", {
|
|
spawnFn: fakeSpawn({
|
|
chunks: [
|
|
whole.slice(0, 30),
|
|
whole.slice(30),
|
|
resultEvent("error_max_turns"),
|
|
],
|
|
code: 1,
|
|
}),
|
|
});
|
|
assert.equal(sampleHit("testing", invoked), true);
|
|
});
|
|
|
|
test("runPrompt rejects a run that produced no terminal result", async () => {
|
|
await assert.rejects(
|
|
runPrompt("p", { spawnFn: fakeSpawn({ code: 1 }) }),
|
|
/no terminal `result` event \(exit 1\) for prompt: p/,
|
|
);
|
|
});
|
|
|
|
test("runPrompt rejects a run that ended in an unusable state", async () => {
|
|
await assert.rejects(
|
|
runPrompt("p", {
|
|
spawnFn: fakeSpawn({
|
|
chunks: [resultEvent("error_during_execution")],
|
|
code: 1,
|
|
}),
|
|
}),
|
|
/ended `error_during_execution`/,
|
|
);
|
|
});
|
|
|
|
test("runPrompt propagates a spawn error", async () => {
|
|
await assert.rejects(
|
|
runPrompt("p", { spawnFn: fakeSpawn({ error: new Error("ENOENT") }) }),
|
|
/ENOENT/,
|
|
);
|
|
});
|
|
|
|
test("a negative case ignores skills that are not this repo's", () => {
|
|
// A contributor's own `~/.claude/skills` entry, or a bundled skill, firing on
|
|
// a negative prompt says nothing about these skills — failing on it would be
|
|
// a false failure about someone else's environment.
|
|
const ours = new Set(["testing", "local-dev"]);
|
|
const foreign = new Set(['{"skill":"my-personal-notes"}']);
|
|
const mine = new Set(['{"skill":"testing"}']);
|
|
|
|
assert.equal(sampleHit(null, foreign, ours), true);
|
|
assert.equal(sampleHit(null, mine, ours), false);
|
|
assert.equal(sampleHit(null, new Set(), ours), true);
|
|
// A positive case is unaffected: it names the skill it wants.
|
|
assert.equal(sampleHit("testing", mine, ours), true);
|
|
assert.equal(sampleHit("testing", foreign, ours), false);
|
|
// With no repo set, any invocation still fails a negative case.
|
|
assert.equal(sampleHit(null, foreign), false);
|
|
});
|
|
|
|
test("runPrompt sends the prompt on stdin, never in argv", () => {
|
|
// On Windows the CLI is a `.cmd` shim and can only start through a shell,
|
|
// where `cmd.exe` re-parses any metacharacter in an argument as syntax. Off
|
|
// Windows it also keeps the prompt out of the process table.
|
|
const prompt = "does it? (yes & no)";
|
|
let seen;
|
|
let written = null;
|
|
runPrompt(prompt, {
|
|
spawnFn: (command, args, options) => {
|
|
seen = { command, args, options };
|
|
const c = new EventEmitter();
|
|
c.stdout = new EventEmitter();
|
|
c.stdin = {
|
|
end: (text) => {
|
|
written = text;
|
|
},
|
|
};
|
|
queueMicrotask(() => c.emit("close", 0));
|
|
return c;
|
|
},
|
|
platform: "linux",
|
|
}).catch(() => {});
|
|
assert.equal(seen.command, "claude");
|
|
assert.ok(!seen.args.includes(prompt), "prompt must not appear in argv");
|
|
assert.deepEqual(seen.options.stdio, ["pipe", "pipe", "inherit"]);
|
|
assert.equal(written, prompt);
|
|
});
|
|
|
|
test("runPrompt asks for a shell on Windows", () => {
|
|
let seen;
|
|
runPrompt("p", {
|
|
spawnFn: (_c, _a, options) => {
|
|
seen = options;
|
|
const c = new EventEmitter();
|
|
c.stdout = new EventEmitter();
|
|
c.stdin = { end: () => {} };
|
|
queueMicrotask(() => c.emit("close", 0));
|
|
return c;
|
|
},
|
|
platform: "win32",
|
|
}).catch(() => {});
|
|
assert.equal(seen.shell, true);
|
|
});
|
|
|
|
/** A skills directory with two model-invoked skills and one name-only skill. */
|
|
function skillsFixture() {
|
|
const root = mkdtempSync(path.join(tmpdir(), "skill-eval-"));
|
|
const write = (name, frontmatter, evals) => {
|
|
const dir = path.join(root, name);
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(
|
|
path.join(dir, "SKILL.md"),
|
|
`---\nname: ${name}\ndescription: d\n${frontmatter}---\n\nBody\n`,
|
|
);
|
|
if (evals) {
|
|
mkdirSync(path.join(dir, "evals"), { recursive: true });
|
|
writeFileSync(
|
|
path.join(dir, "evals", "evals.json"),
|
|
JSON.stringify(evals),
|
|
);
|
|
}
|
|
};
|
|
// Enough positives to satisfy the same MIN_POSITIVE_CASES floor the real eval
|
|
// files must clear; the prompts stay predictable so assertions can name them.
|
|
const evalsFor = (letter, skill) => [
|
|
...Array.from({ length: MIN_POSITIVE_CASES }, (_, i) => ({
|
|
prompt: `${letter}+${i}`,
|
|
expect: skill,
|
|
})),
|
|
{ prompt: `${letter}-`, expect: null },
|
|
];
|
|
write("alpha", "disable-model-invocation: false\n", evalsFor("a", "alpha"));
|
|
write("beta", "disable-model-invocation: false\n", evalsFor("b", "beta"));
|
|
write("gamma", "disable-model-invocation: true\n");
|
|
return root;
|
|
}
|
|
|
|
test("focused mode narrows the cases but not the repo's own skill set", () => {
|
|
// `ours` decides what a NEGATIVE case counts as a false trigger. Narrowing it
|
|
// with `only` would let `skills:eval -- alpha` score a `beta` invocation as
|
|
// somebody else's skill and pass a negative case it should fail.
|
|
const root = skillsFixture();
|
|
|
|
const all = collectCases(undefined, root);
|
|
assert.deepEqual([...all.ours].sort(), ["alpha", "beta"]);
|
|
assert.equal(all.cases.length, (MIN_POSITIVE_CASES + 1) * 2);
|
|
|
|
const focused = collectCases("alpha", root);
|
|
assert.deepEqual([...focused.ours].sort(), ["alpha", "beta"]);
|
|
assert.deepEqual(
|
|
focused.cases.map((c) => c.prompt),
|
|
["a+0", "a+1", "a+2", "a+3", "a+4", "a-"],
|
|
);
|
|
// The consequence, stated as the assertion that matters:
|
|
assert.equal(
|
|
sampleHit(null, new Set(['{"skill":"beta"}']), focused.ours),
|
|
false,
|
|
);
|
|
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("focused mode accepts several skill names", () => {
|
|
const root = skillsFixture();
|
|
|
|
const focused = collectCases(["alpha", "beta"], root);
|
|
assert.deepEqual(
|
|
focused.cases.map((c) => c.prompt).sort(),
|
|
[
|
|
"a+0",
|
|
"a+1",
|
|
"a+2",
|
|
"a+3",
|
|
"a+4",
|
|
"a-",
|
|
"b+0",
|
|
"b+1",
|
|
"b+2",
|
|
"b+3",
|
|
"b+4",
|
|
"b-",
|
|
].sort(),
|
|
);
|
|
// Narrowing to every skill is still not the same as no filter — `ours` is
|
|
// unchanged either way, which is what keeps negative scoring honest.
|
|
assert.deepEqual([...focused.ours].sort(), ["alpha", "beta"]);
|
|
|
|
// A single name keeps working, so the one-argument form is unaffected.
|
|
assert.deepEqual(
|
|
collectCases("beta", root).cases.map((c) => c.prompt),
|
|
["b+0", "b+1", "b+2", "b+3", "b+4", "b-"],
|
|
);
|
|
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("an unknown skill name is an error, not an empty run", () => {
|
|
// A typo would otherwise enqueue zero cases and report a green 0/0, which
|
|
// reads exactly like a clean pass of the skill that was meant.
|
|
const root = skillsFixture();
|
|
assert.throws(
|
|
() => collectCases(["alpha", "aplha"], root),
|
|
/no model-invoked skill named `aplha`/,
|
|
);
|
|
// A name-only skill is not model-invoked, so asking for it is the same error.
|
|
assert.throws(() => collectCases(["gamma"], root), /no model-invoked skill/);
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("a name-only skill is not part of the repo's model-invoked set", () => {
|
|
const root = skillsFixture();
|
|
const { ours } = collectCases(undefined, root);
|
|
assert.equal(ours.has("gamma"), false);
|
|
// So its firing does not fail a negative case — it cannot fire on its own.
|
|
assert.equal(sampleHit(null, new Set(['{"skill":"gamma"}']), ours), true);
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test("collection fails loudly rather than silently shrinking the set", () => {
|
|
const root = skillsFixture();
|
|
// A model-invoked skill whose evals vanished.
|
|
rmSync(path.join(root, "beta", "evals"), { recursive: true, force: true });
|
|
assert.throws(
|
|
() => collectCases(undefined, root),
|
|
/beta is model-invoked but has no/,
|
|
);
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|