mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix: honor disabled session retention across writes and resume
This commit is contained in:
@@ -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([])
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user