diff --git a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts index 9320a2fe02..a9ddc3e660 100644 --- a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +++ b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts @@ -18,6 +18,12 @@ type ParsedSession = { sessionName?: string; }; +type TranscriptTitles = { + customTitle?: string; + aiTitle?: string; + lastPrompt?: string; +}; + /** * Session indexer for Claude transcript artifacts. */ @@ -137,18 +143,21 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { // ids must be resolved through the provider-id mapping first. const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId) ?? sessionsDb.getSessionById(parsed.sessionId); - const existingSessionName = existingSession?.custom_name; - if (existingSessionName && existingSessionName !== 'Untitled Claude Session') { - return { - ...parsed, - sessionName: normalizeSessionName(existingSessionName, 'Untitled Claude Session'), - }; - } - - let sessionName = nameMap.get(parsed.sessionId); - if (!sessionName) { - sessionName = await this.extractSessionAiTitleFromEnd(filePath, parsed.sessionId); - } + const existingSessionName = existingSession?.custom_name !== 'Untitled Claude Session' + ? existingSession?.custom_name ?? undefined + : undefined; + + // The transcript is the source of truth for explicit names: `/rename` in the + // Claude CLI and a rename in this UI both land as a `custom-title` event, and + // the reverse scan below returns the newest one. The stored name only ranks + // above the history.jsonl first prompt so a UI rename whose transcript + // write-back failed is not silently reverted to that prompt. + const titles = await this.extractSessionTitlesFromEnd(filePath, parsed.sessionId); + const sessionName = titles.customTitle + ?? titles.aiTitle + ?? existingSessionName + ?? nameMap.get(parsed.sessionId) + ?? titles.lastPrompt; return { ...parsed, @@ -156,10 +165,19 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { }; } - private async extractSessionAiTitleFromEnd( + /** + * Collects the newest title-bearing transcript events for one session. + * + * Scans backwards so the last event of each type wins, matching how Claude + * itself resolves these `last-wins` records; the scan stops as soon as a + * `custom-title` is found because nothing outranks it. + */ + private async extractSessionTitlesFromEnd( filePath: string, sessionId: string - ): Promise { + ): Promise { + const titles: TranscriptTitles = {}; + try { const content = await readFile(filePath, 'utf8'); const lines = content.split(/\r?\n/); @@ -180,22 +198,29 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { const data = parsed as Record; const eventType = typeof data.type === 'string' ? data.type : undefined; const eventSessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; + if (!eventType || eventSessionId !== sessionId) { + continue; + } + const aiTitle = typeof data.aiTitle === 'string' ? data.aiTitle : undefined; const lastPrompt = typeof data.lastPrompt === 'string' ? data.lastPrompt : undefined; - const claudeRenamedTitle = typeof data.customTitle === 'string' ? data.customTitle : undefined; - - if ( - (eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) || - (eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim()) || - (eventType === "custom-title" && eventSessionId === sessionId && claudeRenamedTitle?.trim()) - ) { - return aiTitle || lastPrompt || claudeRenamedTitle; + const customTitle = typeof data.customTitle === 'string' ? data.customTitle : undefined; + + if (eventType === 'custom-title' && customTitle?.trim()) { + titles.customTitle = customTitle; + break; + } + if (eventType === 'ai-title' && aiTitle?.trim()) { + titles.aiTitle ??= aiTitle; + } + if (eventType === 'last-prompt' && lastPrompt?.trim()) { + titles.lastPrompt ??= lastPrompt; } } } catch { // Ignore missing/unreadable files so sync can continue. } - return undefined; + return titles; } } diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 2352f3a141..a13c203150 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -584,7 +584,7 @@ router.put( asyncHandler(async (req: Request, res: Response) => { const sessionId = parseSessionId(req.params.sessionId); const summary = parseSessionRenameSummary(req.body); - const result = sessionsService.renameSessionById(sessionId, summary); + const result = await sessionsService.renameSessionById(sessionId, summary); res.json(createApiSuccessResponse(result)); }), ); diff --git a/server/modules/providers/services/sessions.service.ts b/server/modules/providers/services/sessions.service.ts index 6836e87e00..9c45f48131 100644 --- a/server/modules/providers/services/sessions.service.ts +++ b/server/modules/providers/services/sessions.service.ts @@ -32,6 +32,40 @@ type ArchivedSessionListItem = { isProjectArchived: boolean; }; +/** + * Mirrors a rename into the Claude transcript as a `custom-title` event. + * + * Claude Code appends the same event on `/rename` and reads the last one back + * for its own session list, so writing it keeps both sides on the newest name + * and lets the session synchronizer re-derive the stored name from disk. + * Best effort by design: a missing or unwritable transcript must not fail the + * rename, which has already been persisted. + */ +async function appendClaudeCustomTitle( + session: { session_id: string; provider: string; provider_session_id: string | null; jsonl_path: string | null }, + customTitle: string +): Promise { + if (session.provider !== 'claude' || !session.jsonl_path) { + return; + } + + const event = JSON.stringify({ + type: 'custom-title', + customTitle, + sessionId: session.provider_session_id ?? session.session_id, + }); + + try { + // Never create the transcript: appendFile would happily materialize a file + // that Claude never wrote, leaving an orphan in ~/.claude/projects. + await fsp.access(session.jsonl_path); + await fsp.appendFile(session.jsonl_path, `${event}\n`, 'utf8'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Failed to record rename in Claude transcript for session ${session.session_id}:`, message); + } +} + /** * Removes one file if it exists. */ @@ -293,7 +327,7 @@ export const sessionsService = { /** * Renames one session by id without requiring the caller to pass provider. */ - renameSessionById(sessionId: string, summary: string): { sessionId: string; summary: string } { + async renameSessionById(sessionId: string, summary: string): Promise<{ sessionId: string; summary: string }> { const session = sessionsDb.getSessionById(sessionId); if (!session) { throw new AppError(`Session "${sessionId}" was not found.`, { @@ -303,6 +337,7 @@ export const sessionsService = { } sessionsDb.updateSessionCustomName(sessionId, summary); + await appendClaudeCustomTitle(session, summary); return { sessionId, summary }; }, }; diff --git a/server/modules/providers/tests/claude-sessions.test.ts b/server/modules/providers/tests/claude-sessions.test.ts new file mode 100644 index 0000000000..fa5237df5c --- /dev/null +++ b/server/modules/providers/tests/claude-sessions.test.ts @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; +import { ClaudeSessionSynchronizer } from '@/modules/providers/list/claude/claude-session-synchronizer.provider.js'; +import { sessionsService } from '@/modules/providers/services/sessions.service.js'; + +const patchHomeDir = (nextHomeDir: string) => { + const original = os.homedir; + (os as any).homedir = () => nextHomeDir; + return () => { + (os as any).homedir = original; + }; +}; + +async function withIsolatedDatabase(runTest: () => void | Promise): Promise { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'claude-provider-db-')); + const databasePath = path.join(tempDirectory, 'auth.db'); + + closeConnection(); + process.env.DATABASE_PATH = databasePath; + await initializeDatabase(); + + try { + await runTest(); + } finally { + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +/** + * Writes one Claude transcript. `events` are appended verbatim after the first + * user turn, mirroring the `custom-title` / `ai-title` / `last-prompt` records + * the CLI writes at the end of a session file. + */ +const writeClaudeTranscript = async ( + homeDir: string, + sessionId: string, + workspacePath: string, + events: Record[] = [] +): Promise => { + const projectDir = path.join(homeDir, '.claude', 'projects', workspacePath.replace(/[/\\]/g, '-')); + await mkdir(projectDir, { recursive: true }); + + const lines = [ + JSON.stringify({ type: 'user', sessionId, cwd: workspacePath }), + ...events.map((event) => JSON.stringify(event)), + ]; + + const filePath = path.join(projectDir, `${sessionId}.jsonl`); + await writeFile(filePath, `${lines.join('\n')}\n`, 'utf8'); + return filePath; +}; + +const writeHistory = async (homeDir: string, entries: { sessionId: string; display: string }[]): Promise => { + await mkdir(path.join(homeDir, '.claude'), { recursive: true }); + await writeFile( + path.join(homeDir, '.claude', 'history.jsonl'), + `${entries.map((entry) => JSON.stringify(entry)).join('\n')}\n`, + 'utf8' + ); +}; + +async function withClaudeHome(prefix: string, runTest: (context: { + homeDir: string; + workspacePath: string; +}) => Promise): Promise { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), prefix)); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await runTest({ homeDir: tempRoot, workspacePath }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +} + +test('Claude synchronizer prefers a custom-title event over the history first prompt', { concurrency: false }, async () => { + await withClaudeHome('claude-session-sync-custom-', async ({ homeDir, workspacePath }) => { + await writeHistory(homeDir, [{ sessionId: 'claude-1', display: 'first prompt text' }]); + await writeClaudeTranscript(homeDir, 'claude-1', workspacePath, [ + { type: 'custom-title', customTitle: 'Renamed in the CLI', sessionId: 'claude-1' }, + ]); + + await withIsolatedDatabase(async () => { + await new ClaudeSessionSynchronizer().synchronize(); + + assert.equal(sessionsDb.getSessionById('claude-1')?.custom_name, 'Renamed in the CLI'); + }); + }); +}); + +test('Claude synchronizer replaces a stored name with a newer custom-title', { concurrency: false }, async () => { + await withClaudeHome('claude-session-sync-rename-', async ({ homeDir, workspacePath }) => { + await writeHistory(homeDir, [{ sessionId: 'claude-1', display: 'first prompt text' }]); + await writeClaudeTranscript(homeDir, 'claude-1', workspacePath, [ + { type: 'custom-title', customTitle: 'First name', sessionId: 'claude-1' }, + ]); + + await withIsolatedDatabase(async () => { + const synchronizer = new ClaudeSessionSynchronizer(); + await synchronizer.synchronize(); + assert.equal(sessionsDb.getSessionById('claude-1')?.custom_name, 'First name'); + + // A second /rename appends another event; the newest one must win. + await writeClaudeTranscript(homeDir, 'claude-1', workspacePath, [ + { type: 'custom-title', customTitle: 'First name', sessionId: 'claude-1' }, + { type: 'custom-title', customTitle: 'Second name', sessionId: 'claude-1' }, + ]); + await synchronizer.synchronize(); + + assert.equal(sessionsDb.getSessionById('claude-1')?.custom_name, 'Second name'); + }); + }); +}); + +test('Claude synchronizer falls back from ai-title to history to last-prompt', { concurrency: false }, async () => { + await withClaudeHome('claude-session-sync-fallback-', async ({ homeDir, workspacePath }) => { + await writeHistory(homeDir, [{ sessionId: 'claude-history', display: 'History prompt' }]); + await writeClaudeTranscript(homeDir, 'claude-ai', workspacePath, [ + { type: 'last-prompt', lastPrompt: 'Ignored last prompt', sessionId: 'claude-ai' }, + { type: 'ai-title', aiTitle: 'Generated title', sessionId: 'claude-ai' }, + ]); + await writeClaudeTranscript(homeDir, 'claude-history', workspacePath, [ + { type: 'last-prompt', lastPrompt: 'Ignored last prompt', sessionId: 'claude-history' }, + ]); + await writeClaudeTranscript(homeDir, 'claude-last', workspacePath, [ + { type: 'last-prompt', lastPrompt: 'Only last prompt', sessionId: 'claude-last' }, + ]); + await writeClaudeTranscript(homeDir, 'claude-none', workspacePath); + + await withIsolatedDatabase(async () => { + await new ClaudeSessionSynchronizer().synchronize(); + + assert.equal(sessionsDb.getSessionById('claude-ai')?.custom_name, 'Generated title'); + assert.equal(sessionsDb.getSessionById('claude-history')?.custom_name, 'History prompt'); + assert.equal(sessionsDb.getSessionById('claude-last')?.custom_name, 'Only last prompt'); + assert.equal(sessionsDb.getSessionById('claude-none')?.custom_name, 'Untitled Claude Session'); + }); + }); +}); + +test('Claude synchronizer skips subagent transcripts', { concurrency: false }, async () => { + await withClaudeHome('claude-session-sync-subagent-', async ({ homeDir, workspacePath }) => { + const parentPath = await writeClaudeTranscript(homeDir, 'claude-parent', workspacePath, [ + { type: 'custom-title', customTitle: 'Parent session', sessionId: 'claude-parent' }, + ]); + + // Subagent transcripts repeat the parent sessionId; indexing them would + // overwrite the parent row's jsonl_path. + const subagentDir = path.join(path.dirname(parentPath), 'claude-parent', 'subagents'); + await mkdir(subagentDir, { recursive: true }); + await writeFile( + path.join(subagentDir, 'agent-1.jsonl'), + `${JSON.stringify({ type: 'user', sessionId: 'claude-parent', cwd: workspacePath })}\n`, + 'utf8' + ); + + await withIsolatedDatabase(async () => { + const processed = await new ClaudeSessionSynchronizer().synchronize(); + + assert.equal(processed, 1); + assert.equal(sessionsDb.getSessionById('claude-parent')?.jsonl_path, parentPath); + }); + }); +}); + +test('Renaming a Claude session appends a custom-title event to its transcript', { concurrency: false }, async () => { + await withClaudeHome('claude-session-rename-', async ({ homeDir, workspacePath }) => { + await writeHistory(homeDir, [{ sessionId: 'claude-1', display: 'first prompt text' }]); + const transcriptPath = await writeClaudeTranscript(homeDir, 'claude-1', workspacePath); + + await withIsolatedDatabase(async () => { + const synchronizer = new ClaudeSessionSynchronizer(); + await synchronizer.synchronize(); + + await sessionsService.renameSessionById('claude-1', 'Renamed in the UI'); + + const appended = JSON.parse((await readFile(transcriptPath, 'utf8')).trimEnd().split('\n').at(-1)!); + assert.deepEqual(appended, { + type: 'custom-title', + customTitle: 'Renamed in the UI', + sessionId: 'claude-1', + }); + + // A watcher re-sync racing the append must converge on the same name. + await synchronizer.synchronizeFile(transcriptPath); + assert.equal(sessionsDb.getSessionById('claude-1')?.custom_name, 'Renamed in the UI'); + }); + }); +}); + +test('Renaming a Claude session survives a missing transcript file', { concurrency: false }, async () => { + await withClaudeHome('claude-session-rename-missing-', async ({ homeDir, workspacePath }) => { + const transcriptPath = await writeClaudeTranscript(homeDir, 'claude-1', workspacePath); + + await withIsolatedDatabase(async () => { + await new ClaudeSessionSynchronizer().synchronize(); + await rm(transcriptPath); + + const result = await sessionsService.renameSessionById('claude-1', 'Renamed in the UI'); + + assert.deepEqual(result, { sessionId: 'claude-1', summary: 'Renamed in the UI' }); + assert.equal(sessionsDb.getSessionById('claude-1')?.custom_name, 'Renamed in the UI'); + }); + }); +}); + +test('Renaming an app-created Claude session uses the provider-native session id', { concurrency: false }, async () => { + await withClaudeHome('claude-session-rename-app-', async ({ homeDir, workspacePath }) => { + const transcriptPath = await writeClaudeTranscript(homeDir, 'claude-provider-1', workspacePath); + + await withIsolatedDatabase(async () => { + sessionsDb.createAppSession('app-1', 'claude', workspacePath); + sessionsDb.assignProviderSessionId('app-1', 'claude-provider-1'); + await new ClaudeSessionSynchronizer().synchronize(); + + await sessionsService.renameSessionById('app-1', 'Renamed in the UI'); + + const appended = JSON.parse((await readFile(transcriptPath, 'utf8')).trimEnd().split('\n').at(-1)!); + assert.equal(appended.sessionId, 'claude-provider-1'); + }); + }); +});