Files
fidoca/test/compact.test.js

149 lines
5.7 KiB
JavaScript

'use strict';
/**
* Tests for invalidateStaleReadResults in src/compact.js.
*
* Run with: node --test
* (or) node --test test/compact.test.js
*
* These are pure unit tests: they build synthetic transcript arrays and never
* call a model or touch the network. Importing compact.js also loads config.js
* (dotenv + ROOT_DIR) which is harmless and needs no API key.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { invalidateStaleReadResults } from '../src/compact.js';
// A file body long enough that the replacement marker is strictly smaller.
const BODY = Array.from({ length: 40 }, (_, i) => `line ${i} of the file body`).join('\n');
// Assistant message declaring a tool call.
const call = (id, name, args) => ({
role: 'assistant',
tool_calls: [{ id, function: { name, arguments: JSON.stringify(args) } }],
});
// A read_file tool result carrying a whole file body.
const read = (id, filename, content = BODY) =>
({ role: 'tool', tool_call_id: id, content });
// A patch tool result. `applied` is the array of mutation tags the real
// applyOperations emits, e.g. ["PATCH in src/a.js"].
const patchResult = (id, applied) =>
({ role: 'tool', tool_call_id: id, content: JSON.stringify({ applied }) });
const isMarker = (content) => {
try {
const parsed = JSON.parse(content);
return typeof parsed.note === 'string' && parsed.note.includes('overwritten meanwhile');
} catch {
return false;
}
};
test('read before a later patch is invalidated', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
const n = invalidateStaleReadResults(messages);
assert.equal(n, 1);
assert.ok(isMarker(messages[1].content), 'read result replaced with marker');
assert.equal(JSON.parse(messages[1].content).filename, 'src/a.js');
});
test('read after the last patch is left intact (still matches disk)', () => {
const messages = [
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[3].content, BODY);
});
test('a file no patch ever wrote is left intact', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/untouched.js' }),
read('r1', 'src/untouched.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, BODY);
});
test('a failed patch (no applied entries) invalidates nothing', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', []), // failure => nothing actually written
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, BODY);
});
test('partial failure: appliedSoFar still invalidates a written file', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
{ role: 'tool', tool_call_id: 'p1', content: JSON.stringify({ appliedSoFar: ['CREATE/WRITE src/a.js'], error: 'boom' }) },
];
assert.equal(invalidateStaleReadResults(messages), 1);
assert.ok(isMarker(messages[1].content));
});
test('CREATE/WRITE tag is recognised', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/new.js' }),
read('r1', 'src/new.js'),
call('p1', 'patch', { operations: [{ file: 'src/new.js', search: null, replace: 'hi' }] }),
patchResult('p1', ['CREATE/WRITE src/new.js']),
];
assert.equal(invalidateStaleReadResults(messages), 1);
assert.ok(isMarker(messages[1].content));
});
test('path canonicalization: ./src/a.js matches src/a.js', () => {
const messages = [
call('r1', 'read_file', { filename: './src/a.js' }),
read('r1', './src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 1);
assert.ok(isMarker(messages[1].content));
});
test('size guard: a tiny read is never traded for a longer marker', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js', 'tiny'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, 'tiny');
});
test('idempotent: a second pass changes nothing', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 1);
const afterFirst = messages[1].content;
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, afterFirst);
});