diff --git a/src/server/__tests__/task-notification-persistence.test.ts b/src/server/__tests__/task-notification-persistence.test.ts index 87077a0c..d3fdb549 100644 --- a/src/server/__tests__/task-notification-persistence.test.ts +++ b/src/server/__tests__/task-notification-persistence.test.ts @@ -82,20 +82,33 @@ describe('background task notification persistence', () => { timestamp: '2026-07-18T00:00:00.000Z', })}\n`, 'utf8') + const realOpen = fs.open const realWriteFile = fs.writeFile + let appendClosed = false let markAppendStarted!: () => void const appendStarted = new Promise((resolve) => { markAppendStarted = resolve }) - spyOn(fs, 'writeFile').mockImplementation((filePath: any, data: any, options: any) => { - if (options && typeof options === 'object' && options.flag === 'a') { + spyOn(fs, 'open').mockImplementation(async (...args) => { + const handle = await realOpen(...args) + const realClose = handle.close.bind(handle) + spyOn(handle, 'close').mockImplementation(async () => { + await realClose() + appendClosed = true + }) + spyOn(handle, 'writeFile').mockImplementation((_data, options) => { markAppendStarted() + const signal = typeof options === 'object' ? options?.signal : undefined return new Promise((resolve) => { - if (options.signal?.aborted) resolve() - else options.signal?.addEventListener('abort', () => resolve(), { once: true }) + if (signal?.aborted) resolve() + else signal?.addEventListener('abort', () => resolve(), { once: true }) }) - } - return realWriteFile(filePath, data, options) + }) + return handle + }) + spyOn(fs, 'writeFile').mockImplementation((...args) => { + expect(appendClosed).toBe(true) + return realWriteFile(...args) }) const service = new SessionService() @@ -129,27 +142,39 @@ describe('background task notification persistence', () => { timestamp: '2026-07-18T00:00:00.000Z', })}\n`, 'utf8') - const realWriteFile = fs.writeFile + const realOpen = fs.open let appendAttempts = 0 + let abortedAppendClosed = false let markAppendStarted!: () => void const appendStarted = new Promise((resolve) => { markAppendStarted = resolve }) - spyOn(fs, 'writeFile').mockImplementation((filePath: any, data: any, options: any) => { - if (options && typeof options === 'object' && options.flag === 'a') { - appendAttempts++ - if (appendAttempts > 1) return realWriteFile(filePath, data, options) + spyOn(fs, 'open').mockImplementation(async (...args) => { + const handle = await realOpen(...args) + appendAttempts++ + if (appendAttempts > 1) return handle + const realClose = handle.close.bind(handle) + spyOn(handle, 'close').mockImplementation(async () => { + await realClose() + abortedAppendClosed = true + }) + spyOn(handle, 'writeFile').mockImplementation((_data, options) => { markAppendStarted() + const signal = typeof options === 'object' ? options?.signal : undefined return new Promise((_resolve, reject) => { const rejectAbort = () => { const error = new Error('The operation was aborted') error.name = 'AbortError' reject(error) } - if (options.signal?.aborted) rejectAbort() - else options.signal?.addEventListener('abort', rejectAbort, { once: true }) + if (signal?.aborted) rejectAbort() + else signal?.addEventListener('abort', rejectAbort, { once: true }) }) - } + }) + return handle + }) + spyOn(fs, 'writeFile').mockImplementation(() => { + expect(abortedAppendClosed).toBe(true) return Promise.reject(new Error('transcript replacement failed')) }) diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index d546c04b..1fa52e1b 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -23,7 +23,10 @@ import { import { conversationService } from '../services/conversationService.js' import { computerUseApprovalService } from '../services/computerUseApprovalService.js' import { sessionService } from '../services/sessionService.js' +import * as titleService from '../services/titleService.js' +import { SettingsService } from '../services/settingsService.js' import * as teleportApi from '../../utils/teleport/api.js' +import { resetSettingsCache, setSessionSettingsCache } from '../../utils/settings/settingsCache.js' function makeClientSocket(sessionId: string, clientKind: 'full' | 'pet' = 'full') { const sent: string[] = [] @@ -122,6 +125,7 @@ describe('translateCliMessage usage mapping', () => { describe('WebSocket handler session title lifecycle', () => { afterEach(() => { + resetSettingsCache() __resetWebSocketHandlerStateForTests() mock.restore() }) @@ -158,6 +162,85 @@ describe('WebSocket handler session title lifecycle', () => { ) }) + it('keeps private prompt titles in memory when settings change while title saving awaits', async () => { + const sessionId = `title-private-${crypto.randomUUID()}` + const ws = makeClientSocket(sessionId) + spyOn(conversationService, 'hasSession').mockReturnValue(true) + spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([]) + spyOn(conversationService, 'onOutput').mockImplementation(() => {}) + spyOn(conversationService, 'removeOutputCallback').mockImplementation(() => {}) + spyOn(conversationService, 'sendMessage').mockResolvedValue(true) + setSessionSettingsCache({ settings: { cleanupPeriodDays: 0 }, errors: [] }) + let release!: (value: string | null) => void + const saving = new Promise(resolve => { release = resolve }) + spyOn(sessionService, 'getCustomTitle') + .mockResolvedValueOnce(null) + .mockImplementationOnce(() => saving) + spyOn(sessionService, 'getSessionLaunchInfo').mockResolvedValue({ + filePath: '/tmp/private-session.jsonl', projectDir: '/tmp', workDir: '/tmp', + transcriptMessageCount: 0, customTitle: null, + }) + const append = spyOn(sessionService, 'appendAiTitle').mockResolvedValue(undefined) + handleWebSocket.open(ws) + handleWebSocket.message(ws, JSON.stringify({ type: 'user_message', content: 'PRIVATE delayed prompt' })) + await flushMicrotasks(30) + setSessionSettingsCache({ settings: { cleanupPeriodDays: 365 }, errors: [] }) + release(null) + await flushMicrotasks(30) + expect(append).toHaveBeenCalledWith(sessionId, 'PRIVATE delayed prompt', false) + expect(ws.sent.map(payload => JSON.parse(payload))).toContainEqual({ + type: 'session_title_updated', sessionId, title: 'PRIVATE delayed prompt', + }) + }) + + it('does not persist delayed polished titles or later refreshes derived from private turns', async () => { + spyOn(SettingsService.prototype, 'getUserSettings').mockResolvedValue({}) + const sessionId = `title-private-complete-${crypto.randomUUID()}` + const ws = makeClientSocket(sessionId) + const callbacks = new Set<(message: any) => void>() + spyOn(conversationService, 'hasSession').mockReturnValue(true) + spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([]) + spyOn(conversationService, 'onOutput').mockImplementation((_id, callback) => { callbacks.add(callback) }) + spyOn(conversationService, 'removeOutputCallback').mockImplementation((_id, callback) => { callbacks.delete(callback) }) + spyOn(conversationService, 'sendMessage').mockResolvedValue(true) + spyOn(sessionService, 'getCustomTitle').mockResolvedValue(null) + spyOn(sessionService, 'getSessionLaunchInfo').mockResolvedValue({ + filePath: '/tmp/private-session.jsonl', projectDir: '/tmp', workDir: '/tmp', + transcriptMessageCount: 0, customTitle: null, + }) + setSessionSettingsCache({ settings: { cleanupPeriodDays: 0 }, errors: [] }) + const append = spyOn(sessionService, 'appendAiTitle').mockResolvedValue(undefined) + let release!: (value: string) => void + const generated = new Promise(resolve => { release = resolve }) + const generate = spyOn(titleService, 'generateTitle').mockImplementation(() => generated) + handleWebSocket.open(ws) + handleWebSocket.message(ws, JSON.stringify({ type: 'user_message', content: 'PRIVATE prompt' })) + await flushMicrotasks(30) + for (const callback of [...callbacks]) callback({ type: 'result', subtype: 'success', result: 'PRIVATE reply' }) + // Language settings load is async; wait until the controlled model seam starts. + for (let i = 0; i < 100 && generate.mock.calls.length === 0; i++) { + await new Promise(resolve => setTimeout(resolve, 1)) + } + expect(generate).toHaveBeenCalledTimes(1) + setSessionSettingsCache({ settings: { cleanupPeriodDays: 365 }, errors: [] }) + release('PRIVATE polished title') + await flushMicrotasks(30) + expect(append).toHaveBeenCalledWith(sessionId, 'PRIVATE polished title', false) + generate.mockResolvedValue('PRIVATE third-turn summary') + for (const content of ['PUBLIC second prompt', 'PUBLIC third prompt']) { + handleWebSocket.message(ws, JSON.stringify({ type: 'user_message', content })) + await flushMicrotasks(30) + for (const callback of [...callbacks]) callback({ type: 'result', subtype: 'success', result: 'PUBLIC reply' }) + await flushMicrotasks(30) + } + for (let i = 0; i < 100 && generate.mock.calls.length < 2; i++) { + await new Promise(resolve => setTimeout(resolve, 1)) + } + await flushMicrotasks(30) + expect(generate).toHaveBeenCalledTimes(2) + expect(append).toHaveBeenCalledWith(sessionId, 'PRIVATE third-turn summary', false) + }) + it('ignores /compact for titles without disabling the next real first-message title', async () => { const sessionId = `title-compact-${crypto.randomUUID()}` const ws = makeClientSocket(sessionId) diff --git a/src/server/services/sessionService.retention.test.ts b/src/server/services/sessionService.retention.test.ts new file mode 100644 index 00000000..97a5345b --- /dev/null +++ b/src/server/services/sessionService.retention.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test' +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { SessionService } from './sessionService.js' +import { resetSettingsCache } from '../../utils/settings/settingsCache.js' + +let directory: string +let service: SessionService +let previousConfig: string | undefined +let previousHome: string | undefined + +async function retention(days: number) { + await writeFile(join(directory, 'settings.json'), JSON.stringify({ cleanupPeriodDays: days })) + resetSettingsCache() +} + +beforeEach(async () => { + directory = await mkdtemp('/tmp/session-service-retention-') + previousConfig = process.env.CLAUDE_CONFIG_DIR + previousHome = process.env.HOME + process.env.CLAUDE_CONFIG_DIR = directory + process.env.HOME = directory + resetSettingsCache() + service = new SessionService() +}) + +afterEach(async () => { + if (previousConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previousConfig + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + resetSettingsCache() + await rm(directory, { recursive: true, force: true }) +}) + +async function transcripts() { + return (await readdir(join(directory, 'projects'), { recursive: true }).catch(() => [])) + .filter(file => file.endsWith('.jsonl')) +} + +test('retention zero keeps workspace, runtime and title usable without snapshot/meta/title JSONL', async () => { + await retention(0) + const { sessionId, workDir } = await service.createSession(directory, undefined, 'plan') + await service.appendSessionMetadata(sessionId, { + workDir, runtimeProviderId: 'fake-provider', runtimeModelId: 'fake-model', effortLevel: 'high', + }) + await service.renameSession(sessionId, 'PRIVATE-CUSTOM') + await service.appendAiTitle(sessionId, 'PRIVATE-PROMPT-DERIVED-TITLE') + expect(await transcripts()).toEqual([]) + expect(await service.getSessionWorkDir(sessionId)).toBe(workDir) + expect(await service.getCustomTitle(sessionId)).toBe('PRIVATE-CUSTOM') + expect(await service.getSessionLaunchInfo(sessionId)).toMatchObject({ + workDir, permissionMode: 'plan', runtimeProviderId: 'fake-provider', + runtimeModelId: 'fake-model', effortLevel: 'high', transcriptMessageCount: 0, + }) +}) + +test('disabled metadata/title appends leave old files unchanged and clear does not recreate removed history', async () => { + await retention(365) + const { sessionId, workDir } = await service.createSession(directory) + const info = (await service.getSessionLaunchInfo(sessionId))! + const original = await readFile(info.filePath, 'utf8') + await retention(0) + await service.appendSessionMetadata(sessionId, { workDir, customTitle: 'PRIVATE-META', runtimeModelId: 'private-model' }) + await service.appendAiTitle(sessionId, 'PRIVATE-AI') + expect(await readFile(info.filePath, 'utf8')).toBe(original) + await rm(info.filePath) + await service.clearSessionTranscript(sessionId, workDir, 'plan') + expect(await transcripts()).toEqual([]) + expect(await service.getSessionLaunchInfo(sessionId)).toMatchObject({ workDir, permissionMode: 'plan' }) +}) + + +test('restoring persistence cannot copy private custom titles into metadata or save delayed private AI titles', async () => { + await retention(0) + const { sessionId, workDir } = await service.createSession(directory) + await service.renameSession(sessionId, 'PRIVATE-CUSTOM') + await retention(365) + const info = (await service.getSessionLaunchInfo(sessionId))! + // Simulate the runtime materializing only its new public turn. + await mkdir(join(directory, 'projects', info.projectDir), { recursive: true }) + await writeFile(info.filePath, JSON.stringify({ type: 'user', uuid: 'public-user', message: { role: 'user', content: 'PUBLIC' } }) + '\n') + await service.appendSessionMetadata(sessionId, { workDir, customTitle: 'PRIVATE-CUSTOM', runtimeModelId: 'public-model' }) + await service.appendAiTitle(sessionId, 'PRIVATE-DELAYED-AI', false) + await service.appendAiTitle(sessionId, 'Public title') + const content = await readFile(info.filePath, 'utf8') + expect(content).not.toContain('PRIVATE') + expect(content).toContain('Public title') + expect(await new SessionService().getSessionLaunchInfo(sessionId)).toMatchObject({ workDir, runtimeModelId: 'public-model', transcriptMessageCount: 1 }) +}) + +test('a delayed title never recreates an old transcript deleted after lookup', async () => { + await retention(365) + const { sessionId } = await service.createSession(directory) + const info = (await service.getSessionLaunchInfo(sessionId))! + const originalFind = service.findSessionFile.bind(service) + const lookup = spyOn(service, 'findSessionFile').mockImplementation(async id => { + const found = await originalFind(id) + await rm(info.filePath) + return found + }) + try { + await service.appendAiTitle(sessionId, 'Delayed old title') + expect(await transcripts()).toEqual([]) + } finally { + lookup.mockRestore() + } +}) + + +test('memory-only sessions can be deleted and cleared without creating JSONL', async () => { + await retention(0) + const { sessionId, workDir } = await service.createSession(directory) + await service.clearSessionTranscript(sessionId, workDir, 'plan') + await service.deleteSession(sessionId) + expect(await service.getSessionLaunchInfo(sessionId)).toBeNull() + expect(await service.getCustomTitle(sessionId)).toBeNull() + expect(await transcripts()).toEqual([]) +}) diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 91de87ee..11b475c1 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -5,7 +5,7 @@ * 确保 Desktop App 与 CLI 的数据完全互通。 */ -import { createReadStream, type Stats } from 'node:fs' +import { constants, createReadStream, type Stats } from 'node:fs' import { createHash } from 'node:crypto' import * as fs from 'node:fs/promises' import * as path from 'node:path' @@ -47,6 +47,7 @@ import { import { ProviderService } from './providerService.js' import { shouldHideCommandMetadataContent } from '../../utils/commandMetadata.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' +import { getSettings_DEPRECATED } from '../../utils/settings/settings.js' import { extractGoalCreationTitle, extractTranscriptUserTitle, @@ -584,6 +585,31 @@ function getSharedSessionMutationState( export class SessionService { private providerService = new ProviderService() + // Keep launch state available when retention disables or removes transcripts. + // Scope keys by config directory so test/embedded server instances cannot mix state. + private readonly memoryLaunchInfo = new Map() + private readonly privateTitles = new Map>() + + shouldPersistSession(): boolean { + return getSettings_DEPRECATED()?.cleanupPeriodDays !== 0 + } + + private memorySessionKey(sessionId: string): string { + return `${this.getConfigDir()}:${sessionId}` + } + + private rememberPrivateTitle(sessionId: string, title: string): void { + const key = this.memorySessionKey(sessionId) + const titles = this.privateTitles.get(key) ?? new Set() + titles.add(title) + this.privateTitles.set(key, titles) + } + + private canPersistTitle(sessionId: string, title: string): boolean { + return this.shouldPersistSession() && + !this.privateTitles.get(this.memorySessionKey(sessionId))?.has(title) + } + private readonly pendingTaskNotificationWrites = new Map< string, Set<{ @@ -1443,16 +1469,24 @@ export class SessionService { entry: Record, signal?: AbortSignal, ): Promise { + if (!this.shouldPersistSession()) return const line = JSON.stringify(entry) + '\n' - if (signal) { - await fs.writeFile(filePath, line, { - encoding: 'utf-8', - flag: 'a', - signal, - }) - return + // A delayed title/notification must not recreate a transcript removed by + // retention cleanup after lookup. Metadata relocation opts into creation. + let handle: fs.FileHandle + try { + handle = await fs.open(filePath, constants.O_WRONLY | constants.O_APPEND | + (entry.type === 'session-meta' ? constants.O_CREAT : 0)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + try { + if (!this.shouldPersistSession()) return + await handle.writeFile(line, { encoding: 'utf-8', signal }) + } finally { + await handle.close() } - await fs.appendFile(filePath, line, 'utf-8') } private resolveWorkDirFromEntries( @@ -3965,6 +3999,7 @@ export class SessionService { permissionMode?: string, ): Promise<{ sessionId: string; workDir: string }> { // Default to user home directory when no workDir specified + const persist = this.shouldPersistSession() const resolvedWorkDir = workDir || os.homedir() const sessionId = crypto.randomUUID() @@ -3991,7 +4026,7 @@ export class SessionService { const dirPath = path.join(this.getProjectsDir(), sanitized) // Ensure the project directory exists - await fs.mkdir(dirPath, { recursive: true }) + if (persist && this.shouldPersistSession()) await fs.mkdir(dirPath, { recursive: true }) const filePath = path.join(dirPath, `${sessionId}.jsonl`) const now = new Date().toISOString() @@ -4020,7 +4055,16 @@ export class SessionService { timestamp: now, } - await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n' + JSON.stringify(metaEntry) + '\n', 'utf-8') + if (!persist || !this.shouldPersistSession()) this.memoryLaunchInfo.set(this.memorySessionKey(sessionId), { + filePath, projectDir: sanitized, workDir: absWorkDir, + repository: preparedWorkspace.repository, transcriptMessageCount: 0, + customTitle: null, + ...(permissionMode && VALID_SESSION_PERMISSION_MODES.has(permissionMode) + ? { permissionMode } : {}), + }) + if (persist && this.shouldPersistSession()) { + await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n' + JSON.stringify(metaEntry) + '\n', 'utf-8') + } this.invalidateSessionListCache() return { sessionId, workDir: absWorkDir } @@ -4031,12 +4075,14 @@ export class SessionService { */ async deleteSession(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) - if (!found) { + if (!found && !this.memoryLaunchInfo.has(this.memorySessionKey(sessionId))) { throw ApiError.notFound(`Session not found: ${sessionId}`) } - await fs.unlink(found.filePath) - this.sessionListSummaryCache.delete(found.filePath) + if (found) await fs.unlink(found.filePath) + this.memoryLaunchInfo.delete(this.memorySessionKey(sessionId)) + this.privateTitles.delete(this.memorySessionKey(sessionId)) + if (found) this.sessionListSummaryCache.delete(found.filePath) this.invalidateSessionListCache() } @@ -4081,10 +4127,18 @@ export class SessionService { throw ApiError.badRequest('title is required') } - const found = await this.findSessionFile(sessionId) - if (!found) { - throw ApiError.notFound(`Session not found: ${sessionId}`) + const persist = this.shouldPersistSession() + const info = await this.getSessionLaunchInfo(sessionId) + if (!info) throw ApiError.notFound(`Session not found: ${sessionId}`) + if (!persist || !this.shouldPersistSession() || this.memoryLaunchInfo.has(this.memorySessionKey(sessionId))) { + this.memoryLaunchInfo.set(this.memorySessionKey(sessionId), { ...info, customTitle: title }) } + if (!persist || !this.shouldPersistSession()) { + this.rememberPrivateTitle(sessionId, title) + return + } + const found = await this.findSessionFile(sessionId) + if (!found || !this.canPersistTitle(sessionId, title)) return const entry = { type: 'custom-title', @@ -4099,9 +4153,13 @@ export class SessionService { /** * Append an AI-generated title entry to a session's JSONL file. */ - async appendAiTitle(sessionId: string, title: string): Promise { + async appendAiTitle(sessionId: string, title: string, persist = this.shouldPersistSession()): Promise { + if (!persist || !this.shouldPersistSession()) { + this.rememberPrivateTitle(sessionId, title) + return + } const found = await this.findSessionFile(sessionId) - if (!found) return + if (!found || !this.canPersistTitle(sessionId, title)) return await this.appendJsonlEntry(found.filePath, { type: 'ai-title', @@ -4112,6 +4170,8 @@ export class SessionService { } async getCustomTitle(sessionId: string): Promise { + const memory = this.memoryLaunchInfo.get(this.memorySessionKey(sessionId)) + if (memory?.customTitle) return memory.customTitle const found = await this.findSessionFile(sessionId) if (!found) return null @@ -4130,6 +4190,8 @@ export class SessionService { * First checks for stored session-meta entry, then falls back to desanitizePath. */ async getSessionWorkDir(sessionId: string): Promise { + const memory = this.memoryLaunchInfo.get(this.memorySessionKey(sessionId)) + if (memory) return memory.workDir const found = await this.findSessionFile(sessionId) if (!found) return null @@ -4154,8 +4216,9 @@ export class SessionService { * Placeholder desktop-created sessions have zero transcript messages. */ async getSessionLaunchInfo(sessionId: string): Promise { + const memory = this.memoryLaunchInfo.get(this.memorySessionKey(sessionId)) const found = await this.findSessionFile(sessionId) - if (!found) return null + if (!found) return memory ? { ...memory, transcriptMessageCount: 0 } : null const entries = await this.readJsonlFile(found.filePath) const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd() @@ -4195,12 +4258,13 @@ export class SessionService { workDir, repository, worktreeSession, - transcriptMessageCount, customTitle, permissionMode, ...(runtimeProviderId !== undefined ? { runtimeProviderId } : {}), ...(runtimeModelId ? { runtimeModelId } : {}), ...(effortLevel ? { effortLevel } : {}), + ...memory, + transcriptMessageCount, } } @@ -4216,6 +4280,7 @@ export class SessionService { fallbackWorkDir?: string, preservedPermissionMode?: string, ): Promise { + const persist = this.shouldPersistSession() const nextEpoch = (this.taskNotificationMutationEpochs.get(sessionId) ?? 0) + 1 this.taskNotificationMutationEpochs.set(sessionId, nextEpoch) this.clearingTaskNotificationSessions.add(sessionId) @@ -4225,6 +4290,23 @@ export class SessionService { try { await Promise.allSettled(pendingWrites.map((pending) => pending.promise)) + if (!persist || !this.shouldPersistSession()) { + const storedInfo = await this.getSessionLaunchInfo(sessionId) + const workDir = fallbackWorkDir && normalizeDriveRootPathForPlatform(fallbackWorkDir) + const projectDir = workDir && this.sanitizePath(workDir) + const info = storedInfo ?? (workDir && projectDir ? { + filePath: path.join(this.getProjectsDir(), projectDir, `${sessionId}.jsonl`), + projectDir, workDir, transcriptMessageCount: 0, customTitle: null, + } : null) + if (info) { + this.memoryLaunchInfo.set(this.memorySessionKey(sessionId), { + ...info, transcriptMessageCount: 0, customTitle: null, + ...(preservedPermissionMode && VALID_SESSION_PERMISSION_MODES.has(preservedPermissionMode) + ? { permissionMode: preservedPermissionMode } : {}), + }) + } + return + } let found = await this.findSessionFile(sessionId) if (!found && fallbackWorkDir) { const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(fallbackWorkDir)) @@ -4271,6 +4353,8 @@ export class SessionService { timestamp: now, } + if (!this.shouldPersistSession()) return + this.memoryLaunchInfo.delete(this.memorySessionKey(sessionId)) await fs.writeFile( found.filePath, `${JSON.stringify(initialEntry)}\n${JSON.stringify(metaEntry)}\n`, @@ -4307,6 +4391,35 @@ export class SessionService { effortLevel?: string } ): Promise { + const persist = this.shouldPersistSession() + const storedInfo = await this.getSessionLaunchInfo(sessionId) + const workDir = normalizeDriveRootPathForPlatform(metadata.workDir) + const projectDir = this.sanitizePath(workDir) + const previousInfo = storedInfo ?? (!persist ? { + filePath: path.join(this.getProjectsDir(), projectDir, `${sessionId}.jsonl`), + projectDir, workDir, transcriptMessageCount: 0, customTitle: null, + } : null) + if (previousInfo && (!persist || !this.shouldPersistSession() || this.memoryLaunchInfo.has(this.memorySessionKey(sessionId)))) { + const normalizedWorkDir = normalizeDriveRootPathForPlatform(metadata.workDir) + const projectDir = this.sanitizePath(normalizedWorkDir) + this.memoryLaunchInfo.set(this.memorySessionKey(sessionId), { + ...previousInfo, + workDir: normalizedWorkDir, projectDir, + filePath: path.join(this.getProjectsDir(), projectDir, `${sessionId}.jsonl`), + ...(metadata.repository ? { repository: metadata.repository } : {}), + ...(metadata.customTitle ? { customTitle: metadata.customTitle } : {}), + ...(metadata.permissionMode && VALID_SESSION_PERMISSION_MODES.has(metadata.permissionMode) + ? { permissionMode: metadata.permissionMode } : {}), + ...(metadata.runtimeProviderId !== undefined ? { runtimeProviderId: metadata.runtimeProviderId } : {}), + ...(metadata.runtimeModelId ? { runtimeModelId: metadata.runtimeModelId } : {}), + ...(metadata.effortLevel && VALID_SESSION_EFFORT_LEVELS.has(metadata.effortLevel) + ? { effortLevel: metadata.effortLevel } : {}), + }) + } + if (!persist || !this.shouldPersistSession()) { + if (metadata.customTitle) this.rememberPrivateTitle(sessionId, metadata.customTitle) + return + } const matches = await this.findSessionFiles(sessionId) if (matches.length === 0) return @@ -4325,9 +4438,8 @@ export class SessionService { const targetProjectDir = this.sanitizePath(normalizedWorkDir) const targetFilePath = path.join(this.getProjectsDir(), targetProjectDir, `${sessionId}.jsonl`) - if (!metadata.customTitle) { - const launchInfo = await this.getSessionLaunchInfo(sessionId) - if (this.metadataMatchesLaunchInfo(launchInfo, { + if (!metadata.customTitle && !this.memoryLaunchInfo.has(this.memorySessionKey(sessionId))) { + if (this.metadataMatchesLaunchInfo(previousInfo, { ...metadata, workDir: normalizedWorkDir, repository, @@ -4356,7 +4468,7 @@ export class SessionService { timestamp: new Date().toISOString(), }) - if (metadata.customTitle) { + if (metadata.customTitle && this.canPersistTitle(sessionId, metadata.customTitle)) { await this.appendJsonlEntry(targetFilePath, { type: 'custom-title', customTitle: metadata.customTitle, @@ -4517,7 +4629,7 @@ export class SessionService { notification, notification.timestamp ?? new Date(this.now()).toISOString(), ) - if (!normalized) return + if (!normalized || !this.shouldPersistSession()) return if (this.clearingTaskNotificationSessions.has(sessionId)) return const epoch = this.taskNotificationMutationEpochs.get(sessionId) ?? 0 diff --git a/src/server/services/titleService.ts b/src/server/services/titleService.ts index 1413676d..07d575a2 100644 --- a/src/server/services/titleService.ts +++ b/src/server/services/titleService.ts @@ -492,10 +492,15 @@ function looksLikeStructuredTitleFragment(text: string): boolean { * Returns false when a user custom title exists, because custom titles are * intentional and must not be replaced by automatic title refreshes. */ -export async function saveAiTitle(sessionId: string, title: string): Promise { +export async function saveAiTitle( + sessionId: string, + title: string, + persist = sessionService.shouldPersistSession(), +): Promise { if (await sessionService.getCustomTitle(sessionId)) { return false } - await sessionService.appendAiTitle(sessionId, title) + if (persist) await sessionService.appendAiTitle(sessionId, title) + else await sessionService.appendAiTitle(sessionId, title, false) return true } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 18b218c8..fffa1bd0 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -173,6 +173,7 @@ const sessionTitleState = new Map, activeTurn: ActiveUserTurnState, ) { + const persistTitleSource = sessionService.shouldPersistSession() const { sessionId } = ws.data const desktopSlashCommand = getDesktopSlashCommand(message.content) @@ -820,6 +822,7 @@ async function handleUserMessage( userMessageCount: 0, hasCustomTitle, hasExistingTranscript: (launchInfo?.transcriptMessageCount ?? 0) > 0, + persistTitleSource, firstUserMessage: '', completedTurns: [], startedGenerationKeys: new Set(), @@ -830,6 +833,7 @@ async function handleUserMessage( const titleInput = getTitleInputForUserMessage(message.content, desktopSlashCommand) let titleTurnNumber: number | null = null if (titleInput) { + titleState.persistTitleSource &&= persistTitleSource titleState.userMessageCount++ titleTurnNumber = titleState.userMessageCount titleState.activeTurn = { @@ -2321,6 +2325,10 @@ function triggerTitleGeneration( ): void { const state = sessionTitleState.get(sessionId) if (!state || state.hasCustomTitle || state.hasExistingTranscript) return + // Titles summarize cumulative input. Once it includes a private turn, later + // refreshes must remain in memory even if retention is enabled again. + state.persistTitleSource &&= sessionService.shouldPersistSession() + const persist = state.persistTitleSource const count = phase === 'turn-complete' ? completedTurnCount ?? state.userMessageCount @@ -2337,7 +2345,7 @@ function triggerTitleGeneration( const text = state.firstUserMessage const placeholder = deriveTitle(text) if (placeholder) { - const saved = await saveAiTitle(sessionId, placeholder) + const saved = await saveAiTitle(sessionId, placeholder, persist) if (!saved) { state.hasCustomTitle = true return @@ -2375,7 +2383,7 @@ function triggerTitleGeneration( ) if (generationSeq !== state.generationSeq) return if (aiTitle) { - const saved = await saveAiTitle(sessionId, aiTitle) + const saved = await saveAiTitle(sessionId, aiTitle, persist && state.persistTitleSource) if (!saved) { state.hasCustomTitle = true return @@ -2457,8 +2465,10 @@ function bindTitleSessionOutput( } function appendAssistantTextForTitle(sessionId: string, cliMsg: any): void { - const activeTurn = sessionTitleState.get(sessionId)?.activeTurn - if (!activeTurn) return + const state = sessionTitleState.get(sessionId) + const activeTurn = state?.activeTurn + if (!state || !activeTurn) return + state.persistTitleSource &&= sessionService.shouldPersistSession() const streamText = extractAssistantStreamTextForTitle(cliMsg) if (streamText) { diff --git a/src/utils/__tests__/sessionStorageRetention.test.ts b/src/utils/__tests__/sessionStorageRetention.test.ts new file mode 100644 index 00000000..586da16a --- /dev/null +++ b/src/utils/__tests__/sessionStorageRetention.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { randomUUID } from 'node:crypto' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { switchSession } from '../../bootstrap/state.js' +import type { SessionId } from '../../types/ids.js' +import { + clearSessionMessagesCache, + flushSessionStorage, + getLastSessionLog, + getTranscriptPathForSession, + reAppendSessionMetadata, + recordTranscript, + recordSidechainTranscript, + getAgentTranscriptPath, + resetProjectForTesting, +} from '../sessionStorage.js' +import { resetSettingsCache, setSessionSettingsCache } from '../settings/settingsCache.js' + +const originalConfigDir = process.env.CLAUDE_CONFIG_DIR +const originalTestPersistence = process.env.TEST_ENABLE_SESSION_PERSISTENCE +const retention = (days: number) => setSessionSettingsCache({ settings: { cleanupPeriodDays: days }, errors: [] }) +let messageClock = 0 +const user = (content: string) => ({ + type: 'user' as const, + uuid: randomUUID(), + timestamp: new Date(1_700_000_000_000 + messageClock++).toISOString(), + message: { role: 'user', content }, +}) + +// Exercise the growing in-memory history used by QueryEngine without a model. +describe('session retention transitions', () => { + let tmpDir: string + let sessionId: SessionId + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'session-retention-')) + process.env.CLAUDE_CONFIG_DIR = tmpDir + process.env.TEST_ENABLE_SESSION_PERSISTENCE = '1' + resetProjectForTesting() + clearSessionMessagesCache() + sessionId = randomUUID() as SessionId + switchSession(sessionId) + retention(365) + }) + afterEach(async () => { + await flushSessionStorage() + resetProjectForTesting() + clearSessionMessagesCache() + resetSettingsCache() + if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = originalConfigDir + if (originalTestPersistence === undefined) delete process.env.TEST_ENABLE_SESSION_PERSISTENCE + else process.env.TEST_ENABLE_SESSION_PERSISTENCE = originalTestPersistence + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + for (const initiallyEnabled of [false, true]) { + it(`never backfills disabled user/assistant/tool history (${initiallyEnabled ? 'positive→0→positive' : '0→positive'})`, async () => { + const history: any[] = [] + const transcriptPath = getTranscriptPathForSession(sessionId) + if (initiallyEnabled) { + history.push(user('previously saved and then removed')) + await recordTranscript(history) + await flushSessionStorage() + await fs.unlink(transcriptPath) + } + retention(0) + const privateUser = user('PRIVATE USER CANARY') + const privateAssistant = { + type: 'assistant', uuid: randomUUID(), timestamp: new Date(1_700_000_000_000 + messageClock++).toISOString(), + message: { role: 'assistant', content: [{ type: 'tool_use', id: 'tool-private', name: 'Read', input: { file_path: 'PRIVATE TOOL INPUT' } }] }, + } + const privateResult = { + ...user(''), sourceToolAssistantUUID: privateAssistant.uuid, + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool-private', content: 'PRIVATE TOOL RESULT' }] }, + } + history.push(privateUser, privateAssistant, privateResult) + const disabledParent = await recordTranscript(history) + await flushSessionStorage() + expect(await fs.access(transcriptPath).then(() => true, () => false)).toBe(false) + retention(365) + // An incremental caller can retain a stale parent hint; tool results may + // also explicitly refer to a suppressed assistant across the transition. + const fresh = { ...user('NEW PUBLIC MESSAGE'), sourceToolAssistantUUID: privateAssistant.uuid } + history.push(fresh) + await recordTranscript(history, undefined, disabledParent ?? undefined) + await flushSessionStorage() + reAppendSessionMetadata() + const raw = await fs.readFile(transcriptPath, 'utf8') + expect(raw).not.toContain('PRIVATE') + expect(raw).not.toContain('previously saved and then removed') + const entries = raw.trim().split('\n').map(line => JSON.parse(line)) + const messages = entries.filter(entry => entry.type === 'user' || entry.type === 'assistant') + expect(messages).toHaveLength(1) + expect(messages[0]).toMatchObject({ uuid: fresh.uuid, parentUuid: null }) + const next = user('SECOND PUBLIC MESSAGE') + history.push(next) + await recordTranscript(history) + await flushSessionStorage() + const restored = await getLastSessionLog(sessionId) + expect(restored?.messages.map(message => message.uuid)).toEqual([fresh.uuid, next.uuid]) + }) + } + + it('keeps an unflushed enabled prefix when a second incremental write arrives', async () => { + const first = user('enabled first') + const second = user('enabled second') + const parent = await recordTranscript([first] as never[]) + await recordTranscript([first, second] as never[], undefined, parent ?? undefined) + await flushSessionStorage() + expect((await getLastSessionLog(sessionId))?.messages.map(message => message.uuid)).toEqual([first.uuid, second.uuid]) + }) + + it('starts a valid new chain after cleanup even when no messages were sent while disabled', async () => { + const old = user('OLD REMOVED HISTORY') + await recordTranscript([old] as never[]) + await flushSessionStorage() + retention(0) + const transcriptPath = getTranscriptPathForSession(sessionId) + await fs.unlink(transcriptPath) + retention(365) + const fresh = user('NEW AFTER IDLE CLEANUP') + await recordTranscript([old, fresh] as never[]) + await flushSessionStorage() + const raw = await fs.readFile(transcriptPath, 'utf8') + expect(raw).not.toContain('OLD REMOVED HISTORY') + expect(JSON.parse(raw.trim())).toMatchObject({ uuid: fresh.uuid, parentUuid: null }) + expect((await getLastSessionLog(sessionId))?.messages.map(message => message.uuid)).toEqual([fresh.uuid]) + }) + + it('drops queued writes when retention is disabled before flush', async () => { + const message = user('QUEUED PRIVATE PROMPT') + await recordTranscript([message] as never[]) + const transcriptPath = getTranscriptPathForSession(sessionId) + retention(0) + await fs.rm(transcriptPath, { force: true }) + await flushSessionStorage() + expect(await fs.access(transcriptPath).then(() => true, () => false)).toBe(false) + retention(365) + await recordTranscript([message, user('new after queued discard')] as never[]) + await flushSessionStorage() + expect(await fs.readFile(transcriptPath, 'utf8')).not.toContain('QUEUED PRIVATE PROMPT') + }) + + it('attributes discarded queued messages to their original session across a switch', async () => { + const privateMessage = user('PRIVATE QUEUED SESSION A') + await recordTranscript([privateMessage] as never[]) + switchSession(randomUUID() as SessionId) + retention(0) + await flushSessionStorage() + switchSession(sessionId) + clearSessionMessagesCache() + retention(365) + const fresh = user('public session A') + await recordTranscript([privateMessage, fresh] as never[]) + await flushSessionStorage() + const raw = await fs.readFile(getTranscriptPathForSession(sessionId), 'utf8') + expect(raw).not.toContain('PRIVATE') + expect(JSON.parse(raw.trim())).toMatchObject({ uuid: fresh.uuid, parentUuid: null }) + }) + + it('keeps excluded sidechain messages private after dedup cache invalidation', async () => { + const privateMessage = user('PRIVATE SIDECHAIN') + retention(0) + await recordSidechainTranscript([privateMessage] as never[], 'retention-agent') + clearSessionMessagesCache() + retention(365) + const fresh = user('public sidechain') + await recordSidechainTranscript([privateMessage, fresh] as never[], 'retention-agent', privateMessage.uuid) + await flushSessionStorage() + const raw = await fs.readFile(getAgentTranscriptPath('retention-agent' as never), 'utf8') + expect(raw).not.toContain('PRIVATE') + expect(JSON.parse(raw.trim())).toMatchObject({ uuid: fresh.uuid, parentUuid: null }) + }) + + it('does not rebuild a removed session through cached last-prompt metadata while disabled', async () => { + await recordTranscript([user('old prompt')] as never[]) + await flushSessionStorage() + const transcriptPath = getTranscriptPathForSession(sessionId) + retention(0) + await fs.unlink(transcriptPath) + await recordTranscript([user('PRIVATE LAST PROMPT')] as never[]) + reAppendSessionMetadata() + await flushSessionStorage() + expect(await fs.access(transcriptPath).then(() => true, () => false)).toBe(false) + }) +}) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 7c635834..7b5c58f3 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -600,6 +600,48 @@ class Project { // Entries buffered while sessionFile is null. Flushed by materializeSessionFile // on the first user/assistant message — prevents metadata-only session files. private pendingEntries: Entry[] = [] + // UUIDs observed while recording was disabled must never be backfilled by + // growing-history callers. Keep these separate from the on-disk dedup cache: + // excluded messages cannot become parents, even after cache invalidation. + private excludedMessages = new Map>() + + isMessageExcluded(uuid: UUID, sessionId = getSessionId()): boolean { + return this.excludedMessages.get(sessionId)?.has(uuid) ?? false + } + + private excludeMessageUuids(uuids: Iterable, sessionId = getSessionId()): void { + let excluded = this.excludedMessages.get(sessionId) + if (!excluded) { + excluded = new Set() + this.excludedMessages.set(sessionId, excluded) + } + for (const uuid of uuids) excluded.add(uuid) + } + + filterPersistableMessages(messages: Transcript, sessionId = getSessionId()): Transcript { + if (this.shouldSkipPersistence()) { + this.excludeMessageUuids(messages.map(message => message.uuid), sessionId) + this.pendingEntries = [] + this.currentSessionLastPrompt = undefined + return [] + } + return messages.filter(message => !this.isMessageExcluded(message.uuid, sessionId)) + } + + async discardDeletedTranscriptHistory(messageSet: Set, sessionId: UUID): Promise { + const filePath = this.sessionFile ?? getTranscriptPathForSession(sessionId) + // An unflushed first turn has UUIDs in the dedup cache before its file + // exists. Only treat a missing file as cleanup when no writes are pending. + if (messageSet.size === 0 || this.activeDrain || this.writeQueues.get(filePath)?.length) return + try { + await stat(filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return + this.excludeMessageUuids(messageSet, sessionId) + messageSet.clear() + this.currentSessionLastPrompt = undefined + } + } private remoteIngressUrl: string | null = null private internalEventWriter: InternalEventWriter | null = null private internalEventReader: InternalEventReader | null = null @@ -698,6 +740,16 @@ class Project { continue } const batch = queue.splice(0) + // Cleanup can disable recording while a batch is waiting for its timer. + // Discard it instead of recreating a transcript removed by cleanup. + if (this.shouldSkipPersistence()) { + for (const { entry, resolve } of batch) { + if ('uuid' in entry) this.filterPersistableMessages([entry as TranscriptMessage], entry.sessionId as SessionId) + resolve() + } + this.currentSessionLastPrompt = undefined + continue + } let content = '' const resolvers: Array<() => void> = [] @@ -769,7 +821,7 @@ class Project { * external-writer concern — their caches are authoritative. */ reAppendSessionMetadata(skipTitleRefresh = false): void { - if (!this.sessionFile) return + if (!this.sessionFile || this.shouldSkipPersistence()) return const sessionId = getSessionId() as UUID if (!sessionId) return @@ -1058,8 +1110,13 @@ class Project { startingParentUuid?: UUID | null, teamInfo?: { teamName?: string; agentName?: string }, ) { + // Capture the policy before any async work: a later settings change must + // not make the history submitted while disabled eligible for persistence. + messages = this.filterPersistableMessages(messages) return this.trackWrite(async () => { - let parentUuid: UUID | null = startingParentUuid ?? null + if (messages.length === 0) return + let parentUuid: UUID | null = startingParentUuid && !this.isMessageExcluded(startingParentUuid) + ? startingParentUuid : null // First user/assistant message materializes the session file. // Hook progress/attachment messages alone stay buffered. @@ -1084,6 +1141,7 @@ class Project { const slug = getPlanSlugCache().get(sessionId) for (const message of messages) { + if (this.filterPersistableMessages([message]).length === 0) continue const isCompactBoundary = isCompactBoundaryMessage(message) // For tool_result messages, use the assistant message UUID from the message @@ -1092,7 +1150,8 @@ class Project { if ( message.type === 'user' && 'sourceToolAssistantUUID' in message && - message.sourceToolAssistantUUID + message.sourceToolAssistantUUID && + !this.isMessageExcluded(message.sourceToolAssistantUUID) ) { effectiveParentUuid = message.sourceToolAssistantUUID } @@ -1124,7 +1183,7 @@ class Project { slug, } await this.appendEntry(transcriptMessage) - if (isChainParticipant(message)) { + if (isChainParticipant(message) && !this.isMessageExcluded(message.uuid)) { parentUuid = message.uuid } } @@ -1132,8 +1191,10 @@ class Project { // Cache this turn's user prompt for reAppendSessionMetadata — // the --resume picker shows what the user was last doing. // Overwritten every turn by design. - if (!isSidechain) { - const text = getFirstMeaningfulUserMessageTextContent(messages) + if (!isSidechain && !this.shouldSkipPersistence()) { + const text = getFirstMeaningfulUserMessageTextContent( + this.filterPersistableMessages(messages), + ) if (text) { const flat = text.replace(/\n/g, ' ').trim() this.currentSessionLastPrompt = @@ -1188,6 +1249,7 @@ class Project { async appendEntry(entry: Entry, sessionId: UUID = getSessionId() as UUID) { if (this.shouldSkipPersistence()) { + if ('uuid' in entry) this.filterPersistableMessages([entry as TranscriptMessage], sessionId as SessionId) return } @@ -1472,13 +1534,21 @@ export async function recordTranscript( startingParentUuidHint?: UUID, allMessages?: readonly Message[], ): Promise { - const cleanedMessages = cleanMessagesForLogging(messages, allMessages) + const project = getProject() + const cleanedMessages = project.filterPersistableMessages( + cleanMessagesForLogging(messages, allMessages), + ) + if (cleanedMessages.length === 0) return null const sessionId = getSessionId() as UUID const messageSet = await getSessionMessages(sessionId) + await project.discardDeletedTranscriptHistory(messageSet, sessionId) const newMessages: typeof cleanedMessages = [] - let startingParentUuid: UUID | undefined = startingParentUuidHint + let startingParentUuid: UUID | undefined = startingParentUuidHint && + !project.isMessageExcluded(startingParentUuidHint) && messageSet.has(startingParentUuidHint) + ? startingParentUuidHint : undefined let seenNewMessage = false for (const m of cleanedMessages) { + if (project.isMessageExcluded(m.uuid)) continue if (messageSet.has(m.uuid as UUID)) { // Only track skipped messages that form a prefix. After compaction, // messagesToKeep appear AFTER new CB/summary, so this skips them. @@ -1505,7 +1575,9 @@ export async function recordTranscript( // slice is all-recorded (rewind, /resume scenarios where every message is // already in messageSet). Progress is skipped — it's written to the JSONL // but nothing chains TO it (see isChainParticipant). - const lastRecorded = newMessages.findLast(isChainParticipant) + const lastRecorded = newMessages.findLast(m => + isChainParticipant(m) && !project.isMessageExcluded(m.uuid), + ) return (lastRecorded?.uuid as UUID | undefined) ?? startingParentUuid ?? null }