diff --git a/packages/core/src/services/chatRecordingService.autoTitle.test.ts b/packages/core/src/services/chatRecordingService.autoTitle.test.ts index 5061afe454..43de494d06 100644 --- a/packages/core/src/services/chatRecordingService.autoTitle.test.ts +++ b/packages/core/src/services/chatRecordingService.autoTitle.test.ts @@ -364,16 +364,14 @@ describe('ChatRecordingService - auto-title trigger', () => { expect(svc.getCurrentCustomTitle()).toBe('Auto-generated title'); expect(svc.getCurrentTitleSource()).toBe('auto'); - // finalize() was called by the constructor — drain the queued async - // write before inspecting the mock. + await svc.flush(); + expect(findCustomTitleRecord()).toBeUndefined(); + + svc.recordUserMessage([{ text: 'resume work' }]); await svc.flush(); - // The re-appended record must carry titleSource: 'auto', not 'manual'. - const finalizeRecord = vi - .mocked(jsonl.writeLine) - .mock.calls.map((c) => c[1] as ChatRecord) - .find((r) => r.type === 'system' && r.subtype === 'custom_title'); - expect(finalizeRecord?.systemPayload).toEqual({ + const reanchoredRecord = findCustomTitleRecord(); + expect(reanchoredRecord?.systemPayload).toEqual({ customTitle: 'Auto-generated title', titleSource: 'auto', }); @@ -404,12 +402,13 @@ describe('ChatRecordingService - auto-title trigger', () => { expect(svc.getCurrentCustomTitle()).toBe('User chose this'); expect(svc.getCurrentTitleSource()).toBe('manual'); await svc.flush(); + expect(findCustomTitleRecord()).toBeUndefined(); - const finalizeRecord = vi - .mocked(jsonl.writeLine) - .mock.calls.map((c) => c[1] as ChatRecord) - .find((r) => r.type === 'system' && r.subtype === 'custom_title'); - expect(finalizeRecord?.systemPayload).toEqual({ + svc.recordUserMessage([{ text: 'resume work' }]); + await svc.flush(); + + const reanchoredRecord = findCustomTitleRecord(); + expect(reanchoredRecord?.systemPayload).toEqual({ customTitle: 'User chose this', titleSource: 'manual', }); @@ -438,13 +437,14 @@ describe('ChatRecordingService - auto-title trigger', () => { // `titleSource: 'manual'` we can't actually verify. expect(svc.getCurrentTitleSource()).toBeUndefined(); await svc.flush(); + expect(findCustomTitleRecord()).toBeUndefined(); - const finalizeRecord = vi - .mocked(jsonl.writeLine) - .mock.calls.map((c) => c[1] as ChatRecord) - .find((r) => r.type === 'system' && r.subtype === 'custom_title'); + svc.recordUserMessage([{ text: 'resume work' }]); + await svc.flush(); + + const reanchoredRecord = findCustomTitleRecord(); // Payload must NOT contain a titleSource field when source is unknown. - expect(finalizeRecord?.systemPayload).toEqual({ + expect(reanchoredRecord?.systemPayload).toEqual({ customTitle: 'Legacy title', }); }); diff --git a/packages/core/src/services/chatRecordingService.customTitle.test.ts b/packages/core/src/services/chatRecordingService.customTitle.test.ts index f6a6357cd2..224d409818 100644 --- a/packages/core/src/services/chatRecordingService.customTitle.test.ts +++ b/packages/core/src/services/chatRecordingService.customTitle.test.ts @@ -135,8 +135,9 @@ describe('ChatRecordingService - recordCustomTitle', () => { }); describe('finalize', () => { - it('should re-append cached custom title to EOF', async () => { + it('should re-append cached custom title to EOF after new content', async () => { chatRecordingService.recordCustomTitle('my-feature'); + chatRecordingService.recordUserMessage([{ text: 'new work' }]); await chatRecordingService.flush(); vi.mocked(jsonl.writeLine).mockClear(); @@ -153,6 +154,17 @@ describe('ChatRecordingService - recordCustomTitle', () => { }); }); + it('should not write anything when the title is already the latest record', async () => { + chatRecordingService.recordCustomTitle('my-feature'); + await chatRecordingService.flush(); + vi.mocked(jsonl.writeLine).mockClear(); + + chatRecordingService.finalize(); + await chatRecordingService.flush(); + + expect(jsonl.writeLine).not.toHaveBeenCalled(); + }); + it('should not write anything when no custom title was set', async () => { chatRecordingService.finalize(); await chatRecordingService.flush(); @@ -160,9 +172,33 @@ describe('ChatRecordingService - recordCustomTitle', () => { expect(jsonl.writeLine).not.toHaveBeenCalled(); }); + it('should not re-append a resumed title without new content', async () => { + vi.mocked(mockConfig.getResumedSessionData).mockReturnValue({ + lastCompletedUuid: null, + } as unknown as ReturnType); + const getSessionTitleInfo = vi.fn().mockReturnValue({ + title: 'resumed-title', + source: 'manual', + }); + ( + mockConfig as unknown as { + getSessionService: () => { + getSessionTitleInfo: typeof getSessionTitleInfo; + }; + } + ).getSessionService = () => ({ getSessionTitleInfo }); + + const svc = new ChatRecordingService(mockConfig); + svc.finalize(); + await svc.flush(); + + expect(jsonl.writeLine).not.toHaveBeenCalled(); + }); + it('should re-append the latest title after multiple renames', async () => { chatRecordingService.recordCustomTitle('first-name'); chatRecordingService.recordCustomTitle('second-name'); + chatRecordingService.recordUserMessage([{ text: 'new work' }]); await chatRecordingService.flush(); vi.mocked(jsonl.writeLine).mockClear(); @@ -288,10 +324,8 @@ describe('ChatRecordingService - recordCustomTitle', () => { ).getSessionService = () => ({ getSessionTitleInfo }); const svc = new ChatRecordingService(mockConfig); - // Constructor's finalize re-appends a custom_title record on resume - // — clear it out so we can isolate the threshold-triggered re-anchor. await svc.flush(); - vi.mocked(jsonl.writeLine).mockClear(); + expect(jsonl.writeLine).not.toHaveBeenCalled(); const bulkText = 'x'.repeat(2000); for (let i = 0; i < 20; i++) { diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 39c1c8a791..29b97dcfcd 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -564,6 +564,7 @@ export class ChatRecordingService { * returning undefined if the title is beyond both windows). */ private bytesSinceTitleAnchor = 0; + private hasNonTitleContentSinceTitleAnchor = false; constructor(config: Config) { this.config = config; @@ -577,16 +578,19 @@ export class ChatRecordingService { // resumed. Legacy records (no `titleSource` field) stay `undefined` — // treated as manual for safety without rewriting the JSONL. // - // We then re-append a custom_title record to EOF so the title stays - // within the tail window that readers scan (guarding against a crash - // before the next finalize). + // Do not re-append during construction: loading/resuming a session is a + // read operation from the user's perspective, and touching the JSONL mtime + // would make session lists treat it as fresh activity. if (config.getResumedSessionData()) { try { const sessionService = config.getSessionService(); const info = sessionService.getSessionTitleInfo(config.getSessionId()); this.currentCustomTitle = info.title; this.currentTitleSource = info.source; - this.finalize(); + if (info.title) { + // Prime the threshold so the first real content write re-anchors. + this.bytesSinceTitleAnchor = TITLE_REANCHOR_BYTES; + } } catch { // Best-effort — don't block construction } @@ -793,9 +797,11 @@ export class ChatRecordingService { private updateTitleAnchorTracking(record: ChatRecord): void { if (record.type === 'system' && record.subtype === 'custom_title') { this.bytesSinceTitleAnchor = 0; + this.hasNonTitleContentSinceTitleAnchor = false; return; } if (!this.currentCustomTitle) return; + this.hasNonTitleContentSinceTitleAnchor = true; // +1 for the trailing newline jsonl.writeLine appends. this.bytesSinceTitleAnchor += Buffer.byteLength(JSON.stringify(record), 'utf8') + 1; @@ -1319,13 +1325,10 @@ export class ChatRecordingService { } /** - * Finalizes the current session by re-appending cached metadata to EOF. - * - * Call this whenever leaving the current session — whether switching to - * another session, shutting down the process, or any other transition. - * This single entry point replaces scattered re-append calls and ensures - * the custom_title record stays within the last 64KB tail window that - * readSessionTitleFromFile() scans. + * Finalizes the current session by re-appending cached metadata to EOF, but + * only after this recorder has appended non-title content since the last + * title anchor. Pure load/resume must remain read-only so session lists do + * not treat restored sessions as newly active. * * Best-effort: errors are logged but never thrown. */ @@ -1344,6 +1347,9 @@ export class ChatRecordingService { if (!this.currentCustomTitle) { return; } + if (!this.hasNonTitleContentSinceTitleAnchor) { + return; + } try { const record: ChatRecord = { ...this.createBaseRecord('system'),