diff --git a/docs/design/web-shell/assistant-response-session-branching.md b/docs/design/web-shell/assistant-response-session-branching.md index 9f7e58a4ad6..cb43dc0164a 100644 --- a/docs/design/web-shell/assistant-response-session-branching.md +++ b/docs/design/web-shell/assistant-response-session-branching.md @@ -700,6 +700,14 @@ target `.jsonl` must therefore be the last resource published. Before creating target resources, compute and sanitize the final title. The Core fork input includes that title, and Core appends its `custom_title` record inside the staged transcript. There is no post-publication rename transaction. +Use the source session's picker display name (`customTitle || prompt`) as the +base, remove an existing generated fork suffix, and append the lowest available +numeric suffix: `Title(1)`, `Title(2)`, and so on. Explicitly requested names +remain unchanged before suffix allocation. If a custom title normalizes to +nothing (it was exactly a legacy `(Branch)` or `(Branch N)` token), no picker +name survives: the daemon route falls back to a session-id prefix while CLI +`/branch` falls back to the first prompt. The divergence is deliberate; both +clients allocate the numeric suffix from their chosen base. ### 14.2 Temporary resources diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 191812d9843..fd0a606bc47 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -638,8 +638,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ readonly rpcCode = -32023; readonly errorKind = 'session_writer_unavailable'; }, - computeUniqueBranchTitle: vi.fn( - async (baseName: string) => `${baseName} (Branch)`, + computeUniqueBranchTitle: vi.fn(async (baseName: string) => `${baseName}(1)`), + // The real helper: the route tests below must exercise the shipped + // normalization, not a copy that can silently drift from it. + normalizeDerivedBranchTitle: vi.fn( + (await importOriginal()) + .normalizeDerivedBranchTitle, ), Storage: { getGlobalQwenDir: vi.fn(() => '/tmp/qwen-global-test'), @@ -16259,6 +16263,7 @@ describe('QwenAgent extMethod renameSession routing', () => { { cwd: '/workspace-other', sessionId: liveSessionId, + name: '创建 MR 描述生成 Skill', }, ); @@ -16271,12 +16276,12 @@ describe('QwenAgent extMethod renameSession routing', () => { expect(sessionService.forkSession).toHaveBeenCalledWith( liveSessionId, expect.any(String), - { title: 'Source session (Branch)' }, + { title: '创建 MR 描述生成 Skill(1)' }, ); expect(sessionService.renameSession).not.toHaveBeenCalled(); expect(result).toMatchObject({ - title: 'Source session (Branch)', - displayName: 'Source session (Branch)', + title: '创建 MR 描述生成 Skill(1)', + displayName: '创建 MR 描述生成 Skill(1)', }); mockConnectionState.resolve(); @@ -16284,22 +16289,112 @@ describe('QwenAgent extMethod renameSession routing', () => { }); it.each([ + { + sourceTitle: 'Source session(2)', + name: undefined, + atRecordId: undefined, + persistedDisplayName: undefined, + expectedTitle: 'Source session(1)', + }, + { + sourceTitle: 'Source session(2)', + name: 'Source session(2)', + atRecordId: 'checkpoint-1', + persistedDisplayName: undefined, + expectedTitle: 'Source session(2)(1)', + }, + { + sourceTitle: 'Recorder title', + name: 'Picker title(2)', + atRecordId: undefined, + persistedDisplayName: undefined, + expectedTitle: 'Picker title(2)(1)', + }, + { + sourceTitle: 'Recorder title', + name: 'Roadmap (2026)', + atRecordId: undefined, + persistedDisplayName: undefined, + expectedTitle: 'Roadmap (2026)(1)', + }, + { + sourceTitle: 'Source session (Branch)', + name: undefined, + atRecordId: undefined, + persistedDisplayName: undefined, + expectedTitle: 'Source session(1)', + }, { sourceTitle: 'Source session (Branch 2)', - expectedTitle: 'Source session (Branch)', + name: undefined, + atRecordId: undefined, + persistedDisplayName: undefined, + expectedTitle: 'Source session(1)', + }, + { + sourceTitle: '(Branch)', + name: undefined, + atRecordId: undefined, + persistedDisplayName: undefined, + expectedTitle: '550e8400(1)', + }, + { + sourceTitle: '(Branch 2)', + name: undefined, + atRecordId: undefined, + persistedDisplayName: undefined, + expectedTitle: '550e8400(1)', + }, + { + sourceTitle: undefined, + name: undefined, + atRecordId: undefined, + persistedDisplayName: 'Prompt session', + expectedTitle: 'Prompt session(1)', + }, + { + sourceTitle: undefined, + name: 'Sprint (2)', + atRecordId: 'checkpoint-2', + persistedDisplayName: 'Sprint (2)', + expectedTitle: 'Sprint (2)(1)', + }, + { + sourceTitle: undefined, + name: undefined, + atRecordId: undefined, + persistedDisplayName: undefined, + expectedTitle: '550e8400(1)', + }, + { + sourceTitle: '', + name: undefined, + atRecordId: undefined, + persistedDisplayName: undefined, + expectedTitle: '550e8400(1)', }, { sourceTitle: undefined, - expectedTitle: '550e8400 (Branch)', + name: undefined, + atRecordId: undefined, + persistedDisplayName: ' ', + expectedTitle: '550e8400(1)', }, ])( 'derives the branch title from $sourceTitle', - async ({ sourceTitle, expectedTitle }) => { + async ({ + sourceTitle, + name, + atRecordId, + persistedDisplayName, + expectedTitle, + }) => { const recording = makeRecordingService(); recording.getCurrentCustomTitle.mockReturnValue(sourceTitle); const sessionService = { forkSession: vi.fn().mockResolvedValue(undefined), findSessionTitlesByPrefix: vi.fn().mockResolvedValue([]), + getSessionDisplayName: vi.fn().mockResolvedValue(persistedDisplayName), renameSession: vi.fn().mockResolvedValue(true), removeSession: vi.fn().mockResolvedValue(undefined), }; @@ -16315,18 +16410,26 @@ describe('QwenAgent extMethod renameSession routing', () => { { cwd: '/tmp', sessionId: liveSessionId, + ...(name !== undefined ? { name } : {}), + ...(atRecordId !== undefined ? { atRecordId } : {}), }, ); expect(sessionService.forkSession).toHaveBeenCalledWith( liveSessionId, expect.any(String), - { title: expectedTitle }, + { + title: expectedTitle, + ...(atRecordId !== undefined ? { atRecordId } : {}), + }, ); expect(result).toMatchObject({ title: expectedTitle, displayName: expectedTitle, }); + expect(sessionService.getSessionDisplayName).toHaveBeenCalledTimes( + sourceTitle === undefined && name === undefined ? 1 : 0, + ); mockConnectionState.resolve(); await agentPromise; @@ -16352,7 +16455,7 @@ describe('QwenAgent extMethod renameSession routing', () => { { cwd: '/tmp', sessionId: liveSessionId, - name: 'Side task', + name: 'Side task(2)', }, ); @@ -16364,15 +16467,85 @@ describe('QwenAgent extMethod renameSession routing', () => { sourceType: 'side_task', sourceId: liveSessionId, }, - title: 'Side task', + title: 'Side task(2)', }, ); expect(recording.runWithWriteBarrier).toHaveBeenCalledOnce(); expect(sessionService.renameSession).not.toHaveBeenCalled(); expect(sessionService.removeSession).not.toHaveBeenCalled(); expect(result).toMatchObject({ - title: 'Side task', - displayName: 'Side task', + title: 'Side task(2)', + displayName: 'Side task(2)', + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('falls back to the session id for an empty normalized side-task title', async () => { + const recording = makeRecordingService(); + recording.getCurrentCustomTitle.mockReturnValue('(Branch)'); + const sessionService = { + forkSession: vi.fn().mockResolvedValue(undefined), + }; + const innerConfig = makeLiveSessionInnerConfig(recording); + innerConfig.getSessionService.mockReturnValue( + sessionService as unknown as SessionService, + ); + const { agent, agentPromise } = await bootAgent(innerConfig); + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const result = await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionSideTask, + { + cwd: '/tmp', + sessionId: liveSessionId, + }, + ); + + expect(sessionService.forkSession).toHaveBeenCalledWith( + liveSessionId, + expect.any(String), + expect.objectContaining({ title: '550e8400' }), + ); + expect(result).toMatchObject({ + title: '550e8400', + displayName: '550e8400', + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('normalizes the source custom title for a nameless side task', async () => { + const recording = makeRecordingService(); + recording.getCurrentCustomTitle.mockReturnValue('My Project(2)'); + const sessionService = { + forkSession: vi.fn().mockResolvedValue(undefined), + }; + const innerConfig = makeLiveSessionInnerConfig(recording); + innerConfig.getSessionService.mockReturnValue( + sessionService as unknown as SessionService, + ); + const { agent, agentPromise } = await bootAgent(innerConfig); + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const result = await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionSideTask, + { + cwd: '/tmp', + sessionId: liveSessionId, + }, + ); + + expect(sessionService.forkSession).toHaveBeenCalledWith( + liveSessionId, + expect.any(String), + expect.objectContaining({ title: 'My Project' }), + ); + expect(result).toMatchObject({ + title: 'My Project', + displayName: 'My Project', }); mockConnectionState.resolve(); @@ -16437,7 +16610,7 @@ describe('QwenAgent extMethod renameSession routing', () => { expect(sessionService.forkSession).toHaveBeenCalledWith( liveSessionId, expect.any(String), - { title: 'Source session (Branch)', atRecordId: checkpoint }, + { title: 'Source session(1)', atRecordId: checkpoint }, ); expect(liveBeginHistoryMutation).toHaveBeenCalledOnce(); expect(liveReleaseHistoryMutation).toHaveBeenCalledOnce(); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index c8aad29652c..bf4587fca24 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -78,6 +78,7 @@ import { subagentGenerator, redactUrlCredentials, computeUniqueBranchTitle, + normalizeDerivedBranchTitle, BranchPointInvalidError, parseGoalSnapshotV2, parseGoalStateCause, @@ -1141,19 +1142,10 @@ function getLoadReplayPageSize(params: LoadSessionRequest): number | undefined { return value as number; } -function deriveForkBaseName( - name: unknown, - recording: { getCurrentCustomTitle(): string | undefined } | undefined, - sessionId: string, -): string { - if (typeof name === 'string' && name.trim().length > 0) { - return name.trim(); - } - const existingTitle = recording?.getCurrentCustomTitle(); - const stripped = existingTitle - ?.replace(/\s*\(Branch(?:\s+\d+)?\)\s*$/, '') - .trim(); - return stripped && stripped.length > 0 ? stripped : sessionId.slice(0, 8); +function normalizeRequestedBranchName(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.trim(); + return normalized || undefined; } function createHiddenWorkspaceMemoryConfig(config: Config): Config { return new Proxy(config, { @@ -11078,11 +11070,31 @@ class QwenAgent implements Agent { const recording = sourceConfig.getChatRecordingService(); const sessionService = sourceConfig.getSessionService(); - const baseName = deriveForkBaseName( - name, - recording, - sessionId, - ); + const requestedName = normalizeRequestedBranchName(name); + const sourceCustomTitle = + requestedName === undefined + ? recording?.getCurrentCustomTitle() + : undefined; + const persistedDisplayName = + requestedName === undefined && + sourceCustomTitle === undefined + ? await sessionService.getSessionDisplayName(sessionId) + : undefined; + const sourceDisplayName = + sourceCustomTitle ?? persistedDisplayName; + const derivedBaseName = sourceCustomTitle + ? normalizeDerivedBranchTitle(sourceCustomTitle) + : sourceDisplayName; + // A base that is empty, whitespace-only, or exactly a + // legacy `(Branch)`/`(Branch N)` token falls back to the + // session-id prefix here, while CLI /branch falls back to + // the first prompt. Deliberate: no picker name survives to + // anchor the family to, and one shared fallback would need + // a prompt-only display-name read on this route. + const baseName = + requestedName ?? + (derivedBaseName?.trim() || undefined) ?? + sessionId.slice(0, 8); const title = await computeUniqueBranchTitle( baseName, @@ -11120,7 +11132,15 @@ class QwenAgent implements Agent { const recording = sourceConfig.getChatRecordingService(); if (recording) await recording.flush(); const sessionService = sourceConfig.getSessionService(); - const title = deriveForkBaseName(name, recording, sessionId); + const requestedName = normalizeRequestedBranchName(name); + let title = requestedName; + if (title === undefined) { + const sourceCustomTitle = recording?.getCurrentCustomTitle(); + title = sourceCustomTitle + ? (normalizeDerivedBranchTitle(sourceCustomTitle) ?? + sessionId.slice(0, 8)) + : sessionId.slice(0, 8); + } const newSessionId = randomUUID(); const fork = () => sessionService.forkSession(sessionId, newSessionId, { diff --git a/packages/cli/src/ui/hooks/useBranchCommand.test.ts b/packages/cli/src/ui/hooks/useBranchCommand.test.ts index e742b0da524..016868c97d1 100644 --- a/packages/cli/src/ui/hooks/useBranchCommand.test.ts +++ b/packages/cli/src/ui/hooks/useBranchCommand.test.ts @@ -20,6 +20,8 @@ describe('useBranchCommand', () => { let renameSession: ReturnType; let finalize: ReturnType; let flush: ReturnType; + let getCurrentCustomTitle: ReturnType; + let getSessionDisplayName: ReturnType; let startNewSessionConfig: ReturnType; let getGoalRuntimeReady: ReturnType; let startNewSessionUI: ReturnType; @@ -94,6 +96,8 @@ describe('useBranchCommand', () => { }); finalize = vi.fn(); flush = vi.fn().mockResolvedValue(undefined); + getCurrentCustomTitle = vi.fn().mockReturnValue(undefined); + getSessionDisplayName = vi.fn().mockResolvedValue(undefined); findSessionTitlesByPrefix = vi.fn().mockResolvedValue([]); startNewSessionConfig = vi.fn(); getGoalRuntimeReady = vi.fn().mockResolvedValue({}); @@ -132,8 +136,13 @@ describe('useBranchCommand', () => { removeSession, renameSession, findSessionTitlesByPrefix, + getSessionDisplayName, + }), + getChatRecordingService: () => ({ + finalize, + flush, + getCurrentCustomTitle, }), - getChatRecordingService: () => ({ finalize, flush }), getGeminiClient: () => ({ initialize: vi.fn() }), getBackgroundTaskRegistry: () => backgroundTaskRegistry, getMonitorRegistry: () => monitorRegistry, @@ -309,24 +318,24 @@ describe('useBranchCommand', () => { ); }); - it('records the user-provided name with a (Branch) suffix', async () => { + it('records the user-provided name with a numeric suffix', async () => { const { result } = renderHook(() => useBranchCommand(makeOptions())); await act(async () => { await result.current.handleBranch('my-branch'); }); expect(renameSession).toHaveBeenCalledWith( expect.any(String), - 'my-branch (Branch)', + 'my-branch(1)', 'manual', ); - expect(setSessionName).toHaveBeenCalledWith('my-branch (Branch)'); + expect(setSessionName).toHaveBeenCalledWith('my-branch(1)'); }); - it('bumps to (Branch N) when the default suffix is already taken', async () => { + it('increments the suffix when the default name is already taken', async () => { // `findSessionTitlesByPrefix` returns every existing title under the - // `${name} (Branch` prefix in one shot, so the bump logic picks the + // `${name}(` prefix in one shot, so the bump logic picks the // first free slot in memory — no per-candidate disk probe. - findSessionTitlesByPrefix.mockResolvedValue(['my-branch (Branch)']); + findSessionTitlesByPrefix.mockResolvedValue(['my-branch(1)']); const { result } = renderHook(() => useBranchCommand(makeOptions())); await act(async () => { @@ -334,22 +343,22 @@ describe('useBranchCommand', () => { }); expect(renameSession).toHaveBeenCalledWith( expect.any(String), - 'my-branch (Branch 2)', + 'my-branch(2)', 'manual', ); - expect(setSessionName).toHaveBeenCalledWith('my-branch (Branch 2)'); + expect(setSessionName).toHaveBeenCalledWith('my-branch(2)'); }); - it('does ONE prefix scan even when many (Branch N) slots are taken', async () => { + it('does ONE prefix scan even when many numeric slots are taken', async () => { // Pin the perf invariant: regardless of collision density, the // collision lookup must be a single project-wide scan, not N probes. // Reviewer's concern was that 99 sequential probes can stall /branch // on dense title spaces. findSessionTitlesByPrefix.mockResolvedValue([ - 'my-branch (Branch)', - 'my-branch (Branch 2)', - 'my-branch (Branch 3)', - 'my-branch (Branch 4)', + 'my-branch(1)', + 'my-branch(2)', + 'my-branch(3)', + 'my-branch(4)', ]); const { result } = renderHook(() => useBranchCommand(makeOptions())); @@ -358,10 +367,10 @@ describe('useBranchCommand', () => { }); expect(findSessionTitlesByPrefix).toHaveBeenCalledTimes(1); - expect(findSessionTitlesByPrefix).toHaveBeenCalledWith('my-branch (Branch'); + expect(findSessionTitlesByPrefix).toHaveBeenCalledWith('my-branch('); expect(renameSession).toHaveBeenCalledWith( expect.any(String), - 'my-branch (Branch 5)', + 'my-branch(5)', 'manual', ); }); @@ -372,15 +381,105 @@ describe('useBranchCommand', () => { await result.current.handleBranch(); }); // deriveFirstPrompt collapses whitespace and truncates to 100 chars; - // "help me fix the login bug" fits, then + " (Branch)" + // "help me fix the login bug" fits, then + "(1)" + expect(renameSession).toHaveBeenCalledWith( + expect.any(String), + 'help me fix the login bug(1)', + 'auto', + ); + }); + + it('prefers the source custom title when no name is given', async () => { + getCurrentCustomTitle.mockReturnValue('My Project'); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch(); + }); + + expect(renameSession).toHaveBeenCalledWith( + expect.any(String), + 'My Project(1)', + 'auto', + ); + }); + + it.each([ + ['My Project(2)', 'My Project(1)'], + ['My Project (Branch)', 'My Project(1)'], + ['My Project (Branch 2)', 'My Project(1)'], + ['My Project (2)', 'My Project (2)(1)'], + ['(Branch)', 'help me fix the login bug(1)'], + ['(Branch 2)', 'help me fix the login bug(1)'], + ])( + 'normalizes the derived source title %s', + async (sourceTitle, expectedTitle) => { + getCurrentCustomTitle.mockReturnValue(sourceTitle); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch(); + }); + + expect(renameSession).toHaveBeenCalledWith( + expect.any(String), + expectedTitle, + 'auto', + ); + }, + ); + + it('uses the picker display name for an untitled source session', async () => { + const pickerDisplayName = `Error: first line\n${'x'.repeat(120)}`; + getSessionDisplayName.mockResolvedValue(pickerDisplayName); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch(); + }); + + expect(getSessionDisplayName).toHaveBeenCalledWith( + '12345678-aaaa-bbbb-cccc-dddddddddddd', + ); expect(renameSession).toHaveBeenCalledWith( expect.any(String), - 'help me fix the login bug (Branch)', + `${pickerDisplayName}(1)`, 'auto', ); }); - it('falls back to "Branched conversation (Branch)" when the transcript has no user records', async () => { + it.each(['', ' '])( + 'falls back to the first prompt when the picker display name is blank (%j)', + async (blankDisplayName) => { + getSessionDisplayName.mockResolvedValue(blankDisplayName); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch(); + }); + + expect(renameSession).toHaveBeenCalledWith( + expect.any(String), + 'help me fix the login bug(1)', + 'auto', + ); + }, + ); + + it('preserves a numeric token in an explicit branch name', async () => { + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch('Roadmap (2026)'); + }); + + expect(renameSession).toHaveBeenCalledWith( + expect.any(String), + 'Roadmap (2026)(1)', + 'manual', + ); + }); + + it('falls back to "Branched conversation(1)" when the transcript has no user records', async () => { loadSession.mockResolvedValue({ conversation: { messages: [] }, filePath: '/tmp/new.jsonl', @@ -392,7 +491,7 @@ describe('useBranchCommand', () => { }); expect(renameSession).toHaveBeenCalledWith( expect.any(String), - 'Branched conversation (Branch)', + 'Branched conversation(1)', 'auto', ); }); @@ -416,7 +515,7 @@ describe('useBranchCommand', () => { }); expect(renameSession).toHaveBeenCalledWith( expect.any(String), - 'what does this codebase do (Branch)', + 'what does this codebase do(1)', 'auto', ); }); diff --git a/packages/cli/src/ui/hooks/useBranchCommand.ts b/packages/cli/src/ui/hooks/useBranchCommand.ts index 4a56c32e457..7befe585083 100644 --- a/packages/cli/src/ui/hooks/useBranchCommand.ts +++ b/packages/cli/src/ui/hooks/useBranchCommand.ts @@ -12,6 +12,7 @@ import { type ResumedSessionData, SessionStartSource, computeUniqueBranchTitle, + normalizeDerivedBranchTitle, } from '@qwen-code/qwen-code-core'; import { buildResumedHistoryItems, @@ -140,8 +141,13 @@ export function useBranchCommand( // a stale `lastCompletedUuid` and the next user message attaches // its parentUuid to a record that's no longer the JSONL tail. const outgoingRecording = config.getChatRecordingService(); + const sourceCustomTitle = outgoingRecording?.getCurrentCustomTitle(); outgoingRecording?.finalize(); await outgoingRecording?.flush(); + const sourceDisplayName = + name === undefined && sourceCustomTitle === undefined + ? await sessionService.getSessionDisplayName(oldSessionId) + : undefined; // 2. Snapshot the parent JSONL state for rollback. `/branch` is // guarded on `isIdleRef`, so the file isn't being mutated @@ -167,8 +173,17 @@ export function useBranchCommand( // 5. Persist the branch title before switching core or UI. A failed // title write leaves the parent active and the catch path removes // the incomplete fork. + // A base that is empty, whitespace-only, or exactly a legacy + // `(Branch)`/`(Branch N)` token falls back to the first prompt here, + // while the daemon route falls back to the session-id prefix; no + // picker name survives either way, so each client keeps its own + // degradation. const baseName = - name ?? deriveFirstPrompt(provisional.conversation.messages); + name ?? + (sourceCustomTitle + ? normalizeDerivedBranchTitle(sourceCustomTitle) + : sourceDisplayName?.trim() || undefined) ?? + deriveFirstPrompt(provisional.conversation.messages); const effectiveTitle = await computeUniqueBranchTitle( baseName, sessionService, @@ -233,7 +248,7 @@ export function useBranchCommand( // 11. Announce. Two history items mirror Claude's success message // (branched line + resume hint). The quoted name is the raw - // user-provided `name`; no `(Branch)` suffix — that decoration + // user-provided `name`; no generated numeric suffix — that decoration // belongs in the picker/prompt bar, not in the user-facing // announcement. const titleInfo = name ? ` "${name}"` : ''; diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 9cdde468023..9c90d1a9586 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -22,6 +22,8 @@ import { readRuntimeStatus } from '../utils/runtimeStatus.js'; import { SessionService, buildApiHistoryFromConversation, + computeUniqueBranchTitle, + normalizeDerivedBranchTitle, getResumePromptTokenCount, getResumeTokenCounts, type ConversationRecord, @@ -5953,23 +5955,25 @@ describe('SessionService', () => { it('returns titles whose custom_title starts with the prefix (case-insensitive)', async () => { seedSessionWithTitle( '11111111-1111-1111-1111-111111111111', - 'my-branch (Branch)', + 'my-branch(1)', ); seedSessionWithTitle( '22222222-2222-2222-2222-222222222222', - 'My-Branch (Branch 2)', + 'My-Branch(2)', ); seedSessionWithTitle( '33333333-3333-3333-3333-333333333333', 'unrelated session', ); - const titles = - await service.findSessionTitlesByPrefix('my-branch (Branch'); + const titles = await service.findSessionTitlesByPrefix('my-branch('); expect(new Set(titles)).toEqual( - new Set(['my-branch (Branch)', 'My-Branch (Branch 2)']), + new Set(['my-branch(1)', 'My-Branch(2)']), ); + await expect( + service.getSessionDisplayName('11111111-1111-1111-1111-111111111111'), + ).resolves.toBe('my-branch(1)'); }); it('returns empty when chats directory does not exist', async () => { @@ -5980,22 +5984,48 @@ describe('SessionService', () => { it('skips sessions from other projects (collisions are project-scoped)', async () => { seedSessionWithTitle( '11111111-1111-1111-1111-111111111111', - 'shared (Branch)', + 'shared(1)', cwd, ); // Same chats dir (sessions are stored under projectHash anyway), but // the record's cwd belongs to another project → must be skipped. seedSessionWithTitle( '22222222-2222-2222-2222-222222222222', - 'shared (Branch 2)', + 'shared(2)', '/some/other/project', ); - const titles = await service.findSessionTitlesByPrefix('shared (Branch'); - expect(titles).toEqual(['shared (Branch)']); + const titles = await service.findSessionTitlesByPrefix('shared('); + expect(titles).toEqual(['shared(1)']); + await expect( + service.getSessionDisplayName('22222222-2222-2222-2222-222222222222'), + ).resolves.toBeUndefined(); }); - it('skips files without a custom_title record', async () => { + it('returns undefined for an empty session file', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const chatsDir = realPath.join( + service['storage'].getProjectDir(), + 'chats', + ); + fs.mkdirSync(chatsDir, { recursive: true }); + fs.writeFileSync(realPath.join(chatsDir, `${sessionId}.jsonl`), ''); + + await expect(service.getSessionDisplayName(sessionId)).resolves.toBe( + undefined, + ); + }); + + it('returns undefined for missing sessions and invalid ids', async () => { + await expect( + service.getSessionDisplayName('44444444-4444-4444-8444-444444444444'), + ).resolves.toBeUndefined(); + await expect( + service.getSessionDisplayName('not-a-session'), + ).resolves.toBeUndefined(); + }); + + it('uses the picker prompt when a session has no custom title', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const chatsDir = realPath.join( service['storage'].getProjectDir(), @@ -6013,12 +6043,56 @@ describe('SessionService', () => { timestamp: '2026-04-22T00:00:00.000Z', cwd, version: 'test', - message: { role: 'user', parts: [{ text: 'hi' }] }, + message: { + role: 'user', + parts: [{ text: '创建 MR 描述生成 Skill(1)' }], + }, }) + '\n', ); - const titles = await service.findSessionTitlesByPrefix('anything'); - expect(titles).toEqual([]); + const titles = + await service.findSessionTitlesByPrefix('创建 MR 描述生成 Skill('); + expect(titles).toEqual(['创建 MR 描述生成 Skill(1)']); + expect( + vi + .mocked(jsonl.readLines) + .mock.calls.filter(([filePath]) => filePath === file), + ).toEqual([[file, 10]]); + await expect(service.getSessionDisplayName(sessionId)).resolves.toBe( + '创建 MR 描述生成 Skill(1)', + ); + }); + }); + + describe('computeUniqueBranchTitle', () => { + it('uses the first available numeric suffix', async () => { + const service = { + findSessionTitlesByPrefix: vi + .fn() + .mockResolvedValue([ + '创建 MR 描述生成 Skill(1)', + '创建 MR 描述生成 Skill(2)', + '创建 MR 描述生成 Skill(4)', + ]), + } as unknown as SessionService; + + await expect( + computeUniqueBranchTitle('创建 MR 描述生成 Skill', service), + ).resolves.toBe('创建 MR 描述生成 Skill(3)'); + expect(service.findSessionTitlesByPrefix).toHaveBeenCalledWith( + '创建 MR 描述生成 Skill(', + ); + }); + + it.each([ + ['Source session (Branch)', 'Source session'], + ['Source session (Branch 2)', 'Source session'], + ['Source session(2)', 'Source session'], + ['Sprint (2)', 'Sprint (2)'], + ['(Branch)', undefined], + ['(Branch 2)', undefined], + ])('normalizes derived branch title %s', (title, expected) => { + expect(normalizeDerivedBranchTitle(title)).toBe(expected); }); }); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index a8e12f8ef66..a8ea916da14 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -2608,6 +2608,40 @@ export class SessionService { return this.readSessionTitleFromFile(filePath); } + private async readSessionDisplayNameFromFile( + filePath: string, + titleInfo = this.readSessionTitleInfoFromFile(filePath), + ): Promise { + const records = await jsonl.readLines( + filePath, + titleInfo.title ? 1 : MAX_PROMPT_SCAN_LINES, + ); + if (records.length === 0) return undefined; + if ( + !(await this.sessionBelongsToCurrentProject( + records[0].sessionId, + records[0].cwd, + )) + ) { + return undefined; + } + return ( + titleInfo.title || + this.extractFirstPromptFromRecords(records) || + undefined + ); + } + + async getSessionDisplayName(sessionId: string): Promise { + if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) return undefined; + const filePath = path.join(this.getChatsDir(), `${sessionId}.jsonl`); + try { + return await this.readSessionDisplayNameFromFile(filePath); + } catch { + return undefined; + } + } + /** * Finds sessions by custom title. * Returns all matching sessions ordered by most recent first. @@ -2706,15 +2740,14 @@ export class SessionService { } /** - * Returns the customTitles in this project that start with `prefix` + * Returns the picker display names in this project that start with `prefix` * (case-insensitive). Single project-wide scan — meant to replace * repeated `findSessionsByTitle()` probes when the caller needs to - * pick the first free `(Branch N)` slot in memory. + * pick the first free numeric suffix in memory. * - * Skips the heavy hydration steps (message count, prompt extraction) - * that `findSessionsByTitle` does — collision lookup only needs the - * title and a project filter, so we read the first record only when - * the title actually matches the prefix. + * Matches the session picker by preferring `customTitle` and falling back + * to the first prompt. Each untitled candidate performs one head read + * bounded by `MAX_PROMPT_SCAN_LINES`; other metadata stays unhydrated. * * @param prefix Case-insensitive title prefix to match. */ @@ -2741,26 +2774,26 @@ export class SessionService { const filePath = path.join(chatsDir, name); const titleInfo = this.readSessionTitleInfoFromFile(filePath); - if (!titleInfo.title) continue; - if (!titleInfo.title.toLowerCase().trim().startsWith(normalizedPrefix)) { + if ( + titleInfo.title && + !titleInfo.title.toLowerCase().trim().startsWith(normalizedPrefix) + ) { continue; } try { - const records = await jsonl.readLines(filePath, 1); - if (records.length === 0) continue; - if ( - !(await this.sessionBelongsToCurrentProject( - records[0].sessionId, - records[0].cwd, - )) - ) { + const displayName = await this.readSessionDisplayNameFromFile( + filePath, + titleInfo, + ); + if (!displayName) continue; + if (!displayName.toLowerCase().trim().startsWith(normalizedPrefix)) { continue; } + titles.push(displayName); } catch { continue; } - titles.push(titleInfo.title); } return titles; @@ -3022,26 +3055,32 @@ export function replayUiTelemetryFromConversation( return resumeTokenCounts; } -const MAX_BRANCH_COLLISION_SCAN = 99; - export async function computeUniqueBranchTitle( baseName: string, sessionService: SessionService, ): Promise { - const maxSuffixLen = ' (Branch 1234567890123)'.length; + const maxSuffixLen = '(1234567890123)'.length; const trimmed = baseName .trim() .slice(0, SESSION_TITLE_MAX_LENGTH - maxSuffixLen); const taken = new Set( - (await sessionService.findSessionTitlesByPrefix(`${trimmed} (Branch`)).map( - (t) => t.toLowerCase().trim(), + (await sessionService.findSessionTitlesByPrefix(`${trimmed}(`)).map((t) => + t.toLowerCase().trim(), ), ); - const first = `${trimmed} (Branch)`; - if (!taken.has(first.toLowerCase())) return first; - for (let n = 2; n <= MAX_BRANCH_COLLISION_SCAN; n++) { - const candidate = `${trimmed} (Branch ${n})`; + for (let n = 1; ; n++) { + const candidate = `${trimmed}(${n})`; if (!taken.has(candidate.toLowerCase())) return candidate; } - return `${trimmed} (Branch ${Date.now()})`; +} + +export function normalizeDerivedBranchTitle( + baseName: string, +): string | undefined { + const normalized = baseName + .trim() + .replace(/\s*\(Branch(?:\s+\d+)?\)$/, '') + .replace(/(\S)\(\d+\)$/, '$1') + .trim(); + return normalized || undefined; }