mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
The edit history panel now covers remote claude sessions. The sync stays path-based; its deps get the session and server.ts routes them to the local reader or to two new ssh reads in remote-files.ts: - remoteLocateClaudeTranscript: one round trip; the conversation the remote pane's claude writes now (~/.claude/sessions/<pid>.json via the pane's process tree, since remote hosts send no hooks), else claudeSessionId, else the session id, newest $HOME/.claude/projects/*/<id>.jsonl. - remoteReadClaudeEditSources: one round trip per read pass, with the local reader's semantics (offset clamp, block end at offset+len, new files read their tail, shared main-first budget, onlyRequested, 8 KiB meta cap). So /clear catch-up, "Load older", cursors and the store LRU work remotely unchanged. Reads use buildRemoteFileCommand + runWithRemoteSshLimit only: a sleeping host is a connect timeout -> unavailable, stored entries kept. Tests: both scripts run on a real sh against a temp HOME; the read is compared byte for byte with readClaudeEditSources on 60 seeded random transcripts. A transitive import guard pins that the edit-history read path reaches neither remote-wake.ts nor session-routes.ts. Browser: a remote session lists images without thumbnails, a click previews. Docs updated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
215 lines
9.1 KiB
TypeScript
215 lines
9.1 KiB
TypeScript
/**
|
|
* Edit history end to end: the real reader (edit-history-local-source.ts), sync and store on
|
|
* temp transcripts: what the fakes in edit-history-sync.test.ts cannot show (file order,
|
|
* a new conversation next to the old one, every line read exactly once).
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { readClaudeEditSources } from '../src/edit-history-local-source.js';
|
|
import { EditHistoryStore } from '../src/edit-history-store.js';
|
|
import { EditHistorySync, MAX_FORWARD_PASSES, type EditHistorySession } from '../src/web/edit-history-sync.js';
|
|
|
|
let root: string;
|
|
const opened: EditHistoryStore[] = [];
|
|
beforeEach(() => {
|
|
root = mkdtempSync(join(tmpdir(), 'edit-pipeline-'));
|
|
});
|
|
afterEach(async () => {
|
|
await Promise.all(opened.splice(0).map((s) => s.remove()));
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
|
|
|
|
/** One edit result line of `pad` bytes or more, newline included. */
|
|
function editLine(id: string, i: number, pad = 0, agentId?: string): string {
|
|
const rec: Record<string, unknown> = {
|
|
type: 'user',
|
|
timestamp: new Date(T0 + i * 1000).toISOString(),
|
|
cwd: '/w',
|
|
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
|
toolUseResult: {
|
|
filePath: `/w/${id}.ts`,
|
|
oldString: 'a',
|
|
newString: 'b',
|
|
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
|
|
},
|
|
pad: '',
|
|
};
|
|
if (agentId) rec.agentId = agentId;
|
|
rec.pad = 'p'.repeat(Math.max(0, pad - JSON.stringify(rec).length - 1));
|
|
return JSON.stringify(rec) + '\n';
|
|
}
|
|
|
|
function setup(budget?: number, base = root) {
|
|
const project = join(base, 'projects', 'p');
|
|
mkdirSync(project, { recursive: true });
|
|
const at = (transcriptId: string) => join(project, `${transcriptId}.jsonl`);
|
|
/** The transcript watcher's path (the hooks report it; `/clear` moves it). */
|
|
let current = at('convA');
|
|
const sync = new EditHistorySync({
|
|
transcriptPath: async () => (existsSync(current) ? current : null),
|
|
readSources: (_session, path, requests, budget, opts) => readClaudeEditSources(path, requests, budget, opts),
|
|
probe: async (_session, paths) => paths.map(() => null),
|
|
loadStore: async (id) => {
|
|
const store = await EditHistoryStore.load(id, { dir: join(base, 'store'), debounceMs: 10 });
|
|
opened.push(store);
|
|
return store;
|
|
},
|
|
deleteStore: (id) => EditHistoryStore.delete(id, { dir: join(base, 'store') }),
|
|
sweepStores: async () => 0,
|
|
budget,
|
|
});
|
|
const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'convA' };
|
|
const ids = async () =>
|
|
(await sync.getStore('s1'))
|
|
.list()
|
|
.map((e) => e.id)
|
|
.sort();
|
|
return {
|
|
project,
|
|
at,
|
|
sync,
|
|
session,
|
|
ids,
|
|
switchTo: (transcriptId: string) => (current = at(transcriptId)),
|
|
};
|
|
}
|
|
|
|
describe('a new conversation (/clear, resume)', () => {
|
|
it('reads the previous transcript to its end before the new one', async () => {
|
|
const { at, sync, session, ids, switchTo } = setup();
|
|
appendFileSync(at('convA'), editLine('a1', 1));
|
|
await sync.sync(session);
|
|
// The turn's last edits land, then /clear before the panel's debounced re-read.
|
|
appendFileSync(at('convA'), editLine('a2', 2) + editLine('a3', 3));
|
|
switchTo('convB');
|
|
appendFileSync(at('convB'), editLine('b1', 4));
|
|
await sync.sync(session);
|
|
expect(await ids()).toEqual(['a1', 'a2', 'a3', 'b1']);
|
|
const store = await sync.getStore('s1');
|
|
expect(store.getTranscriptId()).toBe('convB');
|
|
expect(store.knownSources()).toEqual(['main']);
|
|
});
|
|
|
|
it('a previous backlog larger than one sync reads on with the next ones; the new transcript follows', async () => {
|
|
const { at, sync, session, ids, switchTo } = setup(600);
|
|
appendFileSync(at('convA'), editLine('a0', 0));
|
|
await sync.sync(session);
|
|
const backlog = Array.from({ length: MAX_FORWARD_PASSES * 3 }, (_, i) => `a${i + 1}`);
|
|
appendFileSync(at('convA'), backlog.map((id, i) => editLine(id, i + 1, 500)).join(''));
|
|
switchTo('convB');
|
|
appendFileSync(at('convB'), editLine('b1', 99));
|
|
await sync.sync(session);
|
|
expect(await ids()).not.toContain('b1');
|
|
for (let i = 0; i < 5; i++) await sync.sync(session);
|
|
expect(await ids()).toEqual(['a0', ...backlog, 'b1'].sort());
|
|
});
|
|
|
|
it('subagents of the previous conversation are read to their end too', async () => {
|
|
const { project, at, sync, session, ids, switchTo } = setup();
|
|
const sub = join(project, 'convA', 'subagents');
|
|
mkdirSync(sub, { recursive: true });
|
|
appendFileSync(at('convA'), editLine('a1', 1));
|
|
appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x1', 2, 0, 'x'));
|
|
await sync.sync(session);
|
|
appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x2', 3, 0, 'x'));
|
|
switchTo('convB');
|
|
appendFileSync(at('convB'), editLine('b1', 4));
|
|
await sync.sync(session);
|
|
expect(await ids()).toEqual(['a1', 'b1', 'x1', 'x2']);
|
|
});
|
|
|
|
it('a previous transcript that is gone does not hold up the new one', async () => {
|
|
const { at, sync, session, ids, switchTo } = setup();
|
|
appendFileSync(at('convA'), editLine('a1', 1));
|
|
await sync.sync(session);
|
|
rmSync(at('convA'));
|
|
switchTo('convB');
|
|
appendFileSync(at('convB'), editLine('b1', 2));
|
|
await sync.sync(session);
|
|
expect(await ids()).toEqual(['a1', 'b1']);
|
|
});
|
|
|
|
it('"Load older" before the forward read moved on reads the stored conversation, not the new file', async () => {
|
|
const { at, sync, session, ids, switchTo } = setup(600);
|
|
const older = Array.from({ length: 4 }, (_, i) => `a${i}`);
|
|
appendFileSync(at('convA'), older.map((id, i) => editLine(id, i, 500)).join(''));
|
|
await sync.sync(session);
|
|
expect(await ids()).toEqual(['a3']);
|
|
switchTo('convB');
|
|
appendFileSync(at('convB'), editLine('b1', 99));
|
|
await sync.sync(session, { older: true });
|
|
expect(await ids()).toEqual(['a2', 'a3']);
|
|
});
|
|
});
|
|
|
|
describe('every line exactly once (seeded random transcripts)', () => {
|
|
/** Deterministic: a seed fixes budget, line lengths, subagents, partial writes and sync points. */
|
|
async function run(seed: number): Promise<string | null> {
|
|
let state = seed;
|
|
const rnd = () => {
|
|
state = (state * 1103515245 + 12345) & 0x7fffffff;
|
|
return state / 0x7fffffff;
|
|
};
|
|
const ri = (a: number, b: number) => a + Math.floor(rnd() * (b - a + 1));
|
|
const budget = ri(400, 1500);
|
|
const { project, at, sync, session } = setup(budget, mkdtempSync(join(root, 'run-')));
|
|
const main = at('convA');
|
|
const subDir = join(project, 'convA', 'subagents');
|
|
const expected = new Set<string>();
|
|
const tooLong = new Set<string>();
|
|
const agents: string[] = [];
|
|
const tails = new Map<string, string>();
|
|
let n = 0;
|
|
const write = () => {
|
|
if (rnd() < 0.15 && agents.length < 6) {
|
|
mkdirSync(subDir, { recursive: true });
|
|
agents.push(`a${agents.length}`);
|
|
}
|
|
const k = ri(0, agents.length);
|
|
const agent = k === 0 ? undefined : agents[k - 1];
|
|
const file = agent ? join(subDir, `agent-${agent}.jsonl`) : main;
|
|
const id = `e${n++}`;
|
|
// Some lines are longer than the budget: skipped, never shown, never blocking.
|
|
const line = editLine(id, n, rnd() < 0.12 ? ri(budget + 2, budget * 3) : ri(250, budget - 2), agent);
|
|
if (Buffer.byteLength(line) < budget) expected.add(id);
|
|
else if (Buffer.byteLength(line) > budget) tooLong.add(id);
|
|
// Sometimes half a line: claude is still writing it.
|
|
const cut = rnd() < 0.15 ? ri(1, line.length - 1) : line.length;
|
|
appendFileSync(file, (tails.get(file) ?? '') + line.slice(0, cut));
|
|
if (cut < line.length) tails.set(file, line.slice(cut));
|
|
else tails.delete(file);
|
|
};
|
|
for (let i = ri(0, 20); i > 0; i--) write(); // backlog before the panel opens
|
|
if (!existsSync(main)) appendFileSync(main, '');
|
|
for (let r = ri(5, 25); r > 0; r--) {
|
|
for (let i = ri(0, 6); i > 0; i--) write();
|
|
await sync.sync(session);
|
|
}
|
|
for (const [file, rest] of tails) appendFileSync(file, rest);
|
|
for (let i = 0; i < 40; i++) await sync.sync(session);
|
|
const store = await sync.getStore('s1');
|
|
for (let clicks = 0; store.backfillTruncated && clicks < 400; clicks++) await sync.sync(session, { older: true });
|
|
const got = new Set(store.list().map((e) => e.id));
|
|
const missing = [...expected].filter((id) => !got.has(id));
|
|
const extra = [...got].filter((id) => tooLong.has(id));
|
|
if (missing.length || extra.length || store.backfillTruncated) {
|
|
return `seed ${seed} (budget ${budget}): missing ${missing.join(',')} extra ${extra.join(',')}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
it('reads every line shorter than the budget once, forward and back, and none longer', async () => {
|
|
const failures: string[] = [];
|
|
for (let seed = 1; seed <= 40; seed++) {
|
|
await Promise.all(opened.splice(0).map((s) => s.remove()));
|
|
const r = await run(seed);
|
|
if (r) failures.push(r);
|
|
}
|
|
expect(failures).toEqual([]);
|
|
});
|
|
});
|