149 lines
5.7 KiB
JavaScript
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);
|
|
});
|
|
|