23f7624596
ADR-166 MCP Bridge Security Lock / Static-source security lock (push) Failing after 0s
ADR-166 MCP Bridge Security Lock / Compose default binds loopback + Mongo has auth (push) Failing after 2s
CodeQL Advanced / Analyze (rust) (push) Failing after 0s
ADR-166 MCP Bridge Security Lock / plugin-agent-federation bindHost default (push) Failing after 1s
ADR-166 MCP Bridge Security Lock / Runtime behavior — 401 + terminal gate + fail-closed (push) Failing after 4s
business-pods-smoke / smoke (push) Failing after 1s
all-plugins-smoke / smoke-all (push) Failing after 2s
CI/CD Pipeline / Security & Code Quality (push) Failing after 1s
CI/CD Pipeline / Test Suite (ubuntu-latest) (push) Failing after 1s
CI/CD Pipeline / Build & Package (macos-latest) (push) Has been skipped
CI/CD Pipeline / Build & Package (ubuntu-latest) (push) Has been skipped
CI/CD Pipeline / Build & Package (windows-latest) (push) Has been skipped
CI/CD Pipeline / Documentation & Examples (push) Failing after 1s
Clone Tracker (14-day rolling) / Snapshot clones for ruflo ecosystem (push) Failing after 1s
CodeQL Advanced / Analyze (actions) (push) Failing after 1s
CodeQL Advanced / Analyze (javascript-typescript) (push) Failing after 1s
federation-peer-rust / stable-noop (push) Failing after 1s
metaharness-ci / score (push) Failing after 1s
metaharness-ci / router-compat (push) Failing after 0s
metaharness-ci / similarity-tests (push) Failing after 0s
no-agentbbs-smoke / smoke-without-agentbbs (push) Failing after 1s
V3 CI/CD Pipeline / Build V3 (windows-latest) (push) Has been skipped
codex-integration-audit / Codex integration audit (push) Failing after 1s
helpers-manifest-guard / guard (push) Failing after 1s
🔗 Cross-Agent Integration Tests / 🤝 Agent Coordination Tests (push) Has been skipped
🔗 Cross-Agent Integration Tests / 🧠 Memory Sharing Integration (push) Has been skipped
🔗 Cross-Agent Integration Tests / 🛡️ Fault Tolerance Tests (push) Has been skipped
🔗 Cross-Agent Integration Tests / ⚡ Performance Integration Tests (push) Has been skipped
metaharness-ci / mcp-scan (push) Failing after 1s
metaharness-ci / eject-dryrun (push) Failing after 1s
metaharness-ci / metaharness-real-data (push) Failing after 0s
no-cli-optdep-bloat-2561 / guard (push) Failing after 1s
no-metaharness-smoke / smoke-without-metaharness (push) Failing after 1s
no-phantom-agentic-flow-subpath / guard (push) Failing after 1s
🔄 Automated Rollback Manager / 🚨 Failure Detection (push) Failing after 1s
V3 CI/CD Pipeline / Plugin hooks smoke / ubuntu-latest / Node 22 (push) Failing after 1s
V3 CI/CD Pipeline / ruflo-graph-intelligence build + test smoke (#2044, ADR-123) (push) Failing after 1s
CVE Audit Gate / Audit root (critical-blocking) (push) Failing after 2s
cost-tracker-smoke / smoke (push) Failing after 3s
oia-audit-weekly / audit (push) Failing after 2s
ruflo-agent-smoke / ruflo-agent structural smoke (push) Failing after 1s
📊 Status Badges Update / 📊 Update Status Badges (push) Failing after 1s
V3 CI/CD Pipeline / Static regression guards (#2267 YAML + (push) Failing after 1s
V3 CI/CD Pipeline / Test V3 Packages (push) Failing after 0s
V3 CI/CD Pipeline / agent_execute provider routing smoke (#2042) (push) Failing after 0s
CVE Audit Gate / Audit v3 (critical-blocking) (push) Failing after 1s
federation-peer-rust / stable-native (push) Failing after 2s
🔗 Cross-Agent Integration Tests / 🚀 Integration Test Setup (push) Failing after 2s
neural-trader-smoke / runtime-smoke (push) Failing after 1s
V3 CI/CD Pipeline / Build V3 (macos-latest) (push) Has been skipped
V3 CI/CD Pipeline / Build V3 (ubuntu-latest) (push) Has been skipped
V3 CI/CD Pipeline / Type Check V3 (push) Failing after 1s
V3 CI/CD Pipeline / Smoke (no better-sqlite3) / ubuntu-latest / Node 24 (push) Failing after 1s
V3 CI/CD Pipeline / Smoke (no better-sqlite3) / ubuntu-latest / Node 22 (push) Failing after 2s
V3 CI/CD Pipeline / browser rvf create flag smoke (#2015) (push) Failing after 0s
V3 CI/CD Pipeline / Dependency review (#2046) (push) Has been skipped
V3 CI/CD Pipeline / Supply-chain audit (#2046) (push) Failing after 0s
V3 CI/CD Pipeline / witness marker drift smoke (#2021) (push) Failing after 1s
V3 CI/CD Pipeline / neural-trader portfolio CG smoke (#2068, ADR-126 Phase 3) (push) Failing after 1s
V3 CI/CD Pipeline / neural-trader backtest signing smoke (#2068, ADR-126 Phase 4) (push) Failing after 1s
V3 CI/CD Pipeline / kg-extract type-import classification smoke (#2049) (push) Failing after 0s
V3 CI/CD Pipeline / witness verify precondition smoke (#1880) (push) Failing after 2s
V3 CI/CD Pipeline / neural-trader pipeline risk-gate smoke (#2068, ADR-126 Phase 5) (push) Failing after 0s
V3 CI/CD Pipeline / neural-trader feature attribution smoke (#2068, ADR-126 Phase 6) (push) Failing after 0s
V3 CI/CD Pipeline / plugin-registry signature verification smoke (#1922, CWE-347) (push) Failing after 4s
V3 CI/CD Pipeline / memory stats legacy-DB smoke (#2120) (push) Failing after 4s
V3 CI/CD Pipeline / github deprecated actions smoke (#2089, ADR-127 Phase 3) (push) Failing after 1s
V3 CI/CD Pipeline / graph query + pathfinder smoke (ADR-130 P2+P5) (push) Has been skipped
V3 CI/CD Pipeline / graph trajectory hooks smoke (ADR-130 P3) (push) Has been skipped
V3 CI/CD Pipeline / graph plugin adapter smoke (ADR-130 P4) (push) Has been skipped
V3 CI/CD Pipeline / graph benchmark (ADR-130 P6) (push) Has been skipped
V3 CI/CD Pipeline / statusline generator delegation smoke (#2195) (push) Failing after 1s
V3 CI/CD Pipeline / wizard init regression guard (#2206 (push) Failing after 1s
V3 CI/CD Pipeline / memory no-stray-db smoke (ADR-125 P7) (push) Failing after 1s
V3 CI/CD Pipeline / github-safe injection smoke (#2089, ADR-127 Phase 1) (push) Failing after 1s
V3 CI/CD Pipeline / github actions pin smoke (#2089, ADR-127 Phase 1) (push) Failing after 1s
V3 CI/CD Pipeline / github attribution opt-in smoke (#2089, ADR-127 Phase 4) (push) Failing after 1s
V3 CI/CD Pipeline / pre-bash hook safety smoke (#2017) (push) Failing after 1s
V3 CI/CD Pipeline / Memory import smoke / ubuntu-latest (push) Failing after 0s
V3 CI/CD Pipeline / MCP protocol smoke / ubuntu-latest (push) Failing after 2s
V3 CI/CD Pipeline / ruvllm WASM auto-init smoke (#2086) (push) Failing after 4s
V3 CI/CD Pipeline / MCP paired-tool round-trip smoke (#1889) (push) Failing after 1s
V3 CI/CD Pipeline / Plugin package install-safety (#1902/#1903/#1904) (push) Failing after 1s
V3 CI/CD Pipeline / Tool description discoverability (ADR-112) (push) Failing after 3s
V3 CI/CD Pipeline / CLI npx-install smoke (#1147 / (22) (push) Failing after 1s
V3 CI/CD Pipeline / CLI npx-install smoke (#1147 / (24) (push) Failing after 1s
V3 CI/CD Pipeline / Windows hook shim smoke (#2132) / ubuntu-latest (push) Failing after 2s
V3 CI/CD Pipeline / Windows hook execution smoke (#2132) / ubuntu-latest (push) Failing after 1s
V3 CI/CD Pipeline / Windows init hooks smoke (#2132) / ubuntu-latest (push) Failing after 1s
V3 CI/CD Pipeline / Vector-index dimension audit (#1947) (push) Failing after 0s
V3 CI/CD Pipeline / Hook-command install safety (#1921) (push) Failing after 1s
V3 CI/CD Pipeline / ToolOutputGuardrail smoke (ADR-131, (push) Failing after 1s
V3 CI/CD Pipeline / init-bundle invariants smoke (#2095, ADR-128 Phase 5) (push) Failing after 1s
V3 CI/CD Pipeline / wasm provider bridge smoke (ADR-129 P1) (push) Failing after 2s
V3 CI/CD Pipeline / wasm gallery CRUD smoke (ADR-129 P3) (push) Failing after 1s
V3 CI/CD Pipeline / wasm plugin bridge smoke (ADR-129 P4) (push) Failing after 0s
V3 CI/CD Pipeline / wasm compose smoke (ADR-129 P2) (push) Failing after 4s
V3 CI/CD Pipeline / graph schema smoke (ADR-130 P1) (push) Failing after 0s
Validate Marketplace / validate (push) Failing after 1s
🔍 Verification Pipeline / 🚀 Setup Verification (push) Failing after 1s
🔍 Verification Pipeline / 🛡️ Security Verification (push) Has been skipped
🔍 Verification Pipeline / 📝 Code Quality (push) Has been skipped
🔍 Verification Pipeline / 🧪 Test Verification (${{ matrix.os }}, Node ${{ matrix.node }}) (push) Has been skipped
🔍 Verification Pipeline / 🏗️ Build Verification (push) Has been skipped
🔍 Verification Pipeline / 📚 Documentation Verification (push) Has been skipped
CVE Audit Gate / High-severity report (warn only) (push) Has been cancelled
🔄 Automated Rollback Manager / 🔄 Execute Rollback (push) Has been cancelled
🔄 Automated Rollback Manager / ✅ Post-Rollback Verification (push) Has been cancelled
🔄 Automated Rollback Manager / 📊 Rollback Monitoring (push) Has been cancelled
V3 CI/CD Pipeline / Windows init hooks smoke (#2132) / windows-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows hook execution smoke (#2132) / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows hook execution smoke (#2132) / windows-latest (push) Has been cancelled
🔄 Automated Rollback Manager / ⏳ Manual Rollback Approval (push) Has been cancelled
V3 CI/CD Pipeline / MCP protocol smoke / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Memory import smoke / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows hook shim smoke (#2132) / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows hook shim smoke (#2132) / windows-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows init hooks smoke (#2132) / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Witness verify (signed manifest) / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Witness verify (signed manifest) / ubuntu-latest (push) Has been cancelled
V3 CI/CD Pipeline / Witness verify (signed manifest) / windows-latest (push) Has been cancelled
V3 CI/CD Pipeline / Publish to npm (alpha) (push) Has been cancelled
V3 CI/CD Pipeline / Smoke (no better-sqlite3) / macos-latest / Node 22 (push) Has been cancelled
V3 CI/CD Pipeline / Plugin hooks smoke / macos-latest / Node 22 (push) Has been cancelled
CI/CD Pipeline / Deploy & Release (push) Has been cancelled
CI/CD Pipeline / CI Status (push) Has been cancelled
🔗 Cross-Agent Integration Tests / 📊 Integration Test Report (push) Has been cancelled
🔄 Automated Rollback Manager / 🔍 Pre-Rollback Validation (push) Has been cancelled
🔍 Verification Pipeline / ⚡ Performance Verification (push) Has been cancelled
🔍 Verification Pipeline / 📊 Verification Report (push) Has been cancelled
204 lines
9.1 KiB
TypeScript
204 lines
9.1 KiB
TypeScript
/**
|
|
* fable-harness.test.ts — unit tests for the cost-disciplined Fable judge.
|
|
*
|
|
* Every test MOCKS the `claude -p` spawn via the injectable `spawnClaude` — no
|
|
* test ever launches the real CLI or spends a cent. Assertions cover:
|
|
* • command construction (argv: -p / --model / --output-format json /
|
|
* --append-system-prompt / --max-budget-usd)
|
|
* • the fresh-empty-temp-cwd requirement (never the project dir)
|
|
* • batching (ceil(n / batchSize) calls)
|
|
* • budget-cap enforcement (spend stops launching batches)
|
|
* • the OFF-BY-DEFAULT guarantee — no budget cap ⇒ ZERO spawns
|
|
* • verdict extraction from the JSON envelope
|
|
*
|
|
* One optional live smoke (RUFLO_FABLE_LIVE=1) a human can run is at the end.
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { tmpdir } from 'os';
|
|
import {
|
|
FableHarness,
|
|
estimateFableCostUsd,
|
|
extractVerdictArray,
|
|
parseCostFromEnvelope,
|
|
FABLE_COST_MODEL,
|
|
type ClaudeSpawnFn,
|
|
type JudgeItem,
|
|
} from '../../src/services/fable-harness.js';
|
|
|
|
/** Build a fake spawnClaude that records every call and returns a canned envelope. */
|
|
function fakeSpawn(makeResult: (batchIndex: number, argv: string[], prompt: string, cwd: string) => {
|
|
stdout?: string;
|
|
stderr?: string;
|
|
code?: number | null;
|
|
costUsd?: number;
|
|
}) {
|
|
const calls: Array<{ argv: string[]; prompt: string; cwd: string; timeoutMs: number }> = [];
|
|
const fn: ClaudeSpawnFn = async (argv, prompt, cwd, opts) => {
|
|
const idx = calls.length;
|
|
calls.push({ argv, prompt, cwd, timeoutMs: opts.timeoutMs });
|
|
const r = makeResult(idx, argv, prompt, cwd);
|
|
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', code: r.code ?? 0, costUsd: r.costUsd };
|
|
};
|
|
return { fn, calls };
|
|
}
|
|
|
|
/** Envelope shaped like `claude -p --output-format json` output. */
|
|
function envelope(verdicts: unknown[], costUsd = 0.4): string {
|
|
return JSON.stringify({ type: 'result', result: JSON.stringify(verdicts), total_cost_usd: costUsd });
|
|
}
|
|
|
|
const items = (n: number): JudgeItem[] =>
|
|
Array.from({ length: n }, (_, i) => ({ id: `t${i}`, task: `task ${i}`, output: `output ${i}` }));
|
|
|
|
describe('FableHarness — command construction', () => {
|
|
it('builds argv with -p, model, json output, append-system-prompt and budget cap', () => {
|
|
const h = new FableHarness({ maxBudgetUsd: 5, spawnClaude: async () => ({ stdout: '', stderr: '', code: 0 }) });
|
|
const argv = h.buildArgv('JUDGE ROLE');
|
|
expect(argv).toContain('-p');
|
|
expect(argv).toEqual(expect.arrayContaining(['--model', 'claude-fable-5']));
|
|
expect(argv).toEqual(expect.arrayContaining(['--output-format', 'json']));
|
|
expect(argv).toEqual(expect.arrayContaining(['--append-system-prompt', 'JUDGE ROLE']));
|
|
const capIdx = argv.indexOf('--max-budget-usd');
|
|
expect(capIdx).toBeGreaterThan(-1);
|
|
expect(Number(argv[capIdx + 1])).toBeCloseTo(5, 2);
|
|
});
|
|
|
|
it('runs claude from a FRESH TEMP cwd, never the project dir', async () => {
|
|
const { fn, calls } = fakeSpawn(() => ({ stdout: envelope([{ id: 't0', resolved: true, confidence: 0.9, reason: 'ok' }]) }));
|
|
const h = new FableHarness({ maxBudgetUsd: 5, spawnClaude: fn });
|
|
await h.judgeBatch(items(1));
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0].cwd).toContain('ruflo-fable');
|
|
expect(calls[0].cwd).not.toBe(process.cwd());
|
|
// Temp dir lives under the OS temp root (allow macOS /private symlink drift).
|
|
expect(calls[0].cwd.includes(tmpdir()) || calls[0].cwd.includes('ruflo-fable')).toBe(true);
|
|
// The prompt piped to stdin is the batch JSON, not an argv positional.
|
|
expect(calls[0].prompt).toContain('task 0');
|
|
expect(calls[0].argv).not.toContain('task 0');
|
|
});
|
|
});
|
|
|
|
describe('FableHarness — off by default (zero spend)', () => {
|
|
it('makes ZERO spawns when no budget cap is set', async () => {
|
|
const { fn, calls } = fakeSpawn(() => ({ stdout: envelope([]) }));
|
|
const h = new FableHarness({ spawnClaude: fn }); // no maxBudgetUsd
|
|
expect(h.isEnabled()).toBe(false);
|
|
const out = await h.judgeBatch(items(10));
|
|
expect(out).toEqual([]);
|
|
expect(calls).toHaveLength(0);
|
|
expect(h.getSpentUsd()).toBe(0);
|
|
});
|
|
|
|
it('reflectFailures also spends nothing without a budget cap', async () => {
|
|
const { fn, calls } = fakeSpawn(() => ({ stdout: envelope([]) }));
|
|
const h = new FableHarness({ spawnClaude: fn });
|
|
const out = await h.reflectFailures([{ id: 'a', task: 't', output: 'o' }]);
|
|
expect(out).toEqual([]);
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('FableHarness — batching & judging', () => {
|
|
it('batches items into ceil(n / batchSize) calls', async () => {
|
|
const { fn, calls } = fakeSpawn((idx, _argv, prompt) => {
|
|
const batch = JSON.parse(prompt) as JudgeItem[];
|
|
return { stdout: envelope(batch.map((b) => ({ id: b.id, resolved: true, confidence: 0.8, reason: 'ok' }))) };
|
|
});
|
|
const h = new FableHarness({ maxBudgetUsd: 100, batchSize: 4, spawnClaude: fn });
|
|
const out = await h.judgeBatch(items(10)); // 10 / 4 = 3 batches
|
|
expect(calls).toHaveLength(3);
|
|
expect(out).toHaveLength(10);
|
|
expect(out[0]).toMatchObject({ id: 't0', resolved: true });
|
|
});
|
|
|
|
it('normalizes verdicts (resolved boolean, clamped confidence)', async () => {
|
|
const { fn } = fakeSpawn(() => ({
|
|
stdout: envelope([
|
|
{ id: 't0', resolved: 'true', confidence: 1.7, reason: 'x' },
|
|
{ id: 't1', resolved: false, confidence: -3, reason: 'y' },
|
|
]),
|
|
}));
|
|
const h = new FableHarness({ maxBudgetUsd: 10, spawnClaude: fn });
|
|
const out = await h.judgeBatch(items(2));
|
|
expect(out[0]).toEqual({ id: 't0', resolved: true, confidence: 1, reason: 'x' });
|
|
expect(out[1]).toEqual({ id: 't1', resolved: false, confidence: 0, reason: 'y' });
|
|
});
|
|
|
|
it('stops launching batches once measured spend reaches the cap', async () => {
|
|
const { fn, calls } = fakeSpawn((idx, _argv, prompt) => {
|
|
const batch = JSON.parse(prompt) as JudgeItem[];
|
|
// Each batch reports $3 spend; cap is $5 ⇒ only the first batch should run.
|
|
return { stdout: envelope(batch.map((b) => ({ id: b.id, resolved: true, confidence: 0.5, reason: 'ok' }))), costUsd: 3 };
|
|
});
|
|
const h = new FableHarness({ maxBudgetUsd: 5, batchSize: 2, spawnClaude: fn });
|
|
const out = await h.judgeBatch(items(10)); // 5 batches if unbounded
|
|
// batch1: spent 0<5 → run (spent 3); batch2: spent 3<5 → run (spent 6);
|
|
// batch3: spent 6>=5 → stop. So exactly 2 batches run, covering 4 items.
|
|
expect(calls.length).toBe(2);
|
|
expect(out.length).toBe(4);
|
|
expect(h.getSpentUsd()).toBeCloseTo(6, 2);
|
|
});
|
|
});
|
|
|
|
describe('FableHarness — reflectFailures', () => {
|
|
it('returns parsed reflection results', async () => {
|
|
const { fn } = fakeSpawn(() => ({
|
|
stdout: envelope([
|
|
{ id: 'a', failureClass: 'wrong-file', diagnosis: 'edited the wrong module', mutationHint: 'target foo.ts' },
|
|
]),
|
|
}));
|
|
const h = new FableHarness({ maxBudgetUsd: 10, spawnClaude: fn });
|
|
const out = await h.reflectFailures([{ id: 'a', task: 't', output: 'o', failureHint: 'tests red' }]);
|
|
expect(out).toEqual([
|
|
{ id: 'a', failureClass: 'wrong-file', diagnosis: 'edited the wrong module', mutationHint: 'target foo.ts' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('extractVerdictArray', () => {
|
|
it('parses an enveloped result string', () => {
|
|
const arr = extractVerdictArray(envelope([{ id: 'x', resolved: true }]));
|
|
expect(arr).toEqual([{ id: 'x', resolved: true }]);
|
|
});
|
|
it('parses a bare JSON array', () => {
|
|
expect(extractVerdictArray('[{"id":"x"}]')).toEqual([{ id: 'x' }]);
|
|
});
|
|
it('parses a fenced ```json array inside a result string', () => {
|
|
const env = JSON.stringify({ result: '```json\n[{"id":"y"}]\n```' });
|
|
expect(extractVerdictArray(env)).toEqual([{ id: 'y' }]);
|
|
});
|
|
it('returns [] on garbage', () => {
|
|
expect(extractVerdictArray('not json at all')).toEqual([]);
|
|
expect(extractVerdictArray('')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('cost model', () => {
|
|
it('estimateFableCostUsd amortizes per item', () => {
|
|
expect(estimateFableCostUsd(0)).toBe(0);
|
|
expect(estimateFableCostUsd(20)).toBeCloseTo(20 * FABLE_COST_MODEL.perItemBatchedUsd, 4);
|
|
});
|
|
it('parseCostFromEnvelope reads total_cost_usd', () => {
|
|
expect(parseCostFromEnvelope(envelope([], 1.23)) ?? 0).toBeCloseTo(1.23, 2);
|
|
expect(parseCostFromEnvelope('garbage')).toBeUndefined();
|
|
});
|
|
it('exposes the measured cost anchors', () => {
|
|
expect(FABLE_COST_MODEL.perCallProjectCwdUsd).toBeGreaterThan(FABLE_COST_MODEL.perCallCleanCwdUsd);
|
|
expect(FABLE_COST_MODEL.model).toBe('claude-fable-5');
|
|
});
|
|
});
|
|
|
|
// ── Optional live smoke — human-run only, never in CI ─────────────────────
|
|
const LIVE = process.env.RUFLO_FABLE_LIVE === '1';
|
|
(LIVE ? describe : describe.skip)('FableHarness — LIVE smoke (RUFLO_FABLE_LIVE=1)', () => {
|
|
it('judges a trivial item against the real Fable CLI', async () => {
|
|
const h = new FableHarness({ maxBudgetUsd: 0.5 }); // real spawnClaude
|
|
const out = await h.judgeBatch([
|
|
{ id: 'live-1', task: 'Return the number 4', output: '4' },
|
|
]);
|
|
expect(Array.isArray(out)).toBe(true);
|
|
if (out.length > 0) expect(typeof out[0].resolved).toBe('boolean');
|
|
}, 120_000);
|
|
});
|