mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
feat(edit-history): edit history panel for local claude sessions
Part 1 of #643 (local sessions only; remote follows as its own PR). An opt-in, per-device header button opens a panel with the active session's edits, newest first: every Edit/Write/MultiEdit/NotebookEdit of the main agent and its subagents with its diff, plus images a Bash call rendered or the agent sent. Remote, Docker and non-claude sessions answer `unsupported`. - Reads only on request: `?sync=1` when the panel opens; while it is open on that session it re-reads after an edit-capable `transcript:tool_end` or a `transcript:complete` from the existing transcript watcher. No hook, timer, second watcher or new SSE event; a wiring guard pins it. - Main transcript: the path the transcript watcher follows (hook-reported), else `<id>.jsonl` under the session's own CLAUDE_CONFIG_DIR override or the server's config dir. A config dir set outside Codeman is found with the next hook; until then the panel says so. - Store: dataPath('edit-history')/<id>.json, 0600 via tmp+rename, capped at 500 entries and 4 MiB of diff text per session, deleted with the session, blocked in isSensitivePath, never fed to /api/search. Every route goes through findSessionOrFail with req. - Images only through existing file surfaces: file-raw inside the workspace, the attachment route with notify:false outside it. - textContent rendering, bounded byte-offset reads, marker-class header button, hidden on phones (locked in the mobile policy test). Docs: Working-With-Files, Settings-Reference, architecture invariants, API reference, CLAUDE.md key pattern. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
002de32a08
commit
95e2272f89
31 files changed
+4631
-6
No files matched your search
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Local edit-source reader against a temp projects dir: the transcript lookup, byte-offset
|
||||
* reads of the main transcript and its subagents, and the image probe.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
findClaudeTranscript,
|
||||
probeLocalPaths,
|
||||
readClaudeEditSources,
|
||||
resolveClaudeTranscript,
|
||||
} from '../src/edit-history-local-source.js';
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'edit-src-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function put(rel: string, content: string | Buffer, mtimeSec?: number): string {
|
||||
const path = join(root, rel);
|
||||
mkdirSync(join(path, '..'), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
if (mtimeSec) utimesSync(path, mtimeSec, mtimeSec);
|
||||
return path;
|
||||
}
|
||||
|
||||
describe('findClaudeTranscript', () => {
|
||||
it('returns null when no transcript exists under any id', async () => {
|
||||
put('p/other.jsonl', 'x\n');
|
||||
expect(await findClaudeTranscript(['sid'], root)).toBeNull();
|
||||
expect(await findClaudeTranscript(['sid'], join(root, 'missing'))).toBeNull();
|
||||
});
|
||||
|
||||
it('takes the first id that has a transcript, the newest one across project dirs', async () => {
|
||||
put('a/sid.jsonl', 'old\n', 1000);
|
||||
const newest = put('b/sid.jsonl', 'new\n', 2000);
|
||||
put('c/second.jsonl', 'x\n');
|
||||
expect(await findClaudeTranscript(['bad id!', 'sid', 'second'], root)).toBe(newest);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClaudeTranscript', () => {
|
||||
it('takes the watcher path (what the hooks reported) as is', async () => {
|
||||
expect(await resolveClaudeTranscript('/hooked/conv.jsonl', ['sid'], join(root, 'cfg'))).toBe('/hooked/conv.jsonl');
|
||||
});
|
||||
|
||||
it("without one, looks under the session's own claude config dir", async () => {
|
||||
const path = put('cfg/projects/p/sid.jsonl', 'x\n');
|
||||
expect(await resolveClaudeTranscript(null, ['sid'], join(root, 'cfg'))).toBe(path);
|
||||
expect(await resolveClaudeTranscript(undefined, ['other'], join(root, 'cfg'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readClaudeEditSources', () => {
|
||||
it('returns null for a missing file or a path that is not an absolute <id>.jsonl', async () => {
|
||||
const path = put('p/sid.jsonl', 'x\n');
|
||||
expect(await readClaudeEditSources(join(root, 'p/gone.jsonl'), [], 100)).toBeNull();
|
||||
expect(await readClaudeEditSources('p/sid.jsonl', [], 100)).toBeNull();
|
||||
expect(await readClaudeEditSources(path.replace('.jsonl', '.txt'), [], 100)).toBeNull();
|
||||
expect(await readClaudeEditSources(put('p/bad id.jsonl', 'x\n'), [], 100)).toBeNull();
|
||||
});
|
||||
|
||||
it('names the conversation after the file', async () => {
|
||||
const res = await readClaudeEditSources(put('p/conv-1.jsonl', 'x\n'), [], 100);
|
||||
expect(res?.transcriptId).toBe('conv-1');
|
||||
});
|
||||
|
||||
it('reads a new file from its end within the budget, a requested one from its offset', async () => {
|
||||
const path = put('p/sid.jsonl', '0123456789');
|
||||
const fresh = await readClaudeEditSources(path, [], 4);
|
||||
expect(fresh?.files).toEqual([{ relPath: 'main', size: 10, startOffset: 6, chunk: Buffer.from('6789') }]);
|
||||
const cont = await readClaudeEditSources(path, [{ relPath: 'main', offset: 3, len: 2 }], 100);
|
||||
expect(cont?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('34') });
|
||||
});
|
||||
|
||||
it('clamps an offset past the end to the size (a replaced, shorter file)', async () => {
|
||||
const path = put('p/sid.jsonl', 'abc');
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 50 }], 100);
|
||||
expect(res?.files[0]).toMatchObject({ size: 3, startOffset: 3, chunk: Buffer.alloc(0) });
|
||||
});
|
||||
|
||||
it('lists subagents with their meta for new ones only, and shares the budget main first', async () => {
|
||||
const path = put('p/sid.jsonl', 'MAIN');
|
||||
put('p/sid/subagents/agent-a1.jsonl', 'AAAA');
|
||||
put('p/sid/subagents/agent-a1.meta.json', '{"description":"Fix it"}');
|
||||
put('p/sid/subagents/agent-b2.jsonl', 'BBBB');
|
||||
put('p/sid/subagents/agent-b2.meta.json', '{"description":"Other"}');
|
||||
put('p/sid/subagents/notes.txt', 'ignored');
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 0 }], 6);
|
||||
expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
|
||||
['main', 0, 'MAIN'],
|
||||
['subagents/agent-a1.jsonl', 2, 'AA'],
|
||||
['subagents/agent-b2.jsonl', 0, ''],
|
||||
]);
|
||||
expect(res?.metas).toEqual({ a1: '{"description":"Fix it"}' });
|
||||
});
|
||||
|
||||
it('ignores invalid requests', async () => {
|
||||
const path = put('p/sid.jsonl', 'abcdef');
|
||||
const res = await readClaudeEditSources(
|
||||
path,
|
||||
[
|
||||
{ relPath: '../etc/passwd', offset: 0 },
|
||||
{ relPath: 'main', offset: -1 },
|
||||
],
|
||||
3
|
||||
);
|
||||
// The invalid main request is dropped, so main is read like a new file.
|
||||
expect(res?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('def') });
|
||||
});
|
||||
|
||||
it('keeps multibyte bytes intact (byte offsets, not string lengths)', async () => {
|
||||
const path = put('p/sid.jsonl', Buffer.from('äöü\n', 'utf8'));
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2 }], 100);
|
||||
expect(res?.files[0].chunk).toEqual(Buffer.from('äöü\n', 'utf8').subarray(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeLocalPaths', () => {
|
||||
it('reports kind, size and mtime, expands ~/ against home, null for missing', async () => {
|
||||
put('home/img.png', 'PNG!', 1_700_000_000);
|
||||
mkdirSync(join(root, 'dir'));
|
||||
const res = await probeLocalPaths(['~/img.png', join(root, 'dir'), join(root, 'nope.png')], join(root, 'home'));
|
||||
expect(res[0]).toEqual({ realPath: join(root, 'home/img.png'), kind: 'file', size: 4, mtimeMs: 1_700_000_000_000 });
|
||||
expect(res[1]).toMatchObject({ kind: 'directory', size: 0 });
|
||||
expect(res[2]).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Edit history parser: claude transcript lines → edit records and Bash image candidates.
|
||||
* Fixture lines mirror real transcript records (Claude Code 2.1.300), trimmed to the
|
||||
* fields the parser reads plus the bulky ones it must ignore (`originalFile`, `content`).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
classifyEditPath,
|
||||
extractImagePathCandidates,
|
||||
MAX_HUNK_BYTES,
|
||||
MAX_IMAGE_CANDIDATES,
|
||||
MAX_PENDING_BASH,
|
||||
MAX_PENDING_COMMAND_CHARS,
|
||||
parseClaudeEditLines,
|
||||
} from '../src/edit-history-parse.js';
|
||||
|
||||
const CWD = '/work/repo';
|
||||
|
||||
function toolResultLine(id: string, ts: string, toolUseResult: unknown, extra: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
isSidechain: false,
|
||||
timestamp: ts,
|
||||
cwd: CWD,
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function bashUseLine(id: string, ts: string, command: string, extra: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
isSidechain: false,
|
||||
timestamp: ts,
|
||||
cwd: CWD,
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
const hunk = (lines: string[]) => ({ oldStart: 3, oldLines: 2, newStart: 3, newLines: 3, lines });
|
||||
|
||||
const editLine = toolResultLine('toolu_edit', '2026-10-15T19:01:28.880Z', {
|
||||
filePath: '/work/repo/src/app.ts',
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
originalFile: 'x'.repeat(5000),
|
||||
structuredPatch: [hunk([' keep', '-old', '+new', '+added'])],
|
||||
userModified: false,
|
||||
replaceAll: false,
|
||||
});
|
||||
|
||||
describe('parseClaudeEditLines: edits', () => {
|
||||
it('turns an Edit result into a record without the whole-file fields', () => {
|
||||
const { records } = parseClaudeEditLines(editLine + '\n');
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: 'toolu_edit',
|
||||
ts: Date.parse('2026-10-15T19:01:28.880Z'),
|
||||
path: '/work/repo/src/app.ts',
|
||||
kind: 'code',
|
||||
tool: 'Edit',
|
||||
created: false,
|
||||
added: 2,
|
||||
removed: 1,
|
||||
hunks: [hunk([' keep', '-old', '+new', '+added'])],
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(records)).not.toContain('xxxxx');
|
||||
});
|
||||
|
||||
it('reads replaceAll edits like any other Edit', () => {
|
||||
const line = toolResultLine('toolu_all', '2026-10-15T19:02:00.000Z', {
|
||||
filePath: '/work/repo/a.ts',
|
||||
oldString: 'x',
|
||||
newString: 'y',
|
||||
originalFile: '',
|
||||
structuredPatch: [hunk(['-x', '+y']), { ...hunk(['-x', '+y']), oldStart: 9, newStart: 9 }],
|
||||
userModified: false,
|
||||
replaceAll: true,
|
||||
});
|
||||
const [rec] = parseClaudeEditLines(line).records;
|
||||
expect(rec).toMatchObject({ tool: 'Edit', added: 2, removed: 2 });
|
||||
expect(rec.hunks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('marks a Write that created the file, and one that replaced it', () => {
|
||||
const create = toolResultLine('toolu_c', '2026-10-15T19:03:00.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/new.md',
|
||||
content: 'hello\nworld',
|
||||
structuredPatch: [],
|
||||
originalFile: null,
|
||||
});
|
||||
const update = toolResultLine('toolu_u', '2026-10-15T19:04:00.000Z', {
|
||||
type: 'update',
|
||||
filePath: '/work/repo/new.md',
|
||||
content: 'hi',
|
||||
structuredPatch: [hunk(['-hello', '-world', '+hi'])],
|
||||
originalFile: 'hello\nworld',
|
||||
});
|
||||
const { records } = parseClaudeEditLines([create, update].join('\n'));
|
||||
expect(records.map((r) => [r.tool, r.created, r.added, r.removed])).toEqual([
|
||||
['Write', true, 2, 0],
|
||||
['Write', false, 1, 2],
|
||||
]);
|
||||
expect(records[0].hunks).toEqual([
|
||||
{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 2, lines: ['+hello', '+world'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a created file’s diff drops the trailing newline and is capped like any hunk', () => {
|
||||
const big = 'x'.repeat(1000) + '\n';
|
||||
const line = toolResultLine('toolu_big', '2026-10-15T19:03:30.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/big.txt',
|
||||
content: big.repeat(100),
|
||||
structuredPatch: [],
|
||||
originalFile: null,
|
||||
});
|
||||
const [rec] = parseClaudeEditLines(line).records;
|
||||
expect(rec.added).toBe(100);
|
||||
expect(rec.truncated).toBe(true);
|
||||
expect(rec.hunks[0].lines.length).toBeLessThan(100);
|
||||
expect(rec.hunks[0].lines.every((l) => l === '+' + 'x'.repeat(1000))).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies an image written by Write as an image', () => {
|
||||
const line = toolResultLine('toolu_svg', '2026-10-15T19:05:00.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/icon.SVG',
|
||||
content: '<svg/>',
|
||||
structuredPatch: [],
|
||||
originalFile: null,
|
||||
});
|
||||
expect(parseClaudeEditLines(line).records[0].kind).toBe('image');
|
||||
});
|
||||
|
||||
it('keeps a result of an unknown edit tool that has a path and a patch', () => {
|
||||
const line = toolResultLine('toolu_nb', '2026-10-15T19:06:00.000Z', {
|
||||
filePath: '/work/repo/n.ipynb',
|
||||
structuredPatch: [hunk(['+cell'])],
|
||||
});
|
||||
expect(parseClaudeEditLines(line).records[0]).toMatchObject({ tool: 'edit', added: 1 });
|
||||
});
|
||||
|
||||
it('skips failed tool results (a string), unrelated results and lines that do not parse', () => {
|
||||
const failed = toolResultLine('toolu_err', '2026-10-15T19:07:00.000Z', 'Error: String to replace not found');
|
||||
const read = toolResultLine('toolu_read', '2026-10-15T19:07:01.000Z', { type: 'text', file: { filePath: '/x' } });
|
||||
const text = [failed, read, '{"type":"user",broken', '', 'not json'].join('\n');
|
||||
expect(parseClaudeEditLines(text).records).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a first line cut mid-record (a byte range starts inside it)', () => {
|
||||
const cut = editLine.slice(40);
|
||||
expect(parseClaudeEditLines(cut + '\n' + editLine).records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('carries the subagent id of a sidechain line', () => {
|
||||
const line = toolResultLine('toolu_sub', '2026-10-15T19:08:00.000Z', JSON.parse(editLine).toolUseResult, {
|
||||
isSidechain: true,
|
||||
agentId: 'a0038561ea4d57a42',
|
||||
});
|
||||
expect(parseClaudeEditLines(line).records[0].agentId).toBe('a0038561ea4d57a42');
|
||||
});
|
||||
|
||||
it('caps an oversized patch and says so, counting lines from the whole patch', () => {
|
||||
const big = Array.from({ length: 4000 }, (_, i) => `+line ${i} ${'y'.repeat(40)}`);
|
||||
const line = toolResultLine('toolu_big', '2026-10-15T19:09:00.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/big.ts',
|
||||
content: '',
|
||||
structuredPatch: [hunk(big)],
|
||||
originalFile: null,
|
||||
});
|
||||
const [rec] = parseClaudeEditLines(line).records;
|
||||
expect(rec.truncated).toBe(true);
|
||||
expect(rec.added).toBe(4000);
|
||||
expect(JSON.stringify(rec.hunks).length).toBeLessThanOrEqual(MAX_HUNK_BYTES + 1024);
|
||||
expect(rec.hunks[0].lines.length).toBeLessThan(4000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseClaudeEditLines: Bash image candidates', () => {
|
||||
const start = '2026-10-15T20:00:00.000Z';
|
||||
const end = '2026-10-15T20:00:05.000Z';
|
||||
|
||||
it('pairs a Bash call with its result and collects image paths from command and output', () => {
|
||||
const use = bashUseLine('toolu_b', start, 'python3 render.py --out out/frame.png');
|
||||
const res = toolResultLine('toolu_b', end, {
|
||||
stdout: 'Saved to: file:///tmp/gen/a b.png\nwrote /work/repo/out/thumb.webp.',
|
||||
stderr: '',
|
||||
interrupted: false,
|
||||
isImage: false,
|
||||
});
|
||||
const { candidates, pending } = parseClaudeEditLines([use, res].join('\n'));
|
||||
expect(pending).toEqual([]);
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
toolUseId: 'toolu_b',
|
||||
start: Date.parse(start),
|
||||
end: Date.parse(end),
|
||||
paths: ['/work/repo/out/frame.png', '/tmp/gen/a b.png', '/work/repo/out/thumb.webp'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('yields no candidate for a Bash call without image paths', () => {
|
||||
const use = bashUseLine('toolu_n', start, 'git status');
|
||||
const res = toolResultLine('toolu_n', end, { stdout: 'clean', stderr: '', interrupted: false, isImage: false });
|
||||
expect(parseClaudeEditLines([use, res].join('\n')).candidates).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses the tool_result text when the Bash call failed (toolUseResult is a string)', () => {
|
||||
const use = bashUseLine('toolu_f', start, 'make render');
|
||||
const res = JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: end,
|
||||
cwd: CWD,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ tool_use_id: 'toolu_f', type: 'tool_result', is_error: true, content: 'Exit 2\nwrote out/x.jpg' }],
|
||||
},
|
||||
toolUseResult: 'Error: Exit 2',
|
||||
});
|
||||
expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].paths).toEqual(['/work/repo/out/x.jpg']);
|
||||
});
|
||||
|
||||
it('hands an unanswered Bash call back as pending and resolves it in the next chunk', () => {
|
||||
const first = parseClaudeEditLines(bashUseLine('toolu_p', start, 'convert a.svg out.png') + '\n');
|
||||
expect(first.candidates).toEqual([]);
|
||||
expect(first.pending).toEqual([
|
||||
{ id: 'toolu_p', ts: Date.parse(start), command: 'convert a.svg out.png', cwd: CWD },
|
||||
]);
|
||||
const res = toolResultLine('toolu_p', end, { stdout: '', stderr: '', interrupted: false, isImage: false });
|
||||
const second = parseClaudeEditLines(res, first.pending);
|
||||
expect(second.pending).toEqual([]);
|
||||
expect(second.candidates[0].paths).toEqual(['/work/repo/a.svg', '/work/repo/out.png']);
|
||||
});
|
||||
|
||||
it('carries the subagent id onto the candidate', () => {
|
||||
const use = bashUseLine('toolu_s', start, 'render > shot.png', { isSidechain: true, agentId: 'abc' });
|
||||
const res = toolResultLine('toolu_s', end, { stdout: '', stderr: '', interrupted: false, isImage: false });
|
||||
expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].agentId).toBe('abc');
|
||||
});
|
||||
|
||||
it('bounds the pending list, dropping the oldest', () => {
|
||||
const lines = Array.from({ length: MAX_PENDING_BASH + 5 }, (_, i) =>
|
||||
bashUseLine(`toolu_${i}`, start, 'sleep 1')
|
||||
).join('\n');
|
||||
const { pending } = parseClaudeEditLines(lines);
|
||||
expect(pending).toHaveLength(MAX_PENDING_BASH);
|
||||
expect(pending[0].id).toBe('toolu_5');
|
||||
});
|
||||
|
||||
it('keeps only the head of a long command (the pending list is persisted)', () => {
|
||||
const command = 'render > out.png && ' + 'x'.repeat(MAX_PENDING_COMMAND_CHARS * 2);
|
||||
const { pending } = parseClaudeEditLines(bashUseLine('toolu_long', start, command));
|
||||
expect(pending[0].command).toBe(command.slice(0, MAX_PENDING_COMMAND_CHARS));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseClaudeEditLines: images sent to the user', () => {
|
||||
it('takes the image attachments of a SendUserFile result, absolute paths only, without a window', () => {
|
||||
const line = toolResultLine(
|
||||
'toolu_send',
|
||||
'2026-10-15T20:50:12.538Z',
|
||||
{
|
||||
caption: 'Vier Alternativen',
|
||||
attachments: [
|
||||
{ path: '/tmp/s/icons/bremse_alt1_pedal.png', file_uuid: 'x' },
|
||||
{ path: '/tmp/s/icons/bremse_alt1_pedal.png' },
|
||||
{ path: '/tmp/s/report.pdf' },
|
||||
{ path: 'relative.png' },
|
||||
{ nope: 1 },
|
||||
],
|
||||
},
|
||||
{ agentId: 'a1' }
|
||||
);
|
||||
const { candidates, records } = parseClaudeEditLines(line);
|
||||
expect(records).toEqual([]);
|
||||
const at = Date.parse('2026-10-15T20:50:12.538Z');
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
toolUseId: 'toolu_send',
|
||||
start: at,
|
||||
end: at,
|
||||
paths: ['/tmp/s/icons/bremse_alt1_pedal.png'],
|
||||
sent: true,
|
||||
agentId: 'a1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractImagePathCandidates', () => {
|
||||
it('resolves relative paths against cwd and keeps ~/ paths literal', () => {
|
||||
expect(extractImagePathCandidates('cp a.png ../b.jpeg ~/shots/c.gif', '', '', CWD)).toEqual([
|
||||
'/work/repo/a.png',
|
||||
'/work/b.jpeg',
|
||||
'~/shots/c.gif',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads quoted paths with spaces', () => {
|
||||
expect(extractImagePathCandidates(`magick "in dir/x.png" 'out dir/y.avif'`, '', '', CWD)).toEqual([
|
||||
'/work/repo/in dir/x.png',
|
||||
'/work/repo/out dir/y.avif',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores URLs, globs and names that only contain an image extension', () => {
|
||||
const cmd = 'curl https://x.org/a.png; ls *.png out/frame-?.png; mv a.png.bak b.pngx; echo c.png';
|
||||
expect(extractImagePathCandidates(cmd, '', '', CWD)).toEqual(['/work/repo/c.png']);
|
||||
});
|
||||
|
||||
it('splits key=value and trailing punctuation off', () => {
|
||||
expect(extractImagePathCandidates('render --out=out/a.png', 'done (out/b.png), see out/c.png.', '', CWD)).toEqual([
|
||||
'/work/repo/out/a.png',
|
||||
'/work/repo/out/b.png',
|
||||
'/work/repo/out/c.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates and caps the list', () => {
|
||||
const many = Array.from({ length: MAX_IMAGE_CANDIDATES + 10 }, (_, i) => `f${i}.png`).join(' ');
|
||||
const out = extractImagePathCandidates(`a.png a.png ${many}`, '', '', CWD);
|
||||
expect(out).toHaveLength(MAX_IMAGE_CANDIDATES);
|
||||
expect(out[0]).toBe('/work/repo/a.png');
|
||||
expect(out[1]).toBe('/work/repo/f0.png');
|
||||
});
|
||||
|
||||
it('drops relative paths when cwd is unknown', () => {
|
||||
expect(extractImagePathCandidates('x.png /abs/y.png', '', '', '')).toEqual(['/abs/y.png']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyEditPath', () => {
|
||||
it.each([
|
||||
['/a/b.png', 'image'],
|
||||
['/a/b.JPG', 'image'],
|
||||
['/a/b.jpeg', 'image'],
|
||||
['/a/b.webp', 'image'],
|
||||
['/a/b.gif', 'image'],
|
||||
['/a/b.avif', 'image'],
|
||||
['/a/b.svg', 'image'],
|
||||
['/a/b.ts', 'code'],
|
||||
['/a/png', 'code'],
|
||||
['/a/b.png.orig', 'code'],
|
||||
])('%s → %s', (path, kind) => {
|
||||
expect(classifyEditPath(path)).toBe(kind);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* Edit history store: byte ranges, cursors, dedupe, cap, persistence.
|
||||
* Transcript bytes are built from JSONL lines shaped like real claude records.
|
||||
*/
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
EditHistoryStore,
|
||||
entryBytes,
|
||||
MAIN_SOURCE,
|
||||
MAX_AGENT_META_CHARS,
|
||||
MAX_ENTRIES,
|
||||
MAX_STORE_BYTES,
|
||||
parseAgentMeta,
|
||||
} from '../src/edit-history-store.js';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'edit-history-store-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const T0 = Date.parse('2026-10-15T19:00:00.000Z');
|
||||
const iso = (s: number) => new Date(T0 + s * 1000).toISOString();
|
||||
|
||||
function editLine(id: string, sec: number, path = `/work/${id}.ts`, extra: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: iso(sec),
|
||||
cwd: '/work',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult: {
|
||||
filePath: path,
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
originalFile: 'a',
|
||||
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
|
||||
userModified: false,
|
||||
replaceAll: false,
|
||||
},
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
/** An Edit whose diff carries `size` bytes of added text. */
|
||||
function bigEditLine(id: string, sec: number, size: number): string {
|
||||
const line = JSON.parse(editLine(id, sec));
|
||||
line.toolUseResult.structuredPatch[0].lines = ['-a', '+' + 'x'.repeat(size)];
|
||||
return JSON.stringify(line);
|
||||
}
|
||||
|
||||
function bashUse(id: string, sec: number, command: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: iso(sec),
|
||||
cwd: '/work',
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
|
||||
});
|
||||
}
|
||||
|
||||
function bashResult(id: string, sec: number): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: iso(sec),
|
||||
cwd: '/work',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
|
||||
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
|
||||
});
|
||||
}
|
||||
|
||||
const jsonl = (lines: string[]) => Buffer.from(lines.map((l) => l + '\n').join(''));
|
||||
|
||||
/** Read like the sync will: ask the store for a range, slice it out of `file`, apply. */
|
||||
function syncForward(store: EditHistoryStore, file: Buffer, budget?: number, source = MAIN_SOURCE, tid = 'conv-1') {
|
||||
const range = store.nextRange(source, file.length, tid, budget);
|
||||
if (!range) return null;
|
||||
const chunk = file.subarray(range.offset, range.offset + range.len);
|
||||
return store.apply({ transcriptId: tid, source, startOffset: range.offset, chunk, size: file.length });
|
||||
}
|
||||
|
||||
function syncOlder(store: EditHistoryStore, file: Buffer, budget?: number, source = MAIN_SOURCE, tid = 'conv-1') {
|
||||
const range = store.olderRange(source, budget);
|
||||
if (!range) return null;
|
||||
const chunk = file.subarray(range.offset, range.offset + range.len);
|
||||
return store.applyOlder({ transcriptId: tid, source, startOffset: range.offset, chunk, size: file.length });
|
||||
}
|
||||
|
||||
const ids = (store: EditHistoryStore) => store.list().map((e) => e.id);
|
||||
|
||||
describe('EditHistoryStore: forward reads', () => {
|
||||
it('reads a small file whole, then only what was appended', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
let file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||||
expect(syncForward(store, file)?.added).toBe(2);
|
||||
expect(store.getCursor(MAIN_SOURCE)).toEqual({ offset: file.length, floor: 0 });
|
||||
expect(syncForward(store, file)).toBeNull();
|
||||
file = Buffer.concat([file, jsonl([editLine('e3', 3)])]);
|
||||
expect(syncForward(store, file)?.added).toBe(1);
|
||||
expect(ids(store)).toEqual(['e3', 'e2', 'e1']);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a partial last line (still being written) for the next read', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const full = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||||
const firstLineEnd = full.indexOf(0x0a) + 1;
|
||||
const partial = full.subarray(0, firstLineEnd + 30);
|
||||
expect(syncForward(store, partial)?.added).toBe(1);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(firstLineEnd);
|
||||
expect(syncForward(store, full)?.added).toBe(1);
|
||||
expect(ids(store)).toEqual(['e2', 'e1']);
|
||||
});
|
||||
|
||||
it('deduplicates by tool_use_id', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl([editLine('e1', 1), editLine('e1', 1)]);
|
||||
expect(syncForward(store, file)?.added).toBe(1);
|
||||
expect(store.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('starts a large file near its end, drops the cut line and keeps the floor there', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const lines = Array.from({ length: 20 }, (_, i) => editLine(`e${i}`, i));
|
||||
const file = jsonl(lines);
|
||||
const budget = Math.floor(file.length / 3);
|
||||
syncForward(store, file, budget);
|
||||
const cursor = store.getCursor(MAIN_SOURCE)!;
|
||||
expect(cursor.offset).toBe(file.length);
|
||||
expect(cursor.floor).toBeGreaterThan(file.length - budget);
|
||||
expect(file[cursor.floor - 1]).toBe(0x0a);
|
||||
expect(store.backfillTruncated).toBe(true);
|
||||
expect(ids(store)[0]).toBe('e19');
|
||||
expect(ids(store)).not.toContain('e0');
|
||||
});
|
||||
|
||||
it('reads a backlog in budget-sized forward steps without losing a line', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const first = jsonl([editLine('e0', 0)]);
|
||||
syncForward(store, first);
|
||||
const more = Array.from({ length: 30 }, (_, i) => editLine(`e${i + 1}`, i + 1));
|
||||
const file = Buffer.concat([first, jsonl(more)]);
|
||||
const budget = 700;
|
||||
for (let i = 0; i < 100 && syncForward(store, file, budget); i++);
|
||||
expect(store.list()).toHaveLength(31);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
|
||||
});
|
||||
|
||||
it('skips a line longer than the budget instead of re-reading it forever', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const huge = editLine('big', 1, '/work/big.ts', { pad: 'x'.repeat(5000) });
|
||||
const first = jsonl([editLine('e0', 0)]);
|
||||
syncForward(store, first);
|
||||
const file = Buffer.concat([first, jsonl([huge, editLine('e2', 2)])]);
|
||||
for (let i = 0; i < 50 && syncForward(store, file, 1000); i++);
|
||||
expect(ids(store)).toEqual(['e2', 'e0']);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
|
||||
});
|
||||
|
||||
it('keeps a multibyte character split by the chunk boundary intact', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const path = '/work/Größe – Ümlaut 😀.ts';
|
||||
const file = jsonl([editLine('e1', 1), editLine('e2', 2, path)]);
|
||||
const emoji = file.indexOf(Buffer.from('😀'));
|
||||
// Cut inside the 4-byte emoji, then continue.
|
||||
const cut = file.subarray(0, emoji + 2);
|
||||
syncForward(store, cut);
|
||||
syncForward(store, file);
|
||||
expect(store.get('e2')?.path).toBe(path);
|
||||
});
|
||||
|
||||
it('restarts a file that shrank (replaced) from its end', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
|
||||
const replaced = jsonl([editLine('n1', 3)]);
|
||||
expect(syncForward(store, replaced)?.added).toBe(1);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(replaced.length);
|
||||
});
|
||||
|
||||
it('ignores a chunk that does not start at the cursor', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||||
syncForward(store, file);
|
||||
const res = store.apply({ transcriptId: 'conv-1', source: MAIN_SOURCE, startOffset: 3, chunk: file, size: 999 });
|
||||
expect(res.added).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: conversations and sources', () => {
|
||||
it('starts new cursors for a new transcriptId and keeps the old entries', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
const second = jsonl([editLine('f1', 5)]);
|
||||
expect(store.nextRange(MAIN_SOURCE, second.length, 'conv-2')).toEqual({ offset: 0, len: second.length });
|
||||
syncForward(store, second, undefined, MAIN_SOURCE, 'conv-2');
|
||||
expect(store.getTranscriptId()).toBe('conv-2');
|
||||
expect(ids(store)).toEqual(['f1', 'e1']);
|
||||
});
|
||||
|
||||
it('keeps one cursor per source and the subagent id on its entries', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const main = jsonl([editLine('m1', 1)]);
|
||||
const sub = jsonl([editLine('s1', 2, '/work/s.ts', { isSidechain: true, agentId: 'a1' })]);
|
||||
const subSource = 'subagents/agent-a1.jsonl';
|
||||
syncForward(store, main);
|
||||
syncForward(store, sub, undefined, subSource);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(main.length);
|
||||
expect(store.getCursor(subSource)?.offset).toBe(sub.length);
|
||||
expect(store.get('s1')?.agentId).toBe('a1');
|
||||
});
|
||||
|
||||
it('records agent meta from meta.json text', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
store.setAgentMeta(
|
||||
'a1',
|
||||
JSON.stringify({
|
||||
agentType: 'coding',
|
||||
description: 'Impl: fix',
|
||||
worktreePath: '/w/.claude/worktrees/agent-a1',
|
||||
worktreeBranch: 'worktree-agent-a1',
|
||||
toolUseId: 'toolu_x',
|
||||
})
|
||||
);
|
||||
store.setAgentMeta('a2', 'not json');
|
||||
expect(store.getAgents()).toEqual({
|
||||
a1: {
|
||||
description: 'Impl: fix',
|
||||
agentType: 'coding',
|
||||
worktreePath: '/w/.claude/worktrees/agent-a1',
|
||||
worktreeBranch: 'worktree-agent-a1',
|
||||
},
|
||||
});
|
||||
expect(store.hasAgentMeta('a2')).toBe(false);
|
||||
expect(parseAgentMeta('{"description":"d"}')).toEqual({ description: 'd', agentType: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: older blocks', () => {
|
||||
it('loads older lines block by block down to the start, without duplicates at the seam', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: 30 }, (_, i) => editLine(`e${i}`, i)));
|
||||
const budget = Math.floor(file.length / 4);
|
||||
syncForward(store, file, budget);
|
||||
const before = store.list().length;
|
||||
expect(before).toBeLessThan(30);
|
||||
let rounds = 0;
|
||||
while (syncOlder(store, file, budget) && rounds < 20) rounds++;
|
||||
expect(rounds).toBeGreaterThan(1);
|
||||
expect(store.list()).toHaveLength(30);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.floor).toBe(0);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
expect(store.olderRange(MAIN_SOURCE, budget)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses an older chunk that does not end at the floor', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`e${i}`, i)));
|
||||
syncForward(store, file, 300);
|
||||
const res = store.applyOlder({
|
||||
transcriptId: 'conv-1',
|
||||
source: MAIN_SOURCE,
|
||||
startOffset: 0,
|
||||
chunk: file.subarray(0, 10),
|
||||
size: file.length,
|
||||
});
|
||||
expect(res.added).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: Bash images', () => {
|
||||
it('pairs a Bash call and its result across a chunk boundary', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const part1 = jsonl([bashUse('b1', 1, 'python render.py -o out/frame.png')]);
|
||||
const file = Buffer.concat([part1, jsonl([bashResult('b1', 4)])]);
|
||||
expect(syncForward(store, part1)?.candidates).toEqual([]);
|
||||
const res = syncForward(store, file)!;
|
||||
expect(res.candidates).toEqual([
|
||||
{ toolUseId: 'b1', start: T0 + 1000, end: T0 + 4000, paths: ['/work/out/frame.png'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds confirmed images as entries without a diff, once', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const img = { toolUseId: 'b1', path: '/work/out/frame.png', ts: T0 + 4000, agentId: 'a1' };
|
||||
expect(store.addImageEntries([img, img])).toBe(1);
|
||||
expect(store.addImageEntries([img])).toBe(0);
|
||||
expect(store.list()[0]).toEqual({
|
||||
id: 'b1:/work/out/frame.png',
|
||||
ts: T0 + 4000,
|
||||
path: '/work/out/frame.png',
|
||||
kind: 'image',
|
||||
tool: 'bash',
|
||||
created: false,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
hunks: [],
|
||||
truncated: false,
|
||||
agentId: 'a1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: cap', () => {
|
||||
it(`keeps the newest ${MAX_ENTRIES} entries`, () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i)));
|
||||
const res = syncForward(store, file, file.length);
|
||||
expect(res?.added).toBe(MAX_ENTRIES);
|
||||
expect(store.list()).toHaveLength(MAX_ENTRIES);
|
||||
expect(store.get('e19')).toBeUndefined();
|
||||
expect(store.get('e20')).toBeDefined();
|
||||
// Older than everything kept: counts as nothing added.
|
||||
expect(store.addImageEntries([{ toolUseId: 'old', path: '/a.png', ts: T0 - 1000 }])).toBe(0);
|
||||
});
|
||||
|
||||
it('offers no older block once the cap is full (it would be dropped on read)', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const lines = Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i));
|
||||
const file = jsonl(lines);
|
||||
const tail = jsonl(lines.slice(-MAX_ENTRIES)).length;
|
||||
syncForward(store, file, tail + 10);
|
||||
expect(store.list()).toHaveLength(MAX_ENTRIES);
|
||||
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it(`keeps at most ${MAX_STORE_BYTES} bytes of diff text, dropping the oldest`, () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const n = 300;
|
||||
const file = jsonl(Array.from({ length: n }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
|
||||
syncForward(store, file, file.length);
|
||||
const kept = store.list();
|
||||
expect(kept.length).toBeLessThan(n);
|
||||
expect(kept.reduce((sum, e) => sum + entryBytes(e), 0)).toBeLessThanOrEqual(MAX_STORE_BYTES);
|
||||
expect(store.get(`e${n - 1}`)).toBeDefined();
|
||||
expect(store.get('e0')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('offers no older block once the byte cap is full', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: 300 }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
|
||||
syncForward(store, file, 5_000_000);
|
||||
expect(store.list().length).toBeLessThan(MAX_ENTRIES);
|
||||
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the agent meta strings', () => {
|
||||
const meta = parseAgentMeta(JSON.stringify({ description: 'd'.repeat(5000), agentType: 'coding' }));
|
||||
expect(meta?.description).toHaveLength(MAX_AGENT_META_CHARS);
|
||||
expect(meta?.agentType).toBe('coding');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: persistence', () => {
|
||||
it('reloads entries, cursors, pending Bash calls and agents from disk', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
const file = jsonl([editLine('e1', 1), bashUse('b1', 2, 'render > a.png')]);
|
||||
syncForward(store, file);
|
||||
store.setAgentMeta('a1', '{"description":"d","agentType":"coding"}');
|
||||
await store.flush();
|
||||
const loaded = await EditHistoryStore.load('s1', { dir });
|
||||
expect(ids(loaded)).toEqual(['e1']);
|
||||
expect(loaded.getCursor(MAIN_SOURCE)).toEqual(store.getCursor(MAIN_SOURCE));
|
||||
expect(loaded.getTranscriptId()).toBe('conv-1');
|
||||
expect(loaded.getAgents()).toEqual({ a1: { description: 'd', agentType: 'coding' } });
|
||||
const done = Buffer.concat([file, jsonl([bashResult('b1', 3)])]);
|
||||
expect(syncForward(loaded, done)?.candidates[0]?.paths).toEqual(['/work/a.png']);
|
||||
});
|
||||
|
||||
it('writes the file 0600 in a 0700 directory, also over a wider existing file', async () => {
|
||||
const sub = join(dir, 'edit-history');
|
||||
const store = new EditHistoryStore('s1', { dir: sub, debounceMs: 60_000 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
await store.flush();
|
||||
expect(statSync(sub).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
|
||||
writeFileSync(join(sub, 's1.json'), '{}', { mode: 0o644 });
|
||||
await store.flush();
|
||||
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('applies the caps and drops malformed entries when loading', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
syncForward(store, jsonl(Array.from({ length: MAX_ENTRIES }, (_, i) => editLine(`e${i}`, i))));
|
||||
await store.flush();
|
||||
const data = JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8'));
|
||||
const extra = JSON.parse(editLine('late', MAX_ENTRIES + 1));
|
||||
data.entries.push(
|
||||
{ ...store.get('e0')!, id: 'newest', ts: T0 + 10_000_000 },
|
||||
{ id: 'broken', path: 7, hunks: [] },
|
||||
{ id: 'nohunks', path: '/a.ts' },
|
||||
extra
|
||||
);
|
||||
writeFileSync(join(dir, 's1.json'), JSON.stringify(data));
|
||||
const loaded = await EditHistoryStore.load('s1', { dir });
|
||||
expect(loaded.list()).toHaveLength(MAX_ENTRIES);
|
||||
expect(loaded.get('newest')).toBeDefined();
|
||||
expect(loaded.get('e0')).toBeUndefined();
|
||||
expect(loaded.get('broken')).toBeUndefined();
|
||||
expect(loaded.get('nohunks')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a file of an unknown version', async () => {
|
||||
writeFileSync(join(dir, 'v9.json'), JSON.stringify({ version: 9, entries: [{ id: 'x' }] }));
|
||||
expect((await EditHistoryStore.load('v9', { dir })).list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes debounced', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 20 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
const path = join(dir, 's1.json');
|
||||
expect(existsSync(path)).toBe(false);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
expect(JSON.parse(readFileSync(path, 'utf-8')).entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('starts empty from a missing or corrupt file', async () => {
|
||||
expect((await EditHistoryStore.load('none', { dir })).list()).toEqual([]);
|
||||
const store = new EditHistoryStore('bad', { dir });
|
||||
await store.flush();
|
||||
writeFileSync(join(dir, 'bad.json'), '{broken');
|
||||
expect((await EditHistoryStore.load('bad', { dir })).list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('remove() deletes the file and stops later writes', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 10 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
await store.flush();
|
||||
await store.remove();
|
||||
expect(existsSync(join(dir, 's1.json'))).toBe(false);
|
||||
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
await store.flush();
|
||||
expect(existsSync(join(dir, 's1.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to the data directory', () => {
|
||||
const store = new EditHistoryStore('s1');
|
||||
expect((store as unknown as { file: string }).file).toMatch(/edit-history[\\/]s1\.json$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Edit history sync: reader, store, image confirmation, single-flight, failure states and the
|
||||
* "reads only on request" wiring. Reader and probe are fakes; the store is real (temp dir).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { EditHistoryStore } from '../src/edit-history-store.js';
|
||||
import type { EditSourceRequest, EditSources, PathProbe } from '../src/edit-history-local-source.js';
|
||||
import {
|
||||
confirmImages,
|
||||
EditHistorySync,
|
||||
NO_TRANSCRIPT,
|
||||
type EditHistorySession,
|
||||
type EditHistorySyncDeps,
|
||||
unsupportedReason,
|
||||
} from '../src/web/edit-history-sync.js';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'edit-sync-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
|
||||
|
||||
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
|
||||
return (
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date(ts).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult: {
|
||||
filePath: path,
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
|
||||
},
|
||||
...extra,
|
||||
}) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
function bashPair(id: string, start: number, end: number, command: string): string {
|
||||
const use = JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: new Date(start).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
|
||||
});
|
||||
const res = JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date(end).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
|
||||
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
|
||||
});
|
||||
return use + '\n' + res + '\n';
|
||||
}
|
||||
|
||||
/** An in-memory transcript: files by relPath, served with the reader contract. */
|
||||
class FakeHost {
|
||||
transcriptId = 'conv1';
|
||||
files = new Map<string, Buffer>();
|
||||
metas: Record<string, string> = {};
|
||||
calls: EditSourceRequest[][] = [];
|
||||
paths: string[] = [];
|
||||
/** What `transcriptPath` answers (the transcript watcher's path); null = none yet. */
|
||||
get path(): string | null {
|
||||
return this.files.size > 0 ? `/p/${this.transcriptId}.jsonl` : null;
|
||||
}
|
||||
read = async (
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budget: number
|
||||
): Promise<EditSources | null> => {
|
||||
this.calls.push([...requests]);
|
||||
this.paths.push(mainPath);
|
||||
if (this.files.size === 0) return null;
|
||||
let remaining = budget;
|
||||
const out: EditSources = { transcriptId: this.transcriptId, files: [], metas: {} };
|
||||
for (const [relPath, buf] of this.files) {
|
||||
const req = requests.find((r) => r.relPath === relPath);
|
||||
let start: number;
|
||||
let len: number;
|
||||
if (req) {
|
||||
start = Math.min(req.offset, buf.length);
|
||||
len = Math.min(req.len ?? remaining, remaining, buf.length - start);
|
||||
} else {
|
||||
len = Math.min(buf.length, remaining);
|
||||
start = buf.length - len;
|
||||
const agent = /^subagents\/agent-(.+)\.jsonl$/.exec(relPath)?.[1];
|
||||
if (agent && this.metas[agent]) out.metas[agent] = this.metas[agent];
|
||||
}
|
||||
remaining -= len;
|
||||
out.files.push({ relPath, size: buf.length, startOffset: start, chunk: buf.subarray(start, start + len) });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
append(relPath: string, text: string): void {
|
||||
this.files.set(relPath, Buffer.concat([this.files.get(relPath) ?? Buffer.alloc(0), Buffer.from(text)]));
|
||||
}
|
||||
}
|
||||
|
||||
function setup(over: Partial<EditHistorySyncDeps> = {}) {
|
||||
const host = new FakeHost();
|
||||
const probes = new Map<string, PathProbe>();
|
||||
const lookups: string[] = [];
|
||||
const deps: EditHistorySyncDeps = {
|
||||
transcriptPath: async (session) => {
|
||||
lookups.push(session.id);
|
||||
return host.path;
|
||||
},
|
||||
readSources: (path, req, budget) => host.read(path, req, budget),
|
||||
probe: async (paths) => paths.map((p) => probes.get(p) ?? null),
|
||||
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
|
||||
...over,
|
||||
};
|
||||
return { host, probes, lookups, deps, sync: new EditHistorySync(deps) };
|
||||
}
|
||||
|
||||
const local: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
|
||||
const remote: EditHistorySession = {
|
||||
id: 's2',
|
||||
mode: 'claude',
|
||||
remote: { host: 'huff' } as EditHistorySession['remote'],
|
||||
claudeSessionId: 'conv1',
|
||||
};
|
||||
|
||||
describe('unsupportedReason', () => {
|
||||
it('accepts a local claude session, rejects remote, docker and non-claude CLIs', () => {
|
||||
expect(unsupportedReason(local)).toBeUndefined();
|
||||
expect(unsupportedReason(remote)).toBe('remote');
|
||||
expect(unsupportedReason({ ...local, docker: { image: 'x' } })).toBe('docker');
|
||||
expect(unsupportedReason({ ...local, mode: 'shell' })).toBe('mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistorySync', () => {
|
||||
it('reads new edits forward from the stored offset', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
expect(await sync.sync(local)).toEqual({ state: 'ok' });
|
||||
host.append('main', editLine('t2', T0 + 1000, '/w/b.ts'));
|
||||
await sync.sync(local);
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t2', 't1']);
|
||||
expect(host.calls[1]).toEqual([
|
||||
{ relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1000, '/w/b.ts').length },
|
||||
]);
|
||||
expect(host.paths).toEqual(['/p/conv1.jsonl', '/p/conv1.jsonl']);
|
||||
});
|
||||
|
||||
it('reads subagent transcripts and records their meta', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
host.append('subagents/agent-a1.jsonl', editLine('t9', T0 + 5, '/w/sub.ts', { isSidechain: true, agentId: 'a1' }));
|
||||
host.metas.a1 = '{"description":"Fix it","agentType":"coding"}';
|
||||
await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.get('t9')?.agentId).toBe('a1');
|
||||
expect(store.getAgents().a1).toEqual({ description: 'Fix it', agentType: 'coding' });
|
||||
});
|
||||
|
||||
it('reads a new conversation (/clear, resume) from its end, keeping the entries', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
host.transcriptId = 'conv2';
|
||||
host.files.set('main', Buffer.from(editLine('n1', T0 + 9, '/w/n.ts')));
|
||||
await sync.sync(local);
|
||||
expect(host.paths.at(-1)).toBe('/p/conv2.jsonl');
|
||||
expect(host.calls.at(-1)).toEqual([]); // no offsets of the old file
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.getTranscriptId()).toBe('conv2');
|
||||
expect(store.list().map((e) => e.id)).toEqual(['n1', 't1']);
|
||||
});
|
||||
|
||||
it('keeps reading in passes when a file grew by more than one budget', async () => {
|
||||
const { host, sync } = setup({ budget: 600 });
|
||||
host.append('main', editLine('t0', T0, '/w/z.ts'));
|
||||
await sync.sync(local);
|
||||
for (let i = 1; i <= 3; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`));
|
||||
await sync.sync(local);
|
||||
expect((await sync.getStore('s1')).list()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('turns a Bash image into an entry only when its mtime lies in the call window', async () => {
|
||||
const { host, probes, sync } = setup();
|
||||
host.append('main', bashPair('b1', T0, T0 + 5000, 'render out/new.png old.png ~/x.png'));
|
||||
probes.set('/w/out/new.png', { realPath: '/w/out/new.png', kind: 'file', size: 9, mtimeMs: T0 + 4000 });
|
||||
probes.set('/w/old.png', { realPath: '/w/old.png', kind: 'file', size: 9, mtimeMs: T0 - 60_000 });
|
||||
probes.set('~/x.png', { realPath: '/home/u/x.png', kind: 'file', size: 9, mtimeMs: T0 + 6500 });
|
||||
await sync.sync(local);
|
||||
expect((await sync.getStore('s1')).list().map((e) => [e.path, e.tool, e.kind])).toEqual([
|
||||
['/w/out/new.png', 'bash', 'image'],
|
||||
['/home/u/x.png', 'bash', 'image'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the edits when only the image probe fails', async () => {
|
||||
const { host, sync } = setup({
|
||||
probe: async () => {
|
||||
throw new Error('probe failed');
|
||||
},
|
||||
});
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts') + bashPair('b1', T0, T0 + 1, 'x out.png'));
|
||||
expect(await sync.sync(local)).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
|
||||
});
|
||||
|
||||
it('reports a session without a transcript yet, then reads it once there is one', async () => {
|
||||
const { host, sync } = setup();
|
||||
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT });
|
||||
expect(host.calls).toEqual([]);
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
expect(await sync.sync(local)).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports a transcript that vanished (watcher path stale) the same way', async () => {
|
||||
const { host, sync } = setup({ transcriptPath: async () => '/p/gone.jsonl' });
|
||||
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT });
|
||||
expect(host.paths).toEqual(['/p/gone.jsonl']);
|
||||
});
|
||||
|
||||
it('reports a read error as unavailable and keeps the stored entries', async () => {
|
||||
const { host, sync, deps } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
deps.readSources = async () => {
|
||||
throw new Error('EACCES: permission denied');
|
||||
};
|
||||
const status = await sync.sync(local);
|
||||
expect(status).toEqual({ state: 'unavailable', reason: 'EACCES: permission denied' });
|
||||
expect((await sync.getStore('s1')).list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('marks remote, docker and non-claude sessions unsupported without looking for a transcript', async () => {
|
||||
const { host, lookups, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
expect(await sync.sync({ ...local, mode: 'shell' })).toEqual({ state: 'unsupported', reason: 'mode' });
|
||||
expect(await sync.sync(remote)).toEqual({ state: 'unsupported', reason: 'remote' });
|
||||
expect(await sync.sync({ ...local, docker: {} })).toEqual({ state: 'unsupported', reason: 'docker' });
|
||||
expect(lookups).toEqual([]);
|
||||
expect(host.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('runs one sync per session at a time and folds occasions into one more run', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const orig = host.read;
|
||||
host.read = async (...a) => {
|
||||
await gate;
|
||||
return orig(...a);
|
||||
};
|
||||
const runs = [sync.sync(local), sync.sync(local), sync.sync(local)];
|
||||
release();
|
||||
await Promise.all(runs);
|
||||
expect(host.calls).toHaveLength(2); // the first run + one folded follow-up
|
||||
});
|
||||
|
||||
it('loads older blocks on request', async () => {
|
||||
const { host, sync } = setup({ budget: 400 });
|
||||
for (let i = 0; i < 4; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`));
|
||||
await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
const before = store.list().length;
|
||||
expect(store.backfillTruncated).toBe(true);
|
||||
await sync.sync(local, { older: true });
|
||||
expect(store.list().length).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it('flushAll writes pending saves (shutdown) so a reload keeps the entries', async () => {
|
||||
const { host, sync } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) });
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
await sync.flushAll();
|
||||
const reloaded = await EditHistoryStore.load('s1', { dir });
|
||||
expect(reloaded.list().map((e) => e.id)).toEqual(['t1']);
|
||||
});
|
||||
|
||||
it('remove deletes the stored history', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
await (await sync.getStore('s1')).flush();
|
||||
await sync.remove('s1');
|
||||
expect(() => readFileSync(join(dir, 's1.json'))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmImages', () => {
|
||||
it('ignores directories and missing files', () => {
|
||||
const c = [{ toolUseId: 'b', start: T0, end: T0 + 1, paths: ['/d.png', '/m.png'] }];
|
||||
const out = confirmImages(c, (p) =>
|
||||
p === '/d.png' ? { realPath: p, kind: 'directory', size: 0, mtimeMs: T0 } : null
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists a file sent to the user outside any window, once per file version', () => {
|
||||
const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 };
|
||||
const sent = { toolUseId: 's', start: T0 + 9000, end: T0 + 9000, paths: ['/w/p.png'], sent: true };
|
||||
expect(confirmImages([sent], () => png)).toEqual([
|
||||
{ toolUseId: 's', path: '/w/p.png', ts: T0 + 9000, tool: 'sent' },
|
||||
]);
|
||||
// The Bash call that wrote it was already listed: same version, not again.
|
||||
expect(
|
||||
confirmImages(
|
||||
[sent],
|
||||
() => png,
|
||||
() => T0 + 500
|
||||
)
|
||||
).toEqual([]);
|
||||
expect(confirmImages([sent], () => null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a file version already recorded: the ls right after the render, a listing after a Write', () => {
|
||||
const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 + 900 };
|
||||
const render = { toolUseId: 'r', start: T0, end: T0 + 1000, paths: ['/w/p.png'] };
|
||||
const ls = { toolUseId: 'l', start: T0 + 1500, end: T0 + 1600, paths: ['/w/p.png'] };
|
||||
expect(confirmImages([ls, render], () => png).map((i) => i.toolUseId)).toEqual(['r']);
|
||||
// A Write entry for the same path at T0 + 950 already covers it.
|
||||
expect(
|
||||
confirmImages(
|
||||
[ls],
|
||||
() => png,
|
||||
() => T0 + 950
|
||||
)
|
||||
).toEqual([]);
|
||||
// A later re-render (newer mtime, outside the slack) is a new version.
|
||||
const again = { toolUseId: 'r2', start: T0 + 10_000, end: T0 + 11_000, paths: ['/w/p.png'] };
|
||||
const newer = { ...png, mtimeMs: T0 + 10_500 };
|
||||
expect(
|
||||
confirmImages(
|
||||
[again],
|
||||
() => newer,
|
||||
() => T0 + 1000
|
||||
).map((i) => i.toolUseId)
|
||||
).toEqual(['r2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reads only on request (wiring guard)', () => {
|
||||
const read = (file: string) => readFileSync(join(__dirname, '..', file), 'utf-8');
|
||||
|
||||
it('the server never starts a sync itself: it only hands the reader to the routes', () => {
|
||||
const server = read('src/web/server.ts');
|
||||
const uses = [...server.matchAll(/this\.editHistorySync\b(\.\w+)?/g)].map((m) => m[1] ?? '');
|
||||
expect(uses.sort()).toEqual(['', '.flushAll', '.remove']);
|
||||
expect(server).toMatch(/registerEditHistoryRoutes\(this\.app, ctx, this\.editHistorySync\)/);
|
||||
});
|
||||
|
||||
it('no other module holds the reader, and the edit history modules run no timer or watcher', () => {
|
||||
for (const file of ['src/web/session-listener-wiring.ts', 'src/web/routes/hook-event-routes.ts']) {
|
||||
expect(read(file)).not.toMatch(/editHistory/i);
|
||||
}
|
||||
for (const file of [
|
||||
'src/web/edit-history-sync.ts',
|
||||
'src/edit-history-store.ts',
|
||||
'src/edit-history-local-source.ts',
|
||||
'src/edit-history-parse.ts',
|
||||
'src/web/routes/edit-history-routes.ts',
|
||||
]) {
|
||||
const src = read(file);
|
||||
expect(src, file).not.toMatch(/setInterval|\bwatch\(|chokidar/);
|
||||
}
|
||||
// The store's only timer is its debounced save.
|
||||
expect(read('src/web/edit-history-sync.ts')).not.toMatch(/setTimeout/);
|
||||
});
|
||||
});
|
||||
|
||||
vi.setConfig({ testTimeout: 10_000 });
|
||||
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* @fileoverview Edit history button and panel, end to end in a real browser: real server, real
|
||||
* Chromium. The edits API is mocked with `page.route` (the UI is built against its contract); the
|
||||
* thumbnails (workspace `file-raw`, the attachment route outside it) and the transcript SSE
|
||||
* events the open panel follows go through the real server.
|
||||
*/
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page, type Route } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
/** A 2×2 red PNG, so the thumbnail has real pixels to decode. */
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
ts: number;
|
||||
path: string;
|
||||
relPath: string;
|
||||
kind: 'code' | 'image';
|
||||
tool: string;
|
||||
created: boolean;
|
||||
added: number;
|
||||
removed: number;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
describe('Edit history panel in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let root: string;
|
||||
/** A directory outside the session's workspace, for the attachment-route thumbnail. */
|
||||
let ext: string;
|
||||
let sessionA: string;
|
||||
let sessionB: string;
|
||||
const settingsPutStatuses: number[] = [];
|
||||
/** Bodies of POST /api/sessions/:id/attachments (the external thumbnail registration). */
|
||||
const attachmentPosts: unknown[] = [];
|
||||
/** Every request to the mocked API: `METHOD url`. */
|
||||
const calls: string[] = [];
|
||||
/** The mocked server state per session id. */
|
||||
const state = new Map<string, Record<string, unknown>>();
|
||||
let diffs: Record<string, { diff: string; truncated: boolean }> = {};
|
||||
let olderEntries: Entry[] = [];
|
||||
|
||||
const entry = (over: Partial<Entry> & { id: string; relPath: string }): Entry => ({
|
||||
ts: Date.now(),
|
||||
path: join(root, over.relPath),
|
||||
kind: 'code',
|
||||
tool: 'Edit',
|
||||
created: false,
|
||||
added: 1,
|
||||
removed: 1,
|
||||
...over,
|
||||
});
|
||||
|
||||
const json = (route: Route, data: unknown) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data }) });
|
||||
|
||||
const buttonVisible = () =>
|
||||
page.evaluate(() => getComputedStyle(document.getElementById('editHistoryBtn')!).display !== 'none');
|
||||
const bodyText = async () => (await page.textContent('#editHistoryBody')) ?? '';
|
||||
const setSetting = async (on: boolean) => {
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
if ((await page.isChecked('#appSettingsShowEditHistory')) !== on)
|
||||
await page.click('label.set-chip:has(#appSettingsShowEditHistory)');
|
||||
await page.evaluate(() => (window as any).app.saveAppSettings());
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => (window as any).app.closeAppSettings());
|
||||
};
|
||||
const createSession = (dir: string) =>
|
||||
page.evaluate(async (workingDir) => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id as string;
|
||||
}, dir);
|
||||
const select = (id: string) =>
|
||||
page.evaluate(async (sid) => {
|
||||
const app = (window as any).app;
|
||||
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
|
||||
await app.selectSession(sid);
|
||||
}, id);
|
||||
const reload = () => page.evaluate(() => (window as any).app.refreshEditHistory());
|
||||
const syncReads = (sid: string) => calls.filter((c) => c === `GET /api/sessions/${sid}/edits?sync=1`).length;
|
||||
const toolEnd = (sessionId: string, toolName: string) =>
|
||||
(server as any).broadcast('transcript:tool_end', { sessionId, toolName, isError: false, timestamp: Date.now() });
|
||||
const rows = () => page.locator('#editHistoryBody .edit-history-entry');
|
||||
|
||||
beforeAll(async () => {
|
||||
root = mkdtempSync(join(homedir(), 'edit-history-ui-'));
|
||||
writeFileSync(join(root, 'shot.png'), PNG);
|
||||
ext = mkdtempSync(join(homedir(), 'edit-history-ext-'));
|
||||
writeFileSync(join(ext, 'render.png'), PNG);
|
||||
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
page.on('response', (r) => {
|
||||
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
|
||||
});
|
||||
page.on('request', (r) => {
|
||||
if (r.method() === 'POST' && /\/api\/sessions\/[^/]+\/attachments$/.test(r.url()))
|
||||
attachmentPosts.push(r.postDataJSON());
|
||||
});
|
||||
await page.route('**/api/sessions/*/edits**', async (route) => {
|
||||
const req = route.request();
|
||||
const url = new URL(req.url());
|
||||
calls.push(`${req.method()} ${url.pathname}${url.search}`);
|
||||
const m = url.pathname.match(/\/api\/sessions\/([^/]+)\/edits(?:\/(.+))?$/)!;
|
||||
const sid = decodeURIComponent(m[1]);
|
||||
const rest = m[2] ? decodeURIComponent(m[2]) : '';
|
||||
const s = state.get(sid) ?? { state: 'unsupported', backfillTruncated: false, agents: {}, entries: [] };
|
||||
if (req.method() === 'POST' && rest === 'older') {
|
||||
const entries = [...(s.entries as Entry[]), ...olderEntries];
|
||||
state.set(sid, { ...s, entries, backfillTruncated: false });
|
||||
return json(route, state.get(sid));
|
||||
}
|
||||
if (req.method() === 'GET' && rest) {
|
||||
const d = diffs[rest];
|
||||
if (!d) return route.fulfill({ status: 404, contentType: 'application/json', body: '{"success":false}' });
|
||||
return json(route, d);
|
||||
}
|
||||
return json(route, s);
|
||||
});
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
sessionA = await createSession(root);
|
||||
sessionB = await createSession(root);
|
||||
await select(sessionA);
|
||||
|
||||
const t0 = Date.now();
|
||||
state.set(sessionA, {
|
||||
state: 'ok',
|
||||
backfillTruncated: true,
|
||||
agents: {
|
||||
agent1: { description: 'Refactor the parser', agentType: 'general-purpose', worktreeBranch: 'wt/parser' },
|
||||
},
|
||||
entries: [
|
||||
entry({ id: 'e4', ts: t0, relPath: 'src/a.ts', added: 3, removed: 1 }),
|
||||
entry({ id: 'e3', ts: t0 - 1000, relPath: 'shot.png', kind: 'image', tool: 'bash', created: true }),
|
||||
entry({
|
||||
id: 'e2',
|
||||
ts: t0 - 2000,
|
||||
relPath: 'src/b.ts',
|
||||
created: true,
|
||||
tool: 'Write',
|
||||
added: 10,
|
||||
removed: 0,
|
||||
agentId: 'agent1',
|
||||
}),
|
||||
entry({ id: 'e1', ts: t0 - 3000, relPath: 'src/a.ts', added: 2, removed: 2 }),
|
||||
],
|
||||
});
|
||||
diffs = {
|
||||
e4: { diff: '@@ -1,3 +1,3 @@\n context\n-old line\n+new line\n+another\n', truncated: true },
|
||||
e1: { diff: '@@ -5 +5 @@\n-x\n+y\n', truncated: false },
|
||||
};
|
||||
olderEntries = [entry({ id: 'e0', ts: t0 - 86_400_000, relPath: 'src/old.ts', added: 7, removed: 0 })];
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
rmSync(ext, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
it('is off by default: no button, and nothing asks the edits API', async () => {
|
||||
await page.waitForTimeout(1000);
|
||||
expect(await buttonVisible()).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('turning it on through Settings shows the button and saves cleanly (the key never reaches the strict PUT)', async () => {
|
||||
await setSetting(true);
|
||||
expect(settingsPutStatuses.length).toBeGreaterThan(0);
|
||||
expect(settingsPutStatuses.every((s) => s === 200)).toBe(true);
|
||||
expect(await buttonVisible()).toBe(true);
|
||||
// In the header next to Attachments, where the panel opens; no longer in the bottom bar.
|
||||
expect(await page.evaluate(() => !!document.querySelector('#headerRight #editHistoryBtn'))).toBe(true);
|
||||
expect(await page.evaluate(() => !!document.querySelector('.toolbar #editHistoryBtn'))).toBe(false);
|
||||
expect(calls).toEqual([]); // no poll: nothing is read until the panel opens
|
||||
});
|
||||
|
||||
it('opens a panel listing the entries newest first, with the subagent chip and an image thumbnail', async () => {
|
||||
await page.click('#editHistoryBtn');
|
||||
await page.waitForSelector('#editHistoryPanel.visible');
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-entry');
|
||||
expect(calls[0]).toBe(`GET /api/sessions/${sessionA}/edits?sync=1`);
|
||||
expect(await page.getAttribute('#editHistoryBtn', 'aria-expanded')).toBe('true');
|
||||
expect(await rows().count()).toBe(4);
|
||||
const paths = await page.locator('#editHistoryBody .edit-history-path').allTextContents();
|
||||
expect(paths).toEqual(['src/a.ts', 'shot.png', 'src/b.ts', 'src/a.ts']);
|
||||
expect(await rows().nth(0).locator('.edit-history-badge').textContent()).toBe('edit');
|
||||
expect(await rows().nth(2).locator('.edit-history-badge').textContent()).toBe('new');
|
||||
expect(await rows().nth(0).locator('.edit-history-stats').textContent()).toBe('+3−1');
|
||||
// The image row: a symbol instead of a word, no +/− counts.
|
||||
expect(await rows().nth(1).locator('.edit-history-badge--image svg').count()).toBe(1);
|
||||
expect(await rows().nth(1).locator('.edit-history-stats').count()).toBe(0);
|
||||
// The subagent chip: its description, and type + branch on hover.
|
||||
const chip = rows().nth(2).locator('.edit-history-agent');
|
||||
expect(await chip.textContent()).toBe('Refactor the parser');
|
||||
expect(await chip.getAttribute('title')).toBe('general-purpose · wt/parser');
|
||||
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(1);
|
||||
// The thumbnail is served by the real file-raw route and decodes.
|
||||
const thumb = page.locator('#editHistoryBody img.edit-history-thumb');
|
||||
expect(await thumb.count()).toBe(1);
|
||||
expect(await thumb.getAttribute('src')).toBe(
|
||||
`/api/sessions/${sessionA}/file-raw?path=${encodeURIComponent(join(root, 'shot.png'))}`
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('img.edit-history-thumb') as HTMLImageElement)?.naturalWidth === 2
|
||||
);
|
||||
expect(await page.textContent('#editHistoryCount')).toBe('4');
|
||||
});
|
||||
|
||||
it('a code entry opens its diff inline with the Git panel line classes, and closes again', async () => {
|
||||
await rows().nth(0).locator('.edit-history-spacer').click();
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-diff pre.git-diff');
|
||||
expect(calls).toContain(`GET /api/sessions/${sessionA}/edits/e4`);
|
||||
const lines = await page.$$eval('#editHistoryBody pre.git-diff .git-diff-line', (els) =>
|
||||
els.map((e) => [e.className.replace('git-diff-line', '').trim(), e.textContent])
|
||||
);
|
||||
expect(lines).toEqual([
|
||||
['git-diff-line--hunk', '@@ -1,3 +1,3 @@\n'],
|
||||
['', ' context\n'],
|
||||
['git-diff-line--del', '-old line\n'],
|
||||
['git-diff-line--add', '+new line\n'],
|
||||
['git-diff-line--add', '+another\n'],
|
||||
]);
|
||||
expect(await page.textContent('#editHistoryBody .edit-history-diff')).toContain('Diff cut short');
|
||||
expect(await rows().nth(0).getAttribute('aria-expanded')).toBe('true');
|
||||
// A re-read (what an SSE update does) keeps the open diff without fetching it again.
|
||||
const before = calls.filter((c) => c.endsWith('/edits/e4')).length;
|
||||
await reload();
|
||||
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(1);
|
||||
expect(calls.filter((c) => c.endsWith('/edits/e4')).length).toBe(before);
|
||||
await rows().nth(0).locator('.edit-history-spacer').click();
|
||||
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(0);
|
||||
});
|
||||
|
||||
it('a path and an image open the shared file preview with the absolute path', async () => {
|
||||
await page.evaluate(() => {
|
||||
(window as any).__previewed = [];
|
||||
(window as any).app.openFilePreview = (p: string, sid: string) => (window as any).__previewed.push([p, sid]);
|
||||
});
|
||||
await rows().nth(2).locator('.edit-history-path').click();
|
||||
await page.locator('#editHistoryBody img.edit-history-thumb').click();
|
||||
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([
|
||||
[join(root, 'src/b.ts'), sessionA],
|
||||
[join(root, 'shot.png'), sessionA],
|
||||
]);
|
||||
// Clicking the path never toggles the row's diff.
|
||||
expect(await page.locator('#editHistoryBody .edit-history-diff').count()).toBe(0);
|
||||
});
|
||||
|
||||
it('"Main agent only" hides the subagent edits; "Group by file" puts edits under their file', async () => {
|
||||
await page.check('#editHistoryMainOnlyToggle');
|
||||
expect(await rows().count()).toBe(3);
|
||||
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(0);
|
||||
expect(await bodyText()).not.toContain('src/b.ts');
|
||||
await page.uncheck('#editHistoryMainOnlyToggle');
|
||||
expect(await rows().count()).toBe(4);
|
||||
|
||||
await page.check('#editHistoryGroupToggle');
|
||||
const groups = page.locator('#editHistoryBody .edit-history-group');
|
||||
expect(await groups.count()).toBe(3);
|
||||
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-path').textContent()).toBe('src/a.ts');
|
||||
expect(await groups.nth(0).locator('.edit-history-entry').count()).toBe(2);
|
||||
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-stats').textContent()).toBe('+5−3');
|
||||
await groups.nth(0).locator('.edit-history-entry').nth(1).locator('.edit-history-spacer').click();
|
||||
await page.waitForSelector('#editHistoryBody .git-diff-line--add');
|
||||
expect(await page.textContent('#editHistoryBody .git-diff-line--add')).toBe('+y\n');
|
||||
await page.uncheck('#editHistoryGroupToggle');
|
||||
});
|
||||
|
||||
it('"Load older" asks the server with a POST and shows what it returns', async () => {
|
||||
const older = page.locator('#editHistoryBody button.edit-history-older');
|
||||
expect(await older.count()).toBe(1);
|
||||
await older.click();
|
||||
await page.waitForFunction(() =>
|
||||
/src\/old\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
|
||||
);
|
||||
expect(calls).toContain(`POST /api/sessions/${sessionA}/edits/older`);
|
||||
expect(await rows().count()).toBe(5);
|
||||
expect(await page.locator('#editHistoryBody button.edit-history-older').count()).toBe(0);
|
||||
});
|
||||
|
||||
it('while open it follows the session: an edit-capable tool end re-reads (debounced), other tools and sessions do not', async () => {
|
||||
const before = syncReads(sessionA);
|
||||
const s = state.get(sessionA)!;
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
entries: [entry({ id: 'e5', relPath: 'src/fresh.ts', added: 4, removed: 0 }), ...(s.entries as Entry[])],
|
||||
});
|
||||
toolEnd(sessionA, 'Read');
|
||||
toolEnd(sessionA, 'Grep');
|
||||
toolEnd('someone-else', 'Edit');
|
||||
await page.waitForTimeout(1000);
|
||||
expect(syncReads(sessionA)).toBe(before);
|
||||
toolEnd(sessionA, 'Edit');
|
||||
toolEnd(sessionA, 'Bash');
|
||||
await page.waitForFunction(() =>
|
||||
/src\/fresh\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
|
||||
);
|
||||
await page.waitForTimeout(800);
|
||||
expect(syncReads(sessionA)).toBe(before + 1);
|
||||
// The end of a turn re-reads too (a parallel tool call reports one tool end only).
|
||||
(server as any).broadcast('transcript:complete', { sessionId: sessionA, timestamp: Date.now() });
|
||||
await page.waitForTimeout(1200);
|
||||
expect(syncReads(sessionA)).toBe(before + 2);
|
||||
});
|
||||
|
||||
it('while the panel is closed, transcript events read nothing; opening reads once', async () => {
|
||||
await page.click('#editHistoryBtn');
|
||||
await page.waitForSelector('#editHistoryPanel:not(.visible)', { state: 'attached' });
|
||||
const before = syncReads(sessionA);
|
||||
toolEnd(sessionA, 'Edit');
|
||||
(server as any).broadcast('transcript:complete', { sessionId: sessionA, timestamp: Date.now() });
|
||||
await page.waitForTimeout(1200);
|
||||
expect(syncReads(sessionA)).toBe(before);
|
||||
expect(await page.$('#editHistoryUnread')).toBeNull(); // no unread badge: closed means idle
|
||||
await page.click('#editHistoryBtn');
|
||||
await page.waitForSelector('#editHistoryPanel.visible');
|
||||
await page.waitForFunction(() => document.querySelectorAll('#editHistoryBody .edit-history-entry').length > 0);
|
||||
expect(syncReads(sessionA)).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('an image outside the workspace gets its thumbnail through the attachment route, without a notification', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
const outside = join(ext, 'render.png');
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
entries: [
|
||||
entry({ id: 'ext', path: outside, relPath: outside, kind: 'image', tool: 'bash', added: 0, removed: 0 }),
|
||||
...(s.entries as Entry[]),
|
||||
],
|
||||
});
|
||||
await reload();
|
||||
const thumb = page.locator('#editHistoryBody img.edit-history-thumb').first();
|
||||
await page.waitForFunction(() =>
|
||||
/\/attachments\/[^/]+\/raw$/.test(document.querySelector('img.edit-history-thumb')?.getAttribute('src') ?? '')
|
||||
);
|
||||
expect(attachmentPosts).toEqual([{ path: outside, notify: false }]);
|
||||
expect(await thumb.getAttribute('src')).toMatch(new RegExp(`^/api/sessions/${sessionA}/attachments/[^/]+/raw$`));
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('img.edit-history-thumb') as HTMLImageElement)?.naturalWidth === 2
|
||||
);
|
||||
// A re-read reuses the registration instead of minting another id.
|
||||
await reload();
|
||||
expect(attachmentPosts).toHaveLength(1);
|
||||
state.set(sessionA, s);
|
||||
});
|
||||
|
||||
it('a long path outside the workspace keeps its file name readable, the folder is cut at the start', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
const long =
|
||||
'/tmp/claude-1000/-mnt-caches-ComfyUI/07e259af-d4d2-4ac3-8a7e-95972e3547df/scratchpad/icons/bremse_alt1_pedal.png';
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
entries: [
|
||||
entry({ id: 'long', relPath: long, kind: 'image', tool: 'sent', added: 0, removed: 0 }),
|
||||
...(s.entries as Entry[]),
|
||||
],
|
||||
});
|
||||
await reload();
|
||||
const row = rows().nth(0);
|
||||
await row.waitFor();
|
||||
expect(await row.locator('.edit-history-path').textContent()).toBe(long);
|
||||
const name = row.locator('.edit-history-path-name');
|
||||
expect(await name.textContent()).toBe('bremse_alt1_pedal.png');
|
||||
// The name is shown whole; only the folder part is shortened.
|
||||
expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||
expect(await row.locator('.edit-history-path-dir').evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true);
|
||||
state.set(sessionA, s);
|
||||
});
|
||||
|
||||
it('renders a hostile path as text, never as markup', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
const evil = '<script>window.__pwned=1</script><img src=x onerror="window.__pwned=2">.ts';
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
agents: { agent1: { description: '<b>bold</b>', agentType: 'x', worktreeBranch: '' } },
|
||||
entries: [entry({ id: 'evil', relPath: evil, agentId: 'agent1' }), ...(s.entries as Entry[])],
|
||||
});
|
||||
await reload();
|
||||
await page.waitForFunction(() => /__pwned/.test(document.getElementById('editHistoryBody')!.textContent ?? ''));
|
||||
expect(await rows().nth(0).locator('.edit-history-path').textContent()).toBe(evil);
|
||||
expect(await rows().nth(0).locator('.edit-history-agent').textContent()).toBe('<b>bold</b>');
|
||||
expect(await page.$('#editHistoryBody script')).toBeNull();
|
||||
expect(await page.$('#editHistoryBody b')).toBeNull();
|
||||
expect(
|
||||
await page.$$eval(
|
||||
'#editHistoryBody img',
|
||||
(els) => els.filter((e) => !e.classList.contains('edit-history-thumb')).length
|
||||
)
|
||||
).toBe(0);
|
||||
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('unavailable: the saved entries stay, with the reason on top', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
state.set(sessionA, { ...s, state: 'unavailable', reason: 'EACCES: permission denied' });
|
||||
await reload();
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
|
||||
expect(await page.textContent('#editHistoryBody .edit-history-note--warn')).toBe(
|
||||
'Showing saved entries only: EACCES: permission denied'
|
||||
);
|
||||
expect(await rows().count()).toBeGreaterThan(0);
|
||||
state.set(sessionA, { ...s, state: 'ok' });
|
||||
});
|
||||
|
||||
it('no transcript yet: says it comes with the next prompt, and names CLAUDE_CONFIG_DIR', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
state.set(sessionA, { ...s, state: 'unavailable', reason: 'no-transcript', entries: [] });
|
||||
await reload();
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
|
||||
const note = (await page.textContent('#editHistoryBody .edit-history-note--warn')) ?? '';
|
||||
expect(note).toContain('No transcript found for this session yet');
|
||||
expect(note).toContain('CLAUDE_CONFIG_DIR');
|
||||
state.set(sessionA, s);
|
||||
await reload();
|
||||
});
|
||||
|
||||
it('follows the active session, and says when a session is not a Claude session', async () => {
|
||||
await select(sessionB);
|
||||
await page.waitForFunction(() =>
|
||||
/only available for local Claude sessions/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
|
||||
);
|
||||
expect(calls).toContain(`GET /api/sessions/${sessionB}/edits?sync=1`);
|
||||
expect(await rows().count()).toBe(0);
|
||||
await select(sessionA);
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-entry');
|
||||
});
|
||||
|
||||
it('drags by the header', async () => {
|
||||
const before = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
|
||||
const box = (await page.locator('.edit-history-header').boundingBox())!;
|
||||
await page.mouse.move(box.x + 40, box.y + 10);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x - 140, box.y + 70, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
const after = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
|
||||
expect(after).toBeLessThan(before - 100);
|
||||
});
|
||||
|
||||
it('turning the setting off hides the button and closes the panel; SSE then reads nothing', async () => {
|
||||
await setSetting(false);
|
||||
expect(await buttonVisible()).toBe(false);
|
||||
expect(await page.isVisible('#editHistoryPanel')).toBe(false);
|
||||
const before = calls.length;
|
||||
toolEnd(sessionA, 'Edit');
|
||||
await page.waitForTimeout(1200);
|
||||
expect(calls.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,15 @@ const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
|
||||
// that removes a hide rule fails loudly (not silently). The attachments button is
|
||||
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
|
||||
// it's excluded from the default-visible enumeration rather than mobile-hidden.
|
||||
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager', 'btn-file-viewer'];
|
||||
// The edit history button is opt-in the same way, but it IS here: its panel is
|
||||
// desktop-sized, so it stays off phones even when the setting is on.
|
||||
const KNOWN_PHONE_HIDDEN = [
|
||||
'btn-settings',
|
||||
'btn-lifecycle-log',
|
||||
'btn-session-manager',
|
||||
'btn-file-viewer',
|
||||
'btn-edit-history',
|
||||
];
|
||||
|
||||
function attrOf(openTag: string, name: string): string {
|
||||
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
|
||||
|
||||
@@ -81,6 +81,13 @@ export class MockSession extends EventEmitter {
|
||||
claudeSessionIdIsFirstHand: boolean = false;
|
||||
/** Mirrors Session.claudeSessionChain — oldest first, current last. */
|
||||
claudeSessionChain: string[] = [];
|
||||
/** The per-session CLAUDE_CONFIG_DIR override that Session.claudeConfigDir() reads. */
|
||||
claudeConfigDirOverride: string | undefined = undefined;
|
||||
|
||||
/** Mirrors Session.claudeConfigDir — the override, trimmed; undefined when unset. */
|
||||
claudeConfigDir(): string | undefined {
|
||||
return this.claudeConfigDirOverride?.trim() || undefined;
|
||||
}
|
||||
|
||||
/** Mirrors Session.adoptClaudeSessionId, including the first-hand chain rule. */
|
||||
adoptClaudeSessionId(newId: string, options: { firstHand?: boolean } = {}): void {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* @fileoverview The edit history panel's API (GET/POST /api/sessions/:id/edits…): a real
|
||||
* EditHistorySync and store over a fake transcript reader. Port: N/A (app.inject()).
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { hunksToUnified, registerEditHistoryRoutes, relativeTo } from '../../src/web/routes/edit-history-routes.js';
|
||||
import { EditHistorySync, NO_TRANSCRIPT, type EditHistorySyncDeps } from '../../src/web/edit-history-sync.js';
|
||||
import { EditHistoryStore } from '../../src/edit-history-store.js';
|
||||
import type { EditSources } from '../../src/edit-history-local-source.js';
|
||||
|
||||
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
|
||||
let dir: string;
|
||||
let session: Record<string, unknown>;
|
||||
|
||||
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
|
||||
return (
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date(ts).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult: {
|
||||
filePath: path,
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
structuredPatch: [{ oldStart: 4, oldLines: 1, newStart: 4, newLines: 2, lines: ['-a', '+b', '+c'] }],
|
||||
},
|
||||
...extra,
|
||||
}) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
function sources(main: string, sub?: string): EditSources {
|
||||
const files = [{ relPath: 'main', size: Buffer.byteLength(main), startOffset: 0, chunk: Buffer.from(main) }];
|
||||
const metas: Record<string, string> = {};
|
||||
if (sub) {
|
||||
files.push({
|
||||
relPath: 'subagents/agent-a1.jsonl',
|
||||
size: Buffer.byteLength(sub),
|
||||
startOffset: 0,
|
||||
chunk: Buffer.from(sub),
|
||||
});
|
||||
metas.a1 = JSON.stringify({
|
||||
description: 'Fix it',
|
||||
agentType: 'coding',
|
||||
worktreePath: '/w/.claude/worktrees/agent-a1',
|
||||
worktreeBranch: 'worktree-agent-a1',
|
||||
});
|
||||
}
|
||||
return { transcriptId: 'conv1', files, metas };
|
||||
}
|
||||
|
||||
async function setup(
|
||||
deps: Partial<EditHistorySyncDeps> = {},
|
||||
opts: { authUser?: { username: string; role: 'admin' | 'user' } } = {}
|
||||
) {
|
||||
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts')));
|
||||
const sync = new EditHistorySync({
|
||||
transcriptPath: async () => '/p/conv1.jsonl',
|
||||
readSources: read,
|
||||
probe: async (p) => p.map(() => null),
|
||||
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
|
||||
...deps,
|
||||
});
|
||||
const h = await createRouteTestHarness((app, ctx) => registerEditHistoryRoutes(app, ctx, sync), {
|
||||
authUser: opts.authUser,
|
||||
});
|
||||
session = h.ctx._session as unknown as Record<string, unknown>;
|
||||
session.workingDir = '/w';
|
||||
session.mode = 'claude';
|
||||
session.claudeSessionId = 'conv1';
|
||||
return { ...h, read, sync };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'edit-routes-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const URL = '/api/sessions/test-session-1/edits';
|
||||
|
||||
describe('GET /api/sessions/:id/edits', () => {
|
||||
it('syncs on ?sync=1 and lists entries without diffs, paths relative to the workspace', async () => {
|
||||
const { app } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = res.json().data;
|
||||
expect(data).toMatchObject({ state: 'ok', backfillTruncated: false, agents: {} });
|
||||
expect(data.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 't1',
|
||||
path: '/w/src/a.ts',
|
||||
relPath: 'src/a.ts',
|
||||
tool: 'Edit',
|
||||
added: 2,
|
||||
removed: 1,
|
||||
}),
|
||||
]);
|
||||
expect(data.entries[0]).not.toHaveProperty('hunks');
|
||||
});
|
||||
|
||||
it('without sync=1 answers from the store and does not read', async () => {
|
||||
const { app, read } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: URL });
|
||||
expect(res.json().data).toMatchObject({ state: 'ok', entries: [] });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('labels subagent entries and shows their paths relative to the worktree', async () => {
|
||||
const sub = editLine('t9', T0 + 5, '/w/.claude/worktrees/agent-a1/lib/x.ts', { isSidechain: true, agentId: 'a1' });
|
||||
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts'), sub));
|
||||
const { app } = await setup({ readSources: read });
|
||||
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(data.agents).toEqual({
|
||||
a1: { description: 'Fix it', agentType: 'coding', worktreeBranch: 'worktree-agent-a1' },
|
||||
});
|
||||
expect(data.entries.find((e: { id: string }) => e.id === 't9')).toMatchObject({
|
||||
agentId: 'a1',
|
||||
relPath: 'lib/x.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('a read error answers unavailable with the stored entries', async () => {
|
||||
let fail = false;
|
||||
const read = vi.fn(async () => {
|
||||
if (fail) throw new Error('EACCES: permission denied');
|
||||
return sources(editLine('t1', T0, '/w/src/a.ts'));
|
||||
});
|
||||
const { app } = await setup({ readSources: read });
|
||||
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
fail = true;
|
||||
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(data).toMatchObject({ state: 'unavailable', reason: 'EACCES: permission denied' });
|
||||
expect(data.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('hands the reader the session ids and its own CLAUDE_CONFIG_DIR', async () => {
|
||||
const seen: unknown[] = [];
|
||||
const { app } = await setup({
|
||||
transcriptPath: async (s) => {
|
||||
seen.push({ id: s.id, claudeSessionId: s.claudeSessionId, claudeConfigDir: s.claudeConfigDir });
|
||||
return '/p/conv1.jsonl';
|
||||
},
|
||||
});
|
||||
session.claudeConfigDirOverride = '/cfg/alt ';
|
||||
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
expect(seen).toEqual([{ id: 'test-session-1', claudeSessionId: 'conv1', claudeConfigDir: '/cfg/alt' }]);
|
||||
});
|
||||
|
||||
it('a session without a transcript yet answers unavailable (no-transcript)', async () => {
|
||||
const { app, read } = await setup({ transcriptPath: async () => null });
|
||||
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(data).toMatchObject({ state: 'unavailable', reason: NO_TRANSCRIPT, entries: [] });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['remote', { remote: { host: 'huff' } }],
|
||||
['docker', { docker: { image: 'x' } }],
|
||||
['mode', { mode: 'shell' }],
|
||||
])('answers unsupported (%s) without reading', async (reason, patch) => {
|
||||
const { app, read } = await setup();
|
||||
Object.assign(session, patch);
|
||||
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(data).toMatchObject({ state: 'unsupported', reason });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s an unknown session', async () => {
|
||||
const { app } = await setup();
|
||||
expect((await app.inject({ method: 'GET', url: '/api/sessions/nope/edits' })).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('multi-user: another user’s session is not found and nothing is read', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app, read } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
|
||||
session.owner = 'alice';
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/edits/:entryId', () => {
|
||||
it('returns the diff as unified text', async () => {
|
||||
const { app } = await setup();
|
||||
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}/t1` });
|
||||
expect(res.json().data).toEqual({ diff: '@@ -4,1 +4,2 @@\n-a\n+b\n+c', truncated: false });
|
||||
});
|
||||
|
||||
it('404s an edit the session does not have', async () => {
|
||||
const { app } = await setup();
|
||||
expect((await app.inject({ method: 'GET', url: `${URL}/missing` })).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('multi-user: another user’s diff is not found', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
|
||||
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
session.owner = 'alice';
|
||||
expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/edits/older', () => {
|
||||
it('reads the next older block and lists the result', async () => {
|
||||
const lines = [0, 1, 2, 3].map((i) => editLine(`t${i}`, T0 + i, `/w/${i}.ts`)).join('');
|
||||
const full = Buffer.from(lines);
|
||||
const read = vi.fn(
|
||||
async (_path: string, requests: readonly { relPath: string; offset: number; len?: number }[]) => {
|
||||
const req = requests.find((r) => r.relPath === 'main');
|
||||
const start = req ? req.offset : full.length - 400;
|
||||
const len = req?.len ?? full.length - start;
|
||||
return {
|
||||
transcriptId: 'conv1',
|
||||
metas: {},
|
||||
files: [{ relPath: 'main', size: full.length, startOffset: start, chunk: full.subarray(start, start + len) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
const { app } = await setup({ readSources: read });
|
||||
const first = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(first.backfillTruncated).toBe(true);
|
||||
const older = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
|
||||
expect(older.entries.length).toBeGreaterThan(first.entries.length);
|
||||
});
|
||||
|
||||
it('answers unsupported for a remote session without reading', async () => {
|
||||
const { app, read } = await setup();
|
||||
session.remote = { host: 'huff' };
|
||||
const data = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
|
||||
expect(data).toMatchObject({ state: 'unsupported', reason: 'remote' });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
it('relativeTo strips only a real directory prefix', () => {
|
||||
expect(relativeTo('/w/a/b.ts', '/w')).toBe('a/b.ts');
|
||||
expect(relativeTo('/w/a/b.ts', '/w/')).toBe('a/b.ts');
|
||||
expect(relativeTo('/wx/b.ts', '/w')).toBe('/wx/b.ts');
|
||||
expect(relativeTo('/w/b.ts', undefined)).toBe('/w/b.ts');
|
||||
});
|
||||
|
||||
it('hunksToUnified writes one header per hunk', () => {
|
||||
expect(
|
||||
hunksToUnified([
|
||||
{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-x', '+y'] },
|
||||
{ oldStart: 9, oldLines: 0, newStart: 9, newLines: 1, lines: ['+z'] },
|
||||
])
|
||||
).toBe('@@ -1,1 +1,1 @@\n-x\n+y\n@@ -9,0 +9,1 @@\n+z');
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,10 @@ describe('isSensitivePath', () => {
|
||||
['codeman push keys (VAPID private)', `${HOME}/.codeman/push-keys.json`],
|
||||
['codeman intent profiles', `${HOME}/.codeman/intents.json`],
|
||||
['codeman intents on a named instance', `${HOME}/.codeman-beta/intents.json`],
|
||||
// The edit history stores hold every diff an agent wrote (keys and .env
|
||||
// contents included): 0600 and kept out of /api/search like intents.json.
|
||||
['codeman edit history store', `${HOME}/.codeman/edit-history/s1.json`],
|
||||
['codeman edit history on a named instance', `${HOME}/.codeman-beta/edit-history/s1.json`],
|
||||
];
|
||||
|
||||
it.each(blocked)('blocks the %s', (_label, path) => {
|
||||
|
||||
Reference in new issue
Block a user