From a1d135e233b12f831da4137544de6654679acc8e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:08:12 -0400 Subject: [PATCH] fix(ai-vault): re-read a transcript rewritten to its previous size (#20261) --- .../ai-vault/session-parse-cache-store.ts | 2 ++ .../session-transcript-consumers.test.ts | 36 ++++++++++++++++++- .../ai-vault/session-transcript-reader.ts | 11 +++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/main/ai-vault/session-parse-cache-store.ts b/src/main/ai-vault/session-parse-cache-store.ts index 1f1d624c85d..cbf950cfe7e 100644 --- a/src/main/ai-vault/session-parse-cache-store.ts +++ b/src/main/ai-vault/session-parse-cache-store.ts @@ -9,6 +9,8 @@ const MAX_CACHE_ENTRIES = 4096 export type SessionParseResumePoint = { state: ResumableSessionParseState + mtimeMs: number + sizeBytes: number | undefined // Byte offset just past the last complete ('\n'-terminated) line consumed; // a trailing unterminated line is deliberately left before this point. byteOffset: number diff --git a/src/main/ai-vault/session-transcript-consumers.test.ts b/src/main/ai-vault/session-transcript-consumers.test.ts index cf9a6068673..856e426b658 100644 --- a/src/main/ai-vault/session-transcript-consumers.test.ts +++ b/src/main/ai-vault/session-transcript-consumers.test.ts @@ -1,4 +1,4 @@ -import { appendFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { appendFile, mkdir, mkdtemp, rm, stat, truncate, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' @@ -170,6 +170,8 @@ it('replays only the appended lines on a resumed read', async () => { expect(firstRead?.outcome?.incomplete).toBe(false) await appendFile(transcript, `${jsonLines(claudeTurns(5, 5))}\n`) + const changedAt = new Date(firstRead!.start.candidate.file.mtimeMs + 2000) + await utimes(transcript, changedAt, changedAt) consumer.reads.length = 0 await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) @@ -183,6 +185,38 @@ it('replays only the appended lines on a resumed read', async () => { ]) }) +it.each(['rewrite', 'truncate then regrow'])( + 're-reads a same-size %s from zero', + async (operation) => { + const { transcript } = await writeClaudeFixture() + const before = await claudeCandidate(transcript) + await parseAgentSessionFileCached(before, 'darwin') + const consumer = recordingConsumer() + const rewritten = `${jsonLines(claudeTurns(1, 4))}\n`.replace('reply 4', 'fresh 4') + expect(Buffer.byteLength(rewritten)).toBe(before.file.sizeBytes) + + if (operation === 'truncate then regrow') { + await truncate(transcript, 0) + await appendFile(transcript, rewritten) + } else { + await writeFile(transcript, rewritten) + } + const changedAt = new Date(before.file.mtimeMs + 2000) + await utimes(transcript, changedAt, changedAt) + const session = await parseAgentSessionFileCached(await claudeCandidate(transcript), 'darwin') + + expect(consumer.reads).toHaveLength(1) + expect(consumer.reads[0].start.mode).toBe('replace') + expect(consumer.reads[0].start.previousByteOffset).toBe(0) + expect(textsFor(consumer.reads, 'claude')).toContain('assistant:fresh 4') + expect(textsFor(consumer.reads, 'claude')).not.toContain('assistant:reply 4') + resetSessionParseCacheForTests() + expect(session).toEqual( + await parseAgentSessionFileCached(await claudeCandidate(transcript), 'darwin') + ) + } +) + it('publishes a trailing unterminated line once, when it is complete', async () => { const { roots, transcript } = await writeClaudeFixture() const consumer = recordingConsumer() diff --git a/src/main/ai-vault/session-transcript-reader.ts b/src/main/ai-vault/session-transcript-reader.ts index 3fc0ddcc146..e76bde231f2 100644 --- a/src/main/ai-vault/session-transcript-reader.ts +++ b/src/main/ai-vault/session-transcript-reader.ts @@ -68,6 +68,9 @@ export async function readResumableTranscript(args: { resume !== null && typeof file.sizeBytes === 'number' && file.sizeBytes >= resume.byteOffset && + file.mtimeMs >= resume.mtimeMs && + // A changed timestamp without growth signals a rewrite, even at a valid line boundary. + (file.mtimeMs === resume.mtimeMs || file.sizeBytes > (resume.sizeBytes ?? resume.byteOffset)) && (resume.byteOffset === 0 || (await endsWithNewlineAt(file.path, resume.byteOffset))) // Clone before consuming: a failed read must not corrupt the cached state, @@ -127,7 +130,13 @@ export async function readResumableTranscript(args: { channel.finishRead({ session, byteOffset: readResult.consumedThrough, incomplete: false }) return { session, - resume: { state, byteOffset: readResult.consumedThrough, channel } + resume: { + state, + byteOffset: readResult.consumedThrough, + mtimeMs: file.mtimeMs, + sizeBytes: file.sizeBytes, + channel + } } } catch (error) { channel.finishRead({ session: null, byteOffset: startOffset, incomplete: true })