merge: integrate QA-003 retention fix

This commit is contained in:
程序员阿江(Relakkes)
2026-09-10 17:59:49 +08:00
8 changed files with 669 additions and 55 deletions
@@ -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<void>((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<void>((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<void>((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<void>((_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'))
})
@@ -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<string | null>(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<string>(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)
@@ -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([])
})
+138 -26
View File
@@ -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<string, SessionLaunchInfo>()
private readonly privateTitles = new Map<string, Set<string>>()
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<string>()
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<string, unknown>,
signal?: AbortSignal,
): Promise<void> {
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<void> {
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<void> {
async appendAiTitle(sessionId: string, title: string, persist = this.shouldPersistSession()): Promise<void> {
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<string | null> {
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<string | null> {
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<SessionLaunchInfo | null> {
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<void> {
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<void> {
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
+7 -2
View File
@@ -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<boolean> {
export async function saveAiTitle(
sessionId: string,
title: string,
persist = sessionService.shouldPersistSession(),
): Promise<boolean> {
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
}
+14 -4
View File
@@ -173,6 +173,7 @@ const sessionTitleState = new Map<string, {
userMessageCount: number
hasCustomTitle: boolean
hasExistingTranscript: boolean
persistTitleSource: boolean
firstUserMessage: string
completedTurns: TitleConversationTurn[]
activeTurn?: TitleConversationTurn & { count: number }
@@ -762,6 +763,7 @@ async function handleUserMessage(
message: Extract<ClientMessage, { type: 'user_message' }>,
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<string>(),
@@ -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) {
@@ -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)
})
})
+81 -9
View File
@@ -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<string, Set<UUID>>()
isMessageExcluded(uuid: UUID, sessionId = getSessionId()): boolean {
return this.excludedMessages.get(sessionId)?.has(uuid) ?? false
}
private excludeMessageUuids(uuids: Iterable<UUID>, 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<UUID>, sessionId: UUID): Promise<void> {
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<UUID | null> {
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
}