mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix(sessions): preserve collaboration titles across worktree startup
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -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)) })
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user