diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 2f85af77..f18ebdf4 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -2578,6 +2578,34 @@ describe('SessionService', () => { }) }) + it('preserves a collaboration title only when startup replaces its empty placeholder', async () => { + const workDir = path.join(tmpDir, 'startup-title-placeholder') + await fs.mkdir(workDir, { recursive: true }) + const { sessionId } = await service.createSession(workDir) + await service.appendSessionMetadata(sessionId, { + workDir, + customTitle: 'Review the auth boundary', + }) + + await service.clearSessionTranscript( + sessionId, + workDir, + undefined, + 'Review the auth boundary', + ) + + expect((await service.getSessionLaunchInfo(sessionId))?.customTitle) + .toBe('Review the auth boundary') + expect((await service.listSessions()).sessions.find(session => session.id === sessionId)?.title) + .toBe('Review the auth boundary') + + await service.clearSessionTranscript(sessionId, workDir) + + expect((await service.getSessionLaunchInfo(sessionId))?.customTitle).toBeNull() + expect((await service.listSessions()).sessions.find(session => session.id === sessionId)?.title) + .toBe('Untitled Session') + }) + it('should preserve permission metadata when clearing placeholder transcripts', async () => { const workDir = path.join(tmpDir, 'clear-permission-workdir') await fs.mkdir(workDir, { recursive: true }) diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 6338cd28..7f90de1c 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -374,7 +374,16 @@ export class ConversationService { } if (shouldReplacePlaceholder) { - await sessionService.clearSessionTranscript(sessionId, workDir) + // A collaboration session is named before its first turn starts. Replacing + // that empty launch placeholder must not erase the only title copy before + // the CLI creates the real worktree transcript. User-initiated /clear does + // not pass a title and therefore keeps its existing reset semantics. + await sessionService.clearSessionTranscript( + sessionId, + workDir, + undefined, + launchInfo.customTitle, + ) } let launchWorkDir = workDir @@ -565,8 +574,12 @@ export class ConversationService { !!options?.model || !!options?.effort if (shouldReplacePlaceholder || !launchInfo || shouldPersistRuntimeMetadata) { + // system/init can move a newly-created session into its worktree while + // startup is still awaiting the SDK. Once that authoritative cwd is + // known, never recreate a late metadata placeholder in launchWorkDir. + const metadataWorkDir = this.getSessionWorkDir(sessionId) || launchWorkDir await sessionService.appendSessionMetadata(sessionId, { - workDir: launchWorkDir, + workDir: metadataWorkDir, customTitle: launchInfo?.customTitle ?? null, repository: launchRepository, permissionMode: options?.permissionMode || launchInfo?.permissionMode, diff --git a/src/server/services/sessionCollaborationHost.test.ts b/src/server/services/sessionCollaborationHost.test.ts index 2ca1e748..27f6d2c8 100644 --- a/src/server/services/sessionCollaborationHost.test.ts +++ b/src/server/services/sessionCollaborationHost.test.ts @@ -143,6 +143,37 @@ test('a full metadata page skips transcript search and returns immediately from } }) +test('duplicate transcript rows cannot replace a useful collaboration title with Untitled', async () => { + const previous = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = directory + const dispose = configureSessionCollaborationHost('127.0.0.1', 1234) + const row = (title: string, modifiedAt: string) => ({ + id: 'worker', title, workDir: '/fixture', projectPath: 'project', modifiedAt, + }) + const indexed = spyOn(sessionService, 'getSessionSuggestionMetadata') + .mockReturnValue([ + row('Untitled Session', 'newer'), + row('Review the auth boundary', 'older'), + ]) + try { + const service = await getSessionCollaborationService() + await service.onUserInput('worker') + expect((await service.status(['worker'])).members[0]?.title) + .toBe('Review the auth boundary') + + indexed.mockReturnValue([ + row('Newest useful title', 'newer'), + row('Older useful title', 'older'), + ]) + expect((await service.status(['worker'])).members[0]?.title) + .toBe('Newest useful title') + } finally { + dispose(); mock.restore() + if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previous + } +}) + test('content suggestions fill metadata pages without duplicate metadata hits or canonical history reads', async () => { const previous = process.env.CLAUDE_CONFIG_DIR process.env.CLAUDE_CONFIG_DIR = directory diff --git a/src/server/services/sessionCollaborationHost.ts b/src/server/services/sessionCollaborationHost.ts index b4eb1d2e..1c8e1e45 100644 --- a/src/server/services/sessionCollaborationHost.ts +++ b/src/server/services/sessionCollaborationHost.ts @@ -79,7 +79,20 @@ export async function getSessionCollaborationService(): Promise sessionService.getSessionHistoryPage(sessionId, { ...options, projectContext: false }), exists: async sessionId => Boolean(await sessionService.getSessionSummary(sessionId)), - titles: sessionIds => Object.fromEntries(sessionService.getSessionSuggestionMetadata(sessionIds).map(item => [item.id, item.title])), + titles: sessionIds => { + const titles: Record = {} + // The index can briefly contain both the source placeholder and the + // worktree transcript. Rows are newest-first; keep the first useful + // title instead of letting an older duplicate or Untitled overwrite it. + for (const item of sessionService.getSessionSuggestionMetadata(sessionIds)) { + const title = item.title.trim() + if (!title) continue + if (!(item.id in titles) || titles[item.id] === 'Untitled Session') { + titles[item.id] = title + } + } + return titles + }, async create(callerSessionId, input) { const workDir = input.workDir ?? await sessionService.getSessionWorkDir(callerSessionId) if (!workDir) throw new ApiError(409, 'The source session working directory is unavailable', 'SESSION_WORKSPACE_UNAVAILABLE') diff --git a/src/server/services/sessionCollaborationService.test.ts b/src/server/services/sessionCollaborationService.test.ts index 04a03565..d2dcefc8 100644 --- a/src/server/services/sessionCollaborationService.test.ts +++ b/src/server/services/sessionCollaborationService.test.ts @@ -119,6 +119,29 @@ describe('session collaboration', () => { expect(nextId).toBe(1) }) + test('keeps the creation title while the transcript index is missing or temporarily untitled', async () => { + let indexedTitle: string | undefined = 'Untitled Session' + deps.sessions.titles = sessionIds => indexedTitle + ? Object.fromEntries(sessionIds.map(sessionId => [sessionId, indexedTitle])) + : {} + + const created = await service.create('root', { + prompt: 'Review the authentication boundary', + title: 'Auth boundary review', + }) + expect((await service.status([created.sessionId])).members[0]?.title) + .toBe('Auth boundary review') + + indexedTitle = undefined + service = new SessionCollaborationService(deps) + expect((await service.status([created.sessionId])).members[0]?.title) + .toBe('Auth boundary review') + + indexedTitle = 'Indexed auth review' + expect((await service.status([created.sessionId])).members[0]?.title) + .toBe('Indexed auth review') + }) + test('migrates a versionless fixture while preserving unknown metadata', async () => { await writeFile(deps.statePath, JSON.stringify({ members: {}, messages: [], futureField: { preserve: true } })) service = new SessionCollaborationService(deps) diff --git a/src/server/services/sessionCollaborationService.ts b/src/server/services/sessionCollaborationService.ts index 101556fd..809d1557 100644 --- a/src/server/services/sessionCollaborationService.ts +++ b/src/server/services/sessionCollaborationService.ts @@ -121,7 +121,8 @@ export function migrateCollaborationStore(value: unknown): Store { } for (const [id, member] of Object.entries(source.members)) { if (!member || typeof member !== 'object' || member.sessionId !== id || typeof member.rootSessionId !== 'string' || - !['queued', 'running', 'idle', 'blocked', 'completed', 'failed', 'stopped'].includes(member.state)) throw new Error('Invalid collaboration member') + !['queued', 'running', 'idle', 'blocked', 'completed', 'failed', 'stopped'].includes(member.state) || + (member.title !== undefined && typeof member.title !== 'string')) throw new Error('Invalid collaboration member') } for (const message of source.messages) { if (!message || typeof message.id !== 'string' || typeof message.sourceSessionId !== 'string' || @@ -331,6 +332,7 @@ export class SessionCollaborationService { const message = await this.mutate(() => { const parent = this.ensureMember(callerSessionId) const member = this.ensureMember(created.sessionId, parent.rootSessionId, callerSessionId) + member.title = collaborationSessionTitle(input) // Stop may arrive while workspace/session creation awaits. Keep the // independently created session visible, but do not launch stale work. member.stopped = parent.stopped @@ -371,7 +373,12 @@ export class SessionCollaborationService { ? await Promise.resolve(this.deps.sessions.titles(members.map(member => member.sessionId))).catch(() => undefined) : undefined return structuredClone({ revision: this.store.revision, - members: members.map(member => titles?.[member.sessionId] ? { ...member, title: titles[member.sessionId] } : member), + members: members.map(member => { + const indexedTitle = titles?.[member.sessionId]?.trim() + return indexedTitle && indexedTitle !== 'Untitled Session' + ? { ...member, title: indexedTitle } + : member + }), messages: this.store.messages.filter(message => !ids || ids.has(message.targetSessionId) || ids.has(message.sourceSessionId)) }) } diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index cd2ac94f..f3c59fd2 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -4601,6 +4601,7 @@ export class SessionService { sessionId: string, fallbackWorkDir?: string, preservedPermissionMode?: string, + preservedCustomTitle?: string | null, ): Promise { const persist = this.shouldPersistSession() const nextEpoch = (this.taskNotificationMutationEpochs.get(sessionId) ?? 0) + 1 @@ -4622,7 +4623,9 @@ export class SessionService { } : null) if (info) { this.memoryLaunchInfo.set(this.memorySessionKey(sessionId), { - ...info, transcriptMessageCount: 0, customTitle: null, + ...info, + transcriptMessageCount: 0, + customTitle: preservedCustomTitle?.trim() || null, ...(preservedPermissionMode && VALID_SESSION_PERMISSION_MODES.has(preservedPermissionMode) ? { permissionMode: preservedPermissionMode } : {}), }) @@ -4693,11 +4696,21 @@ export class SessionService { timestamp: now, } + const customTitleEntry = preservedCustomTitle?.trim() + ? { + type: 'custom-title', + customTitle: preservedCustomTitle.trim(), + timestamp: now, + } + : null + if (!this.shouldPersistSession()) return this.memoryLaunchInfo.delete(this.memorySessionKey(sessionId)) await fs.writeFile( found.filePath, - `${JSON.stringify(initialEntry)}\n${JSON.stringify(metaEntry)}\n`, + [initialEntry, metaEntry, ...(customTitleEntry ? [customTitleEntry] : [])] + .map(entry => JSON.stringify(entry)) + .join('\n') + '\n', 'utf-8', ) this.invalidateSessionListCache()