fix(sessions): preserve collaboration titles across worktree startup

This commit is contained in:
程序员阿江(Relakkes)
2026-09-22 08:05:24 +08:00
parent bc9451dbe6
commit 2b27167d68
7 changed files with 135 additions and 7 deletions
+28
View File
@@ -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 })
+15 -2
View File
@@ -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,
@@ -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
@@ -79,7 +79,20 @@ export async function getSessionCollaborationService(): Promise<SessionCollabora
},
read: (sessionId, options) => 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<string, string> = {}
// 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')
@@ -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)
@@ -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)) })
}
+15 -2
View File
@@ -4601,6 +4601,7 @@ export class SessionService {
sessionId: string,
fallbackWorkDir?: string,
preservedPermissionMode?: string,
preservedCustomTitle?: string | null,
): Promise<void> {
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()