Files
gstack/crew/test/eval-input-cache.test.ts
T
Garry Tanandcapy-ai[bot] 6417abf7a6 Move the tools only Capy crews use into a crew/ folder (GST-1833) (#4145)
* Load the Capy crew skills only on Capy machines (GST-1833)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Add changelog entry for Capy-only crew skills (GST-1833)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Move the Capy-only crew tooling into crew/ and run its tests only when needed (GST-1833)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Test that the Capy launch file puts crew/bin on PATH

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Count any ~/.capy as a Capy machine, so a Mac running Capy keeps /captain

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Fix the tests and fixtures that broke when crew tooling moved into crew/

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Plan every crew test when a pull request's merge-base can't be read

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Add a test that checks each crew-only module left the root tree for crew/

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Run crew tests on pull requests that change bin/ commands or workflows they drive

Review-Finding: p1/specialist-batched#1 reader=testing
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep running every crew test on every pull request; the skip comes in a later change

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep copied installs and older release bisects working after the crew/ move

Setup now copies crew/lib into every host's runtime root beside bin and lib, so Windows installs (which copy instead of linking) can still start gstack-ship-receipt, gstack-pregate and the other commands that import it. The release bisect runs the train-gate script from wherever the checked-out commit has it, so a bisect over commits from before the move still gets a verdict.

Review-Finding: p1/outside-voice#1 reader=outside-voice
Review-Finding: p1/outside-voice#2 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Say in the changelog that Windows installs keep working after the crew/ move

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Point the harness guide's merge-queue rows at crew/lib/ where those modules moved

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Point the plain-title test at the wave module's new home in crew/ (GST-1833)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-08 14:26:14 +00:00

191 lines
12 KiB
TypeScript

import { afterEach, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
buildEvalInputIdentity, lookupEvalInputCache, storeEvalInputCache,
EVAL_CACHE_MAX_AGE_MS, EVAL_CACHE_RESULT_MAX_BYTES,
type EvalInputManifest, type EvalPassingProof,
} from '../scripts/eval-input-cache';
const roots: string[] = [];
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
const NOW = 1_800_000_000_000;
function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cache-')); roots.push(root);
for (const [file, content] of Object.entries({ 'runner.ts': 'run()', 'rubric.ts': 'clarity >= 4',
'fixture.json': '{"project":"sample"}', 'generated.md': 'Review this plan', 'bun.lock': 'dependencies' })) {
fs.writeFileSync(path.join(root, file), content, { mode: 0o644 });
}
const input: EvalInputManifest = {
root, scope: { repository: 'owner/repo', pullRequest: 42 },
coverage: { dependencies: 'complete', prompts: 'complete', environment: 'complete' }, unknownDependencies: [],
files: ['runner.ts', 'rubric.ts', 'fixture.json', 'generated.md', 'bun.lock'],
prompts: { 'CEO quality': 'Full expanded source bundle and exact rubric' },
parameters: { thresholds: { clarity: 4 }, retries: 1, max_tokens: 8192, temperature: null },
runtime: { models: { judge: 'model-v1' }, endpoint: 'https://api.example.test', bun: '1.4.0', node: '22', platform: 'linux', arch: 'x64' },
};
const result = buildEvalInputIdentity(input);
if (result.status !== 'eligible') throw new Error(result.reason);
const identity = result.identity;
const proof: EvalPassingProof = { execution: 'new', finalized: true, completeAttemptHistory: true,
exitCode: 0, timedOut: false, cancelled: false, skipped: 0, failed: 0, passed: 1,
cases: [{ id: 'CEO quality', outcome: 'passed', attempt: 1 }],
source: { runId: '1234', revision: 'a'.repeat(40), completedAt: NOW - 1000 },
result: { clarity: 4, completeness: 5, actionability: 4, reasoning: 'Clear instructions' } };
const cacheDir = path.join(root, 'cache');
const common = { cacheDir, purpose: 'gate' as const, now: NOW };
const save = (change: Partial<Parameters<typeof storeEvalInputCache>[0]> = {}) =>
storeEvalInputCache({ ...common, before: identity, after: identity, proof, ...change });
const read = (change: Partial<Parameters<typeof lookupEvalInputCache>[0]> = {}) =>
lookupEvalInputCache({ ...common, identity, validateResult: result =>
!!result && typeof result === 'object' && !Array.isArray(result)
&& result.clarity === 4 && result.completeness === 5 && result.actionability === 4,
...change });
return { root, input, identity, proof, cacheDir, save, read, filename: path.join(cacheDir, identity.key + '.json') };
}
const key = (input: EvalInputManifest) => {
const result = buildEvalInputIdentity(input);
if (result.status !== 'eligible') throw new Error(result.reason);
return result.identity.key;
};
test('identical consumed bytes reuse original successful public evidence without refreshing it', () => {
const f = fixture();
expect(f.read().status).toBe('miss');
expect(f.save()).toEqual({ status: 'stored', key: f.identity.key });
const before = fs.readFileSync(f.filename, 'utf8');
expect(f.read()).toEqual({ status: 'reused', key: f.identity.key, caseIds: ['CEO quality'], source: f.proof.source, result: f.proof.result });
expect(f.read({ now: NOW + 60_000 }).status).toBe('reused');
expect(fs.readFileSync(f.filename, 'utf8')).toBe(before);
if (process.platform !== 'win32') expect(fs.statSync(f.filename).mode & 0o777).toBe(0o600);
});
test('file/map enumeration order and checkout location do not change consumed identity', () => {
const a = fixture(), b = fixture();
b.input.files.reverse();
b.input.parameters = { temperature: null, max_tokens: 8192, retries: 1, thresholds: { clarity: 4 } };
expect(key(a.input)).toBe(key(b.input));
});
for (const file of ['runner.ts', 'rubric.ts', 'fixture.json', 'generated.md', 'bun.lock'])
test(`changed ${file} invalidates a previous pass without relying on commit changes`, () => {
const f = fixture(); f.save(); fs.appendFileSync(path.join(f.root, file), '\nchanged');
const identity = buildEvalInputIdentity(f.input);
expect(identity.status).toBe('eligible');
if (identity.status === 'eligible') {
expect(identity.identity.key).not.toBe(f.identity.key);
expect(f.read({ identity: identity.identity }).status).toBe('miss');
expect(f.save({ after: identity.identity }).status).toBe('not-stored');
}
});
for (const [name, change] of Object.entries({
prompt: (m: EvalInputManifest) => { m.prompts['CEO quality'] += '\nextra input'; },
rubric: (m: EvalInputManifest) => { m.parameters.thresholds = { clarity: 5 }; },
retries: (m: EvalInputManifest) => { m.parameters.retries = 2; },
model: (m: EvalInputManifest) => { m.runtime.models = { judge: 'model-v2' }; },
runtime: (m: EvalInputManifest) => { m.runtime.bun = '1.4.1'; },
environment: (m: EvalInputManifest) => { m.runtime.endpoint = 'https://other.example.test'; },
selection: (m: EvalInputManifest) => { m.prompts.Other = 'A different complete prompt'; },
dependency: (m: EvalInputManifest) => { m.files = m.files.filter(file => file !== 'fixture.json'); },
})) test(`${name} identity changes invalidate prior evidence`, () => {
const f = fixture(); change(f.input); expect(key(f.input)).not.toBe(f.identity.key);
});
for (const [name, change] of Object.entries({
unknown: (m: any) => { m.unknownDependencies = ['dynamic import']; },
incomplete: (m: any) => { m.coverage.dependencies = 'unknown'; },
environment: (m: any) => { delete m.coverage.environment; },
noPrompt: (m: any) => { m.prompts = {}; },
emptyPrompt: (m: any) => { m.prompts['CEO quality'] = ''; },
noParameters: (m: any) => { m.parameters = {}; },
noRuntime: (m: any) => { m.runtime = {}; },
undefined: (m: any) => { m.parameters.unknown = undefined; },
sparseArray: (m: any) => { m.parameters.unknown = new Array(1); },
nonFinite: (m: any) => { m.parameters.timeout = NaN; },
dateObject: (m: any) => { m.parameters.date = new Date(); },
missingFile: (m: any) => { m.files.push('missing.ts'); },
duplicateFile: (m: any) => { m.files.push('runner.ts'); },
absoluteFile: (m: any) => { m.files = [path.join(m.root, 'runner.ts')]; },
traversal: (m: any) => { m.files = ['../runner.ts']; },
directory: (m: any) => { m.files = ['.']; },
})) test(`unknown or invalid input (${name}) cannot create a reusable identity`, () => {
const f = fixture(); change(f.input); expect(buildEvalInputIdentity(f.input).status).toBe('ineligible');
expect(fs.existsSync(f.cacheDir)).toBe(false);
});
for (const [name, mutation] of Object.entries({
failed: { failed: 1 }, skipped: { skipped: 1 }, timeout: { timedOut: true }, cancelled: { cancelled: true },
killed: { exitCode: 137 }, nonzero: { exitCode: 1 }, partial: { finalized: false },
incompleteHistory: { completeAttemptHistory: false }, hollow: { passed: 0, cases: [] },
missing: { cases: [] }, wrongCount: { passed: 2 }, wrongCase: { cases: [{ id: 'Other', outcome: 'passed', attempt: 1 }] },
retry: { cases: [{ id: 'CEO quality', outcome: 'passed', attempt: 2 }] },
duplicate: { cases: [{ id: 'CEO quality', outcome: 'passed', attempt: 1 }, { id: 'CEO quality', outcome: 'passed', attempt: 1 }] },
assertionFailure: { cases: [{ id: 'CEO quality', outcome: 'failed', attempt: 1 }] },
reuse: { execution: 'reused' },
})) test(`${name} evidence is neither stored nor accepted if found in a receipt`, () => {
const f = fixture(), proof = { ...f.proof, ...mutation } as EvalPassingProof;
expect(f.save({ proof }).status).toBe('not-stored');
expect(fs.existsSync(f.cacheDir)).toBe(false);
f.save(); const receipt = JSON.parse(fs.readFileSync(f.filename, 'utf8')); receipt.proof = proof;
fs.writeFileSync(f.filename, JSON.stringify(receipt)); expect(f.read().status).toBe('miss');
});
for (const purpose of ['periodic', 'release'] as const) test(`${purpose} always executes fresh`, () => {
const f = fixture(); expect(f.save({ purpose }).status).toBe('not-stored');
f.save(); expect(f.read({ purpose }).status).toBe('miss');
});
test('fresh bypass applies to both lookup and publication', () => {
const f = fixture(); expect(f.save({ fresh: true }).status).toBe('not-stored');
f.save(); expect(f.read({ fresh: true }).status).toBe('miss');
});
test('24-hour expiry, future timestamps and malformed age policy fail closed', () => {
const f = fixture(); f.save();
expect(f.read({ now: f.proof.source.completedAt + EVAL_CACHE_MAX_AGE_MS - 1 }).status).toBe('reused');
expect(f.read({ now: f.proof.source.completedAt + EVAL_CACHE_MAX_AGE_MS }).status).toBe('miss');
expect(f.read({ now: f.proof.source.completedAt - 1 }).status).toBe('miss');
expect(f.read({ maxAgeMs: 0 }).status).toBe('miss');
expect(f.save({ now: f.proof.source.completedAt - 1 }).status).toBe('not-stored');
});
test('different PR/repository cannot borrow a receipt, even copied to its expected key', () => {
for (const scope of [{ repository: 'owner/repo', pullRequest: 43 }, { repository: 'other/repo', pullRequest: 42 }]) {
const f = fixture(); f.save();
const result = buildEvalInputIdentity({ ...f.input, scope });
if (result.status !== 'eligible') throw new Error(result.reason);
fs.copyFileSync(f.filename, path.join(f.cacheDir, `${result.identity.key}.json`));
expect(f.read({ identity: result.identity }).status).toBe('miss');
}
});
test('caller revalidates cached values; rejected or throwing assertions cannot grant credit', () => {
const f = fixture(); f.save();
expect(f.read({ validateResult: () => false }).status).toBe('miss');
expect(f.read({ validateResult: () => { throw new Error('assertion failed'); } }).status).toBe('miss');
const receipt = JSON.parse(fs.readFileSync(f.filename, 'utf8')); receipt.proof.result.clarity = 1;
fs.writeFileSync(f.filename, JSON.stringify(receipt));
expect(f.read({ validateResult: () => true }).status).toBe('miss');
});
test('payloads are bounded strict JSON and operational collector fields never persist', () => {
const f = fixture();
expect(f.save({ proof: { ...f.proof, result: 'x'.repeat(EVAL_CACHE_RESULT_MAX_BYTES) } }).status).toBe('not-stored');
expect(f.save({ proof: { ...f.proof, result: undefined } as any }).status).toBe('not-stored');
expect(f.save({ proof: { ...f.proof, prompt: 'PRIVATE PROMPT', transcript: 'PRIVATE TRANSCRIPT' } as any }).status).toBe('stored');
const text = fs.readFileSync(f.filename, 'utf8');
expect(text).not.toContain('PRIVATE'); expect(text).not.toContain(f.input.prompts['CEO quality']!);
});
test('malformed receipts, unknown schema and filesystem failures produce misses, not success', () => {
const f = fixture(); f.save();
const valid = fs.readFileSync(f.filename, 'utf8');
for (const content of ['{broken', '{}', valid.replace('"schema":1', '"schema":99')]) {
fs.writeFileSync(f.filename, content); expect(f.read().status).toBe('miss');
}
expect(f.save({ cacheDir: path.join(f.root, 'runner.ts') }).status).toBe('not-stored');
});
test.skipIf(process.platform === 'win32')('external input links and symlinked receipts cannot supply cache credit', () => {
const f = fixture(), outside = fixture();
fs.symlinkSync(path.join(outside.root, 'runner.ts'), path.join(f.root, 'outside.ts'));
expect(buildEvalInputIdentity({ ...f.input, files: ['outside.ts'] }).status).toBe('ineligible');
f.save(); const real = path.join(f.root, 'receipt.json'); fs.renameSync(f.filename, real); fs.symlinkSync(real, f.filename);
expect(f.read().status).toBe('miss');
});