'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); });