// tests/lib/research-loop-cap.test.mjs // Cover lib/util/research-loop-cap.mjs: default-off, worst-case arithmetic, // anti-dead-data (different caps → different denial points), statefulness // (identical args → different answers once the budget is hit), env // coercion, fail-closed on missing CLAUDE_PLUGIN_DATA, and the CLI shim. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, rmSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { allowTurn, isStormEnabled, resolveMaxConvTurns, resolveLedgerPath, resolveDataRoot, MAX_CONV_TURNS, MAX_TOTAL_DIMENSIONS, } from '../../lib/util/research-loop-cap.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const SHIM = join(HERE, '..', '..', 'lib', 'util', 'research-loop-cap.mjs'); function withTmpDataDir(fn) { const dir = mkdtempSync(join(tmpdir(), 'research-loop-cap-')); try { return fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); } } function runShim(args, env) { try { const out = execFileSync(process.execPath, [SHIM, ...args], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...env }, }); return { code: 0, out }; } catch (e) { return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' }; } } /** * Run the shim in a BUILT env rather than an inherited one. Spreading * process.env means no test can express "CLAUDE_PLUGIN_DATA is absent" — the * exact condition that holds in every real run — so the shim's behaviour there * went uncovered while the module was denying turn 1. */ function runShimStripped(args, env = {}) { try { const out = execFileSync(process.execPath, [SHIM, ...args], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], env: { PATH: process.env.PATH, ...env }, }); return { code: 0, out }; } catch (e) { return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' }; } } // ---- (a) default-off -------------------------------------------------------- test('allowTurn — VOYAGE_STORM_ENABLED unset denies with budget 0, regardless of effort', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir }; const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env }); assert.equal(r.ok, false); assert.equal(r.reason, 'storm_disabled'); assert.equal(r.budget, 0); }); }); test('allowTurn — VOYAGE_STORM_ENABLED=0 denies same as unset', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '0' }; const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env }); assert.equal(r.ok, false); assert.equal(r.reason, 'storm_disabled'); }); }); test('allowTurn — enabled but effort !== high denies with budget 0', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' }; const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'standard' }, { env }); assert.equal(r.ok, false); assert.equal(r.reason, 'effort_not_high'); assert.equal(r.budget, 0); }); }); // ---- (f) CLAUDE_PLUGIN_DATA unset => documented fallback root ---------------- // // CLAUDE_PLUGIN_DATA is EMPTY in the Bash tool's process env (measured in a // live plugin-enabled session), and the Bash snippet in commands/trekresearch.md // is the module's only caller. Denying on its absence therefore denied turn 1 // of every real run: the loop could never spend a turn, and the pre-registered // measurement could not be run at all. The root is resolved in code, not // demanded of the environment. test('allowTurn — CLAUDE_PLUGIN_DATA unset falls back to the documented root and grants', () => { withTmpDataDir((home) => { const env = { VOYAGE_STORM_ENABLED: '1', HOME: home }; // no CLAUDE_PLUGIN_DATA const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env }); assert.equal(r.ok, true, 'the loop must be able to spend turn 1 without CLAUDE_PLUGIN_DATA'); assert.equal(r.used, 1); assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS); assert.ok( existsSync(join(home, '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl')), 'the ledger must be written under the fallback root', ); }); }); test('resolveDataRoot — CLAUDE_PLUGIN_DATA wins; empty or unset falls back to ~/.claude/voyage', () => { assert.equal(resolveDataRoot({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data' }), '/tmp/plugin-data'); assert.equal(resolveDataRoot({ CLAUDE_PLUGIN_DATA: '', HOME: '/home/x' }), join('/home/x', '.claude', 'voyage')); assert.equal(resolveDataRoot({ HOME: '/home/x' }), join('/home/x', '.claude', 'voyage')); }); // ---- (c) worst-case arithmetic ---------------------------------------------- test('allowTurn — budget is max_conv_turns × max_total_dimensions (default 3×8=24)', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' }; const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env }); assert.equal(r.ok, true); assert.equal(r.budget, 24); assert.equal(r.used, 1); }); }); test('allowTurn — grants exactly `budget` turns then denies the next one (default 24)', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' }; let last; for (let i = 0; i < 24; i++) { last = allowTurn({ runId: 'r-exhaust', dimension: `d${i % 8}`, effort: 'high' }, { env }); assert.equal(last.ok, true, `turn ${i + 1} should be granted`); } const denied = allowTurn({ runId: 'r-exhaust', dimension: 'd0', effort: 'high' }, { env }); assert.equal(denied.ok, false); assert.equal(denied.reason, 'budget_exhausted'); assert.equal(denied.used, 24); assert.equal(denied.budget, 24); }); }); // ---- (c)/(anti-dead-data) — different caps → observably different denial points test('allowTurn — TREKRESEARCH_MAX_CONV_TURNS=1 denies after 8 turns (1×8), not 24', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' }; let last; for (let i = 0; i < 8; i++) { last = allowTurn({ runId: 'r-narrow', dimension: `d${i}`, effort: 'high' }, { env }); assert.equal(last.ok, true, `turn ${i + 1} should be granted`); } const denied = allowTurn({ runId: 'r-narrow', dimension: 'd8', effort: 'high' }, { env }); assert.equal(denied.ok, false); assert.equal(denied.budget, 8); assert.notEqual(denied.budget, 24, 'a narrower cap must produce a different denial point than the default'); }); }); // ---- (d) stateful — identical args give different answers once exhausted --- test('allowTurn — identical {runId, dimension, effort} args diverge once the budget is hit (proves statefulness)', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' }; const args = { runId: 'r-identical', dimension: 'same-dim', effort: 'high' }; const results = []; for (let i = 0; i < 9; i++) results.push(allowTurn(args, { env })); // First 8 (budget = 1*8) granted, 9th denied — same exact input object each time. assert.deepEqual(results.slice(0, 8).map(r => r.ok), Array(8).fill(true)); assert.equal(results[8].ok, false); assert.equal(results[8].reason, 'budget_exhausted'); }); }); // ---- (e) env coercion -------------------------------------------------------- test('resolveMaxConvTurns — NaN string falls back to default', () => { assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: 'abc' }), MAX_CONV_TURNS); }); test('resolveMaxConvTurns — empty string falls back to default', () => { assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '' }), MAX_CONV_TURNS); }); test('resolveMaxConvTurns — negative value falls back to default', () => { assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '-5' }), MAX_CONV_TURNS); }); test('resolveMaxConvTurns — zero falls back to default (never unbounded)', () => { assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0' }), MAX_CONV_TURNS); }); test('resolveMaxConvTurns — unset falls back to default', () => { assert.equal(resolveMaxConvTurns({}), MAX_CONV_TURNS); }); test('resolveMaxConvTurns — valid positive integer string is honored', () => { assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2' }), 2); }); // ---- pure-core unit coverage -------------------------------------------------- test('isStormEnabled — only the literal string "1" enables', () => { assert.equal(isStormEnabled({ VOYAGE_STORM_ENABLED: '1' }), true); assert.equal(isStormEnabled({ VOYAGE_STORM_ENABLED: 'true' }), false); assert.equal(isStormEnabled({}), false); }); test('resolveLedgerPath — falls back under ~/.claude/voyage when CLAUDE_PLUGIN_DATA is unset or empty', () => { const expected = join('/home/x', '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl'); assert.equal(resolveLedgerPath({ HOME: '/home/x' }), expected); assert.equal(resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '', HOME: '/home/x' }), expected); }); test('resolveLedgerPath — joins CLAUDE_PLUGIN_DATA with the ledger filename', () => { const p = resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data' }); assert.equal(p, join('/tmp/plugin-data', 'trekresearch-loop-ledger.jsonl')); }); test('allowTurn — missing runId or dimension denies with missing_args', () => { withTmpDataDir((dir) => { const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' }; const r1 = allowTurn({ dimension: 'd1', effort: 'high' }, { env }); assert.equal(r1.ok, false); assert.equal(r1.reason, 'missing_args'); const r2 = allowTurn({ runId: 'r1', effort: 'high' }, { env }); assert.equal(r2.ok, false); assert.equal(r2.reason, 'missing_args'); }); }); // ---- (g) shim contract -------------------------------------------------------- test('CLI shim — grants and exits 0 when enabled + high effort + budget available', () => { withTmpDataDir((dir) => { const r = runShim( ['--run-id', 'shim-1', '--dimension', 'd1', '--effort', 'high'], { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' }, ); assert.equal(r.code, 0); const parsed = JSON.parse(r.out.trim()); assert.equal(parsed.ok, true); }); }); test('CLI shim — denies and exits 1 when disabled', () => { const r = runShim(['--run-id', 'shim-2', '--dimension', 'd1', '--effort', 'high'], { VOYAGE_STORM_ENABLED: '0' }); assert.equal(r.code, 1); const parsed = JSON.parse(r.out.trim()); assert.equal(parsed.ok, false); assert.equal(parsed.reason, 'storm_disabled'); }); test('CLI shim — grants with CLAUDE_PLUGIN_DATA STRIPPED from the environment', () => { withTmpDataDir((home) => { const r = runShimStripped( ['--run-id', 'shim-stripped', '--dimension', 'd1', '--effort', 'high'], { VOYAGE_STORM_ENABLED: '1', HOME: home }, ); assert.equal(r.code, 0, `shim must grant without CLAUDE_PLUGIN_DATA; got: ${r.out}`); const parsed = JSON.parse(r.out.trim()); assert.equal(parsed.ok, true); assert.ok(existsSync(join(home, '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl'))); }); }); test('CLI shim — missing required args exits 1 with usage reason', () => { const r = runShim(['--run-id', 'shim-3']); assert.equal(r.code, 1); const parsed = JSON.parse(r.out.trim()); assert.equal(parsed.ok, false); assert.match(parsed.reason, /usage:/); });