fix(edit-history): a remote lookup takes the conversation written last, not the first id's

Review round 2: with the stored conversation tried before the launch id, a
respawn that resumed the launch conversation after a /clear was missed once
claude exited again: the cleared-to conversation still had a file and won,
so the edits made after the resume were never read. No fixed order of the
ids is right both after a /clear and after such a resume. Without a live
conversation the lookup now takes the most recently written transcript of
all known ids (stored, claudeSessionId, id); the live one still goes first.

Also from the review:
- the remote no-transcript note and the docs name login profiles too
  (.profile, .bash_profile): sshd's `bash -c` reads neither, like .zshrc;
- a wiring guard pins that server.ts looks a remote transcript up with
  remoteTranscriptIds and leaves the local lookup on the hook path;
- the browser tests undo the injected remote session in a finally.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
RandalixandClaude Opus 5.5 committed 2026-11-05 11:20:01 +01:00
1 parent 0a6a877415
commit 5bcec26066
9 files changed
+80 -46

No files matched your search

+11 -1
View File
@@ -179,7 +179,7 @@ describe('unsupportedReason', () => {
});
describe('remoteTranscriptIds', () => {
it('tries the stored conversation before the launch id: no hook tells Codeman of a remote /clear', () => {
it('includes the stored conversation besides the launch id: no hook tells Codeman of a remote /clear', () => {
expect(remoteTranscriptIds(remote, 'conv2')).toEqual(['conv2', 'conv1', 's2']);
expect(remoteTranscriptIds(remote, 'conv1')).toEqual(['conv1', 's2']);
expect(remoteTranscriptIds(remote, null)).toEqual(['conv1', 's2']);
@@ -734,6 +734,16 @@ describe('reads only on request (wiring guard)', () => {
expect(server).toMatch(/registerEditHistoryRoutes\(this\.app, ctx, this\.editHistorySync\)/);
});
it('a remote session looks its transcript up with the stored conversation; a local one keeps the hook path', () => {
const server = read('src/web/server.ts');
const start = server.indexOf('new EditHistorySync({');
const lookup = server.slice(start, server.indexOf('readSources:', start));
expect(lookup).toMatch(
/remoteLocateClaudeTranscript\(\s*session\.remote,\s*remoteTranscriptIds\(session, storedTranscriptId\)/
);
expect(lookup).toMatch(/resolveClaudeTranscript\(\s*this\.transcriptWatchers\.get\(session\.id\)\?\.getPath\(\)/);
});
it('no other module holds the reader, and the edit history modules run no timer or watcher', () => {
const root = join(__dirname, '..');
const holders = readdirSync(join(root, 'src'), { recursive: true, encoding: 'utf-8' })
+28 -20
View File
@@ -389,17 +389,20 @@ describe('Edit history panel in a real browser', () => {
w.__openFilePreview = w.app.openFilePreview;
w.app.openFilePreview = (p: string, s: string) => w.__previewed.push([p, s]);
}, sessionA);
await reload();
await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).waitFor();
expect(await thumbs()).toBe(0);
await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).click();
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([[join(root, 'shot.png'), sessionA]]);
await page.evaluate((sid) => {
const w = window as any;
delete w.app.sessions.get(sid).remote;
w.app.openFilePreview = w.__openFilePreview;
}, sessionA);
await reload();
try {
await reload();
await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).waitFor();
expect(await thumbs()).toBe(0);
await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).click();
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([[join(root, 'shot.png'), sessionA]]);
} finally {
await page.evaluate((sid) => {
const w = window as any;
delete w.app.sessions.get(sid).remote;
w.app.openFilePreview = w.__openFilePreview;
}, sessionA);
await reload();
}
await page.locator('#editHistoryBody img.edit-history-thumb').waitFor();
});
@@ -473,15 +476,20 @@ describe('Edit history panel in a real browser', () => {
await page.evaluate((sid) => {
(window as any).app.sessions.get(sid).remote = { hostId: 'h', label: 'h', host: '192.0.2.1' };
}, sessionA);
await reload();
const remoteNote = page.locator('#editHistoryBody .edit-history-note--warn', {
hasText: 'No transcript found on the remote host yet',
});
await remoteNote.waitFor();
expect(await remoteNote.textContent()).not.toContain('next prompt');
await page.evaluate((sid) => delete (window as any).app.sessions.get(sid).remote, sessionA);
state.set(sessionA, s);
await reload();
try {
await reload();
const remoteNote = page.locator('#editHistoryBody .edit-history-note--warn', {
hasText: 'No transcript found on the remote host yet',
});
await remoteNote.waitFor();
const text = (await remoteNote.textContent()) ?? '';
expect(text).not.toContain('next prompt');
expect(text).toContain('.bash_profile');
} finally {
await page.evaluate((sid) => delete (window as any).app.sessions.get(sid).remote, sessionA);
state.set(sessionA, s);
await reload();
}
});
it('follows the active session, and says when a session is not a Claude session', async () => {
+9 -2
View File
@@ -105,9 +105,16 @@ describe('the transcript lookup on a real shell', () => {
expect(command).not.toMatch(/(^|[\s;|(])ls\s/m);
});
it('finds the newest <id>.jsonl of the first id that has one', () => {
it('takes the most recently written transcript of all ids, not the first id that has one', () => {
// conv-1 lies in old/ (1000 s) and new/ (2000 s); solo is older than both, then newest.
const solo = join(projects(), 'old', 'solo.jsonl');
utimesSync(solo, 1_500, 1_500);
expect(locate(['conv-1', 'solo'])).toBe(join(projects(), 'new', 'conv-1.jsonl'));
expect(locate(['missing', 'solo'])).toBe(join(projects(), 'old', 'solo.jsonl'));
expect(locate(['solo', 'conv-1'])).toBe(join(projects(), 'new', 'conv-1.jsonl'));
expect(locate(['missing', 'solo'])).toBe(solo);
// A respawn resumed the launch conversation after a /clear: the one written last wins.
utimesSync(solo, 3_000, 3_000);
expect(locate(['conv-1', 'solo'])).toBe(solo);
});
it('exits 3 when no id has a transcript, and drops ids that are not plain tokens', () => {