Files
ECC/tests/lib/gateguard-turn-scan.test.js
T

393 lines
18 KiB
JavaScript

'use strict';
/**
* Tests for scripts/lib/gateguard-turn-scan.js: turn boundaries, turn ids,
* result pairing, error results, duplicate ids and same-batch exclusion.
*
* Run with: node tests/lib/gateguard-turn-scan.test.js
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const libPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-turn-scan.js');
const hookPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js');
const {
isErrorToolResult,
isToolResultRecord,
isCompactionBoundary,
scanCurrentTurn,
excludedBatchId,
createTurnScanner,
currentTurnId,
transcriptPathFor
} = require(libPath);
console.log('=== Testing gateguard-turn-scan.js ===\n');
let passed = 0;
let failed = 0;
function test(desc, fn) {
try {
fn();
console.log(` ✓ ${desc}`);
passed++;
} catch (e) {
console.log(` ✗ ${desc}: ${e.message}`);
failed++;
}
}
// Transcript fixtures in the shape Claude Code writes (one JSON record per line).
const transcriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-turn-scan-'));
let seq = 0;
let uuidSeq = 0;
const nextUuid = () => `uuid-${++uuidSeq}`;
const human = (text, extra = {}) => ({ type: 'user', uuid: nextUuid(), message: { role: 'user', content: text }, ...extra });
const toolUse = (id, name, input, extra = {}) => ({
type: 'assistant',
uuid: nextUuid(),
message: { id: `msg_${id}`, role: 'assistant', content: [{ type: 'tool_use', id, name, input }] },
...extra
});
const toolResult = (id, isError = false, extra = {}) => ({
type: 'user',
uuid: nextUuid(),
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, is_error: isError, content: 'ok' }] },
...extra
});
const search = (id, name, input) => [toolUse(id, name, input), toolResult(id)];
// Like Claude Code, a fixture ends with the pending call's own assistant record by default.
const writeTranscript = (records, { pending = true } = {}) => {
seq += 1;
const file = path.join(transcriptDir, `t-${seq}.jsonl`);
const all = records.slice();
if (pending) {
all.push({
type: 'assistant',
uuid: nextUuid(),
message: { id: `msg_pending_${seq}`, role: 'assistant', content: [{ type: 'tool_use', id: `toolu_pending_${seq}`, name: 'Edit', input: {} }] }
});
}
const lines = all.map(r => (typeof r === 'string' ? r : JSON.stringify(r)));
fs.writeFileSync(file, lines.join('\n') + '\n', 'utf8');
return file;
};
// ~400 KiB of tool traffic: pushes earlier records out of the 256 KiB tail.
const filler = prefix => {
const records = [];
for (let i = 0; i < 40; i++) {
const id = `toolu_${prefix}${i}`;
records.push(toolUse(id, 'Bash', { command: 'npm test' }));
records.push({
type: 'user',
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: 'y'.repeat(10 * 1024) }] }
});
}
return records;
};
// ── turn id ──
console.log('turn id:');
test('boundary in window with promptId -> turnId is the promptId', () => {
const t = writeTranscript([
human('old', { promptId: 'prompt-old' }),
human('now', { promptId: 'prompt-now' }),
...search('toolu_pid1', 'Grep', { pattern: 'widget_factory' }).map(r => (r.type === 'user' ? { ...r, promptId: 'prompt-now' } : r))
]);
assert.strictEqual(scanCurrentTurn(t).turnId, 'prompt-now');
const overlong = human('x', { promptId: 'p'.repeat(129) });
assert.strictEqual(scanCurrentTurn(writeTranscript([overlong])).turnId, overlong.uuid, 'overlong promptId ignored');
});
test('records without promptId keep the uuid / hash / null turn id', () => {
const boundary = human('now');
assert.strictEqual(scanCurrentTurn(writeTranscript([boundary])).turnId, boundary.uuid);
const noUuid = writeTranscript([{ type: 'user', message: { role: 'user', content: 'x' } }]);
assert.ok(/^h:[0-9a-f]{16}$/.test(scanCurrentTurn(noUuid).turnId));
const clipped = writeTranscript([human('long ago'), ...filler('nopid')]);
assert.strictEqual(scanCurrentTurn(clipped).turnId, null, 'clipped window without promptId');
});
test('compaction summary and compact_boundary records start a turn', () => {
const pre = [human('orig task'), ...search('toolu_c1', 'Grep', { pattern: 'payment' })];
const summary = { type: 'user', uuid: nextUuid(), isCompactSummary: true, message: { role: 'user', content: 'Summary' } };
const afterSummary = scanCurrentTurn(writeTranscript([...pre, summary], { pending: false }));
assert.strictEqual(afterSummary.turnId, summary.uuid);
assert.deepStrictEqual(afterSummary.searches, [], 'searches before compaction do not count');
const marker = scanCurrentTurn(writeTranscript([...pre, { type: 'system', uuid: 'cb-1', subtype: 'compact_boundary' }], { pending: false }));
assert.strictEqual(marker.turnId, 'cb-1');
assert.deepStrictEqual(marker.searches, []);
assert.strictEqual(isCompactionBoundary({ ...summary, isSidechain: true }), false, 'sidechain summaries are ignored');
});
test('meta and sidechain user records are not boundaries; tool_result records are not either', () => {
const boundary = human('go');
const t = writeTranscript(
[
boundary,
...search('toolu_m1', 'Grep', { pattern: 'alpha' }),
human('meta note', { isMeta: true }),
human('side prompt', { isSidechain: true })
],
{ pending: false }
);
const scan = scanCurrentTurn(t);
assert.strictEqual(scan.turnId, boundary.uuid);
assert.deepStrictEqual(scan.searches.map(s => s.name), ['Grep']);
assert.strictEqual(isToolResultRecord(toolResult('x')), true);
assert.strictEqual(isToolResultRecord(human('x')), false);
});
// ── which searches qualify ──
console.log('\nsearches:');
test('a search qualifies only with a paired, non-error, unique result', () => {
const t = writeTranscript(
[
human('go'),
...search('toolu_ok', 'Grep', { pattern: 'ok' }),
toolUse('toolu_noresult', 'Grep', { pattern: 'pending' }),
toolUse('toolu_err', 'Grep', { pattern: 'err' }),
toolResult('toolu_err', true),
...search('toolu_dup', 'Glob', { pattern: 'a/*' }),
...search('toolu_dup', 'Glob', { pattern: 'b/*' }),
...search('toolu_read', 'Read', { file_path: '/x.js' })
],
{ pending: false }
);
const scan = scanCurrentTurn(t);
assert.deepStrictEqual(scan.searches.map(s => s.input.pattern), ['ok']);
});
test('error results are recognised without a boolean is_error', () => {
assert.strictEqual(isErrorToolResult({ is_error: true }), true);
assert.strictEqual(isErrorToolResult({ is_error: 'true' }), true);
assert.strictEqual(isErrorToolResult({ content: ' <tool_use_error>denied</tool_use_error>' }), true);
assert.strictEqual(isErrorToolResult({ content: [{ type: 'text', text: '<tool_use_error>x' }] }), true);
assert.strictEqual(isErrorToolResult({ is_error: false, content: 'found 3 files' }), false);
});
test('callsAgo counts back from the newest call, skipping the pending call', () => {
const t = writeTranscript([human('go'), ...search('toolu_a', 'Grep', { pattern: 'a' }), ...search('toolu_b', 'Bash', { command: 'ls' })]);
const scan = scanCurrentTurn(t, `toolu_pending_${seq}`);
assert.deepStrictEqual(scan.searches.map(s => [s.name, s.callsAgo]), [['Bash', 1], ['Grep', 2]]);
assert.deepStrictEqual(scan.shellCommands, ['ls']);
});
test('the scan lists the turn Read calls newest first, skipping the pending call', () => {
const t = writeTranscript([
human('go'),
toolUse('toolu_r1', 'Read', { file_path: 'src/a.js' }),
toolResult('toolu_r1'),
...search('toolu_g', 'Grep', { pattern: 'a' }),
toolUse('toolu_r2', 'Read', { file_path: 'src/b.js' }),
toolResult('toolu_r2'),
toolUse('toolu_r3', 'Read', { file_path: 42 })
]);
const scan = scanCurrentTurn(t, `toolu_pending_${seq}`);
assert.deepStrictEqual(scan.reads, [
{ name: 'Read', path: 'src/b.js', callsAgo: 2 },
{ name: 'Read', path: 'src/a.js', callsAgo: 4 }
]);
assert.deepStrictEqual(scan.searches.map(s => s.name), ['Grep'], 'reads are never searches');
const before = writeTranscript([toolUse('toolu_r0', 'Read', { file_path: 'x' }), toolResult('toolu_r0'), human('next')]);
assert.deepStrictEqual(scanCurrentTurn(before).reads, [], 'reads before the turn are not listed');
});
test('no transcript, a directory, or a whole file without a boundary -> no searches', () => {
assert.strictEqual(scanCurrentTurn(''), null);
assert.strictEqual(scanCurrentTurn(path.join(transcriptDir, 'missing.jsonl')), null);
assert.strictEqual(scanCurrentTurn(transcriptDir), null);
const noBoundary = scanCurrentTurn(writeTranscript(search('toolu_nb', 'Grep', { pattern: 'x' }), { pending: false }));
assert.deepStrictEqual(noBoundary.searches, []);
assert.strictEqual(noBoundary.turnId, null);
});
test('boundary in window -> the boundary record\'s own promptId, not a newer record\'s', () => {
const late = { ...toolResult('toolu_n3a'), promptId: 'P1' };
const t = writeTranscript([human('one', { promptId: 'P1' }), human('two', { promptId: 'P2' }), toolUse('toolu_n3a', 'Bash', { command: 'true' }), late]);
assert.strictEqual(scanCurrentTurn(t).turnId, 'P2', 'late tool_result stamped with the previous promptId');
const noPid = human('two');
const t2 = writeTranscript([noPid, toolUse('toolu_n3b', 'Bash', { command: 'true' }), { ...toolResult('toolu_n3b'), promptId: 'P9' }]);
assert.strictEqual(scanCurrentTurn(t2).turnId, noPid.uuid, 'boundary without promptId falls back to its uuid');
});
test('clipped window without a boundary -> promptId only when all user records agree', () => {
const stamp = (records, pid) => records.map(r => (r.type === 'user' ? { ...r, promptId: pid } : r));
const agree = writeTranscript([
human('long ago', { promptId: 'PA' }),
...stamp([...filler('agree'), ...search('toolu_agree', 'Grep', { pattern: 'payment' })], 'PA')
]);
const agreeScan = scanCurrentTurn(agree);
assert.strictEqual(agreeScan.turnId, 'PA');
assert.ok(agreeScan.searches.some(s => s.name === 'Grep'), 'agreeing clipped window still credits');
const mixed = filler('mixed');
const disagree = writeTranscript([
human('long ago', { promptId: 'PA' }),
...stamp(mixed.slice(0, 60), 'PA'),
...stamp([...mixed.slice(60), ...search('toolu_dis', 'Grep', { pattern: 'payment' })], 'PB')
]);
const disagreeScan = scanCurrentTurn(disagree);
assert.strictEqual(disagreeScan.turnId, null, 'two promptIds in the window');
assert.deepStrictEqual(disagreeScan.searches, [], 'unattributable clipped window gives no credit');
const metaNew = writeTranscript([
human('long ago', { promptId: 'PA' }),
...stamp([...filler('meta'), ...search('toolu_meta', 'Grep', { pattern: 'payment' })], 'PA'),
{ type: 'user', uuid: nextUuid(), isMeta: true, promptId: 'PN', message: { role: 'user', content: '<command-message>' } }
]);
const metaScan = scanCurrentTurn(metaNew);
assert.strictEqual(metaScan.turnId, null, 'a meta record from another prompt also disagrees');
assert.deepStrictEqual(metaScan.searches, [], 'previous-turn search behind an unrecognised turn start is not credited');
const partial = writeTranscript([human('long ago', { promptId: 'PA' }), ...filler('partial').map((r, i) => (r.type === 'user' && i > 70 ? { ...r, promptId: 'PA' } : r))]);
assert.strictEqual(scanCurrentTurn(partial).turnId, 'PA', 'records without a promptId do not disagree');
});
const MALFORMED_RECORDS = [
'{"type":"assistant"}',
'{"type":"assistant","message":null}',
'{"type":"assistant","message":"str"}',
'{"type":"assistant","message":{"content":"str"}}',
'{"type":"assistant","message":{"content":[null,5,"x",{"type":"tool_use"}]}}',
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"q","content":{"toString":1}}]}}',
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"q","content":[null,{"type":"text","text":5}]}]}}',
'null',
'5',
'"str"',
'[]'
];
const MALFORMED_INPUTS = [
undefined,
null,
0,
1,
'',
'x',
[],
{},
{ message: null },
{ message: 'str' },
{ type: 'assistant' },
{ type: 'user', message: { content: {} } },
{ cwd: 5 },
{ tool_use_id: 5 },
{ batchIds: null, newestMessageId: 5 },
{ searches: null },
new Proxy({}, {
get() {
throw new Error('trap');
}
})
];
test('every exported function tolerates malformed inputs without throwing', () => {
const lib = require(libPath);
const fns = Object.entries(lib).filter(([, fn]) => typeof fn === 'function');
assert.ok(fns.length >= 9, `exported functions: ${fns.map(([name]) => name).join(', ')}`);
for (const [name, fn] of fns) {
MALFORMED_INPUTS.forEach((a, i) => {
MALFORMED_INPUTS.forEach((b, j) => {
try {
const result = fn(a, b);
if (typeof result === 'function') result();
} catch (e) {
assert.fail(`${name}(MALFORMED_INPUTS[${i}], MALFORMED_INPUTS[${j}]) threw: ${e.message}`);
}
});
});
}
});
test('scanCurrentTurn skips each malformed record and keeps the rest of the turn', () => {
for (const bad of MALFORMED_RECORDS) {
const t = writeTranscript([human('go', { promptId: 'PM' }), ...search('toolu_m1', 'Grep', { pattern: 'payment' }), bad]);
let scan;
assert.doesNotThrow(() => {
scan = scanCurrentTurn(t, `toolu_pending_${seq}`);
}, bad);
assert.ok(scan, `${bad}: scan result`);
assert.strictEqual(scan.turnId, 'PM', `${bad}: turn id`);
assert.deepStrictEqual(scan.searches.map(s => s.name), ['Grep'], `${bad}: search kept`);
}
for (const bad of ['{"type":"user"}', '{"type":"user","message":null}', '{"type":"user","message":{"content":{}}}']) {
const t = writeTranscript([human('go', { promptId: 'PM' }), ...search('toolu_m2', 'Grep', { pattern: 'payment' }), bad]);
const scan = scanCurrentTurn(t, `toolu_pending_${seq}`);
assert.ok(scan && /^h:/.test(scan.turnId), `${bad}: boundary turn id`);
assert.deepStrictEqual(scan.searches, [], `${bad}: searches before it do not count`);
}
});
// ── same-batch exclusion ──
console.log('\nsame batch:');
test('excludedBatchId: the pending call message when located, else the newest message', () => {
const t = writeTranscript([human('go'), ...search('toolu_s1', 'Grep', { pattern: 'a' })]);
const pendingId = `toolu_pending_${seq}`;
const scan = scanCurrentTurn(t, pendingId);
assert.strictEqual(excludedBatchId(scan, { tool_use_id: pendingId }), `msg_pending_${seq}`);
assert.strictEqual(excludedBatchId(scan, {}), scan.newestMessageId);
assert.strictEqual(excludedBatchId(scan, { tool_use_id: 'toolu_unknown' }), `msg_pending_${seq}`, 'unknown id: newest message');
});
// ── scanner wrapper ──
console.log('\nscanner:');
test('createTurnScanner reads the transcript once and currentTurnId guards the shape', () => {
const boundary = human('go');
const t = writeTranscript([boundary]);
const saved = process.env.CLAUDE_TRANSCRIPT_PATH;
try {
process.env.CLAUDE_TRANSCRIPT_PATH = '';
const getScan = createTurnScanner({ transcript_path: t });
const first = getScan();
fs.writeFileSync(t, JSON.stringify(human('changed')) + '\n', 'utf8');
assert.strictEqual(getScan(), first, 'cached for the rest of run()');
assert.strictEqual(currentTurnId(first), boundary.uuid);
assert.strictEqual(currentTurnId(null), null);
assert.strictEqual(currentTurnId({ turnId: '' }), null);
assert.strictEqual(transcriptPathFor({ transcriptPath: '/x.jsonl' }), '/x.jsonl');
assert.strictEqual(createTurnScanner({})(), null, 'no transcript: null scan');
} finally {
if (saved === undefined) delete process.env.CLAUDE_TRANSCRIPT_PATH;
else process.env.CLAUDE_TRANSCRIPT_PATH = saved;
}
});
test('the hook re-exports scanCurrentTurn', () => {
// The hook prunes stale state files on load: point it at a scratch dir.
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-turn-scan-state-'));
const savedStateDir = process.env.GATEGUARD_STATE_DIR;
process.env.GATEGUARD_STATE_DIR = stateDir;
try {
assert.strictEqual(require(hookPath).scanCurrentTurn, scanCurrentTurn);
} finally {
if (savedStateDir === undefined) delete process.env.GATEGUARD_STATE_DIR;
else process.env.GATEGUARD_STATE_DIR = savedStateDir;
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
test('searchesMayNameTarget finds the stem in any search input', () => {
const { searchesMayNameTarget } = require(libPath);
const scanOf = (...inputs) => ({ searches: inputs.map(input => ({ name: 'Bash', input })) });
assert.strictEqual(searchesMayNameTarget(scanOf({ command: 'rg widget .' }), 'src/widget.py'), true);
assert.strictEqual(searchesMayNameTarget(scanOf({ command: 'rg other .' }, { pattern: 'x', glob: '*.md' }), 'src/widget.py'), false);
assert.strictEqual(searchesMayNameTarget(scanOf({ command: "rg wid''get ." }), 'src/widget.py'), true, 'quotes cannot split the stem');
assert.strictEqual(searchesMayNameTarget(scanOf({ command: 'rg "wid\\get" .' }), 'src/widget.py'), true, 'backslashes cannot split the stem');
assert.strictEqual(searchesMayNameTarget(scanOf({ command: 'rg WIDGET .' }), 'src/widget.py'), true, 'case-insensitive');
assert.strictEqual(searchesMayNameTarget(scanOf({ pattern: 'widget' }), 'tests/test_widget.py'), true, 'test prefix stripped');
assert.strictEqual(searchesMayNameTarget(scanOf({ pattern: 'widget' }), 'src/widget.test.js'), true, 'test suffix stripped');
assert.strictEqual(searchesMayNameTarget(scanOf({ pattern: 'widget' }), 'pkg/widget_test.go'), true, 'go test suffix stripped');
assert.strictEqual(searchesMayNameTarget({ searches: [] }, 'src/widget.py'), false);
assert.strictEqual(searchesMayNameTarget(null, 'src/widget.py'), false);
assert.strictEqual(searchesMayNameTarget(scanOf({ command: 'ls' }), 'dir/'), true, 'no stem: never skipped');
});
fs.rmSync(transcriptDir, { recursive: true, force: true });
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);