diff --git a/src/main/ai-vault/parse-cache/session-scanner-parse-cache-agents.test.ts b/src/main/ai-vault/parse-cache/session-scanner-parse-cache-agents.test.ts index 45a5c669ba9..4da9eb988bd 100644 --- a/src/main/ai-vault/parse-cache/session-scanner-parse-cache-agents.test.ts +++ b/src/main/ai-vault/parse-cache/session-scanner-parse-cache-agents.test.ts @@ -61,6 +61,7 @@ describe('codex-specific resume behavior', () => { process.platform ) expect(seeded?.title).toBe('codex seed question') + expect(seeded?.titleKind).toBe('fallback') // Index titles arrive independently of transcript writes. await writeFile( @@ -75,6 +76,7 @@ describe('codex-specific resume behavior', () => { ) expect(stats.reused).toBe(1) expect(renamed?.title).toBe('Indexed thread title') + expect(renamed?.titleKind).toBe('named') expect(renamed).toEqual( await parseAgentSessionFile(await candidateFor('codex', path, codexHome), process.platform) ) diff --git a/src/main/ai-vault/scan-service/session-scanner-service-entry.ts b/src/main/ai-vault/scan-service/session-scanner-service-entry.ts index 41fb17338d0..30f09c6093b 100644 --- a/src/main/ai-vault/scan-service/session-scanner-service-entry.ts +++ b/src/main/ai-vault/scan-service/session-scanner-service-entry.ts @@ -1,7 +1,6 @@ import { requestSessionSearchRoots } from './session-scanner-service-root-request' import { aiVaultScanWork, trackAiVaultScanProgress } from '../ai-vault-scan-progress' import type { AiVaultScanWork } from '../../../shared/ai-vault/scanning/ai-vault-scan-progress' -import type { AiVaultSessionTitle } from '../../../shared/ai-vault/contracts/ai-vault-session-title' import type { AiVaultListResult } from '../../../shared/ai-vault/contracts/ai-vault-types' import { readAiVaultFirstUserPrompt } from '../session-first-user-prompt-read' import { @@ -21,7 +20,10 @@ import { type AiVaultServiceRequest, type AiVaultServiceResultValue } from './session-scanner-service-protocol' -import { readAiVaultSessionTitlesFromFiles } from '../session-titles/session-title-file-reader' +import { + readAiVaultSessionTitlesFromFiles, + type CachedAiVaultSessionTitle +} from '../session-titles/session-title-file-reader' import { resolveHostReadableAiVaultTitleRequests } from '../session-titles/session-title-request-paths' import { listLocalAiVaultSubagentSessions } from '../session-subagent-reader' import { @@ -36,7 +38,7 @@ if (!process.send) { const controllers = new Map() const cancelled = new Set() const pending = new Set() -const titleIndex = new Map() +const titleIndex = new Map() const invalidatedPaths = new Set() const sessionSearch = new SessionScannerServiceSearch(requestSessionSearchRoots) let initialized = false @@ -117,7 +119,10 @@ async function executeRequest(request: AiVaultServiceRequest): Promise, - title: AiVaultSessionTitle, + titleIndex: Map, + title: CachedAiVaultSessionTitle, maxEntries = 4_096 ): void { const key = `${title.agent}\0${title.sessionId}` diff --git a/src/main/ai-vault/scanners/claude/session-scanner-claude-title.test.ts b/src/main/ai-vault/scanners/claude/session-scanner-claude-title.test.ts index 41d0147fe4d..762718d57be 100644 --- a/src/main/ai-vault/scanners/claude/session-scanner-claude-title.test.ts +++ b/src/main/ai-vault/scanners/claude/session-scanner-claude-title.test.ts @@ -13,7 +13,7 @@ afterEach(async () => { }) describe('scanAiVaultSessions Claude title selection', () => { - it('prefers the latest generated ai-title over the first user prompt, but a custom-title wins over both', async () => { + it('prefers the latest generated ai-title over the first user prompt, but a custom-title wins over both, and marks which is a name', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-ai-title-')) tempRoots.push(root) const roots = isolatedScanRoots(root) @@ -62,12 +62,28 @@ describe('scanAiVaultSessions Claude title selection', () => { } ]) + await writeJsonlFile(join(projectDir, 'prompt.jsonl'), [ + { + type: 'user', + sessionId: 'prompt', + timestamp: '2026-05-01T12:00:00.000Z', + cwd: '/tmp/claude', + message: { role: 'user', content: 'Unnamed session prompt' } + } + ]) + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) expect(result.issues).toEqual([]) - expect(result.sessions.map((session) => session.title).sort()).toEqual([ - 'Updated karma discussion title', - 'User set title' + // Renames and generated names are the session's own; a prompt only stands in until one exists. + expect( + result.sessions + .map((session) => [session.title, session.titleKind]) + .sort(([a], [b]) => String(a).localeCompare(String(b))) + ).toEqual([ + ['Unnamed session prompt', 'fallback'], + ['Updated karma discussion title', 'named'], + ['User set title', 'named'] ]) }) }) diff --git a/src/main/ai-vault/scanners/claude/session-scanner-primary-parsers.ts b/src/main/ai-vault/scanners/claude/session-scanner-primary-parsers.ts index cff6b04692a..815c82b9c46 100644 --- a/src/main/ai-vault/scanners/claude/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/scanners/claude/session-scanner-primary-parsers.ts @@ -176,7 +176,9 @@ export async function finalizeClaudeSessionParseState( snapshot.accumulator.filePath ) } - return finalizeSession(snapshot.accumulator, platform, options) + // A rename (custom-title) or Claude's generated name is the session's own; prompts and metadata are stand-ins. + const titleKind = snapshot.accumulator.title || snapshot.generatedTitle ? 'named' : 'fallback' + return finalizeSession(snapshot.accumulator, platform, { ...options, titleKind }) } export function createClaudeSessionResumeState( diff --git a/src/main/ai-vault/scanners/codex/session-scanner-codex-cached-title.ts b/src/main/ai-vault/scanners/codex/session-scanner-codex-cached-title.ts index 30b67229b52..9f330b35e1e 100644 --- a/src/main/ai-vault/scanners/codex/session-scanner-codex-cached-title.ts +++ b/src/main/ai-vault/scanners/codex/session-scanner-codex-cached-title.ts @@ -8,7 +8,9 @@ export async function refreshCodexTitleFromIndex( readIndexedTitle: (sessionId: string) => Promise ): Promise { const title = await readIndexedTitle(session.sessionId) - return title && title !== session.title ? { ...session, title } : session + return title && (title !== session.title || session.titleKind !== 'named') + ? { ...session, title, titleKind: 'named' } + : session } export function refreshCachedCodexTitle( diff --git a/src/main/ai-vault/scanners/codex/session-scanner-codex-parser.test.ts b/src/main/ai-vault/scanners/codex/session-scanner-codex-parser.test.ts index b38ed3a2a29..ed71c6b47ec 100644 --- a/src/main/ai-vault/scanners/codex/session-scanner-codex-parser.test.ts +++ b/src/main/ai-vault/scanners/codex/session-scanner-codex-parser.test.ts @@ -222,6 +222,8 @@ describe('parseCodexSessionFile', () => { ) expect(session?.lastUserPrompt).toBe('Review the PR and fix real regressions') + // The first prompt only stands in for a name; an index thread name would replace it as 'named'. + expect(session?.titleKind).toBe('fallback') }) it('does not double-count usage when token count formats switch', async () => { diff --git a/src/main/ai-vault/scanners/codex/session-scanner-codex-parser.ts b/src/main/ai-vault/scanners/codex/session-scanner-codex-parser.ts index 76ba74cba5b..43b79f26acd 100644 --- a/src/main/ai-vault/scanners/codex/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/scanners/codex/session-scanner-codex-parser.ts @@ -1,6 +1,9 @@ import { openTranscriptReadStream } from '../../../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' -import type { AiVaultSession } from '../../../../shared/ai-vault/contracts/ai-vault-types' +import type { + AiVaultSession, + AiVaultSessionTitleKind +} from '../../../../shared/ai-vault/contracts/ai-vault-types' import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index' import type { ExecutionHostId } from '../../../../shared/execution-host' import { @@ -218,13 +221,17 @@ async function finalizeCodexParseState( // Finalize a snapshot: the live state keeps accumulating appended lines. const snapshot = cloneCodexParseState(state) // Recheck the signature-cached index: a title can arrive after the transcript stops growing. + let titleKind: AiVaultSessionTitleKind = + snapshot.titleSource === 'meta' && snapshot.accumulator.title ? 'named' : 'fallback' if (snapshot.sawSessionMeta) { const indexedTitle = await args.titleReader?.(snapshot.accumulator.sessionId) if (indexedTitle) { snapshot.accumulator.title = indexedTitle + titleKind = 'named' } } return finalizeSession(snapshot.accumulator, platform, { + titleKind, codexHome: args.codexHome, executionHostId: args.executionHostId, executionHostPlatform: args.executionHostPlatform diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index 5b0dce365d1..beaebd9ad79 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -55,6 +55,7 @@ const aiVaultSessionSchema = z.object({ agent: z.string().min(1), sessionId: z.string(), title: z.string(), + titleKind: z.enum(['named', 'fallback']).optional().catch(undefined), cwd: z.string().nullable(), // Malformed aliases must not discard the entire session. cwdAliases: z diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index 1154ed56e4a..e02a4f9196b 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -2,7 +2,8 @@ import { basename, extname } from 'node:path' import { aiVaultAgentLabel, type AiVaultAgent, - type AiVaultSession + type AiVaultSession, + type AiVaultSessionTitleKind } from '../../shared/ai-vault/contracts/ai-vault-types' import { buildAiVaultResumeCommand } from '../../shared/ai-vault/resume/ai-vault-resume-command' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' @@ -97,6 +98,7 @@ export function finalizeSession( codexHome?: string | null executionHostId?: ExecutionHostId executionHostPlatform?: NodeJS.Platform | null + titleKind?: AiVaultSessionTitleKind } = {} ): AiVaultSession | null { const sessionId = accumulator.sessionId.trim() @@ -119,6 +121,7 @@ export function finalizeSession( agent: accumulator.agent, sessionId, title, + ...(options.titleKind ? { titleKind: options.titleKind } : {}), cwd: accumulator.cwd, branch: accumulator.branch, model: accumulator.model, diff --git a/src/main/ai-vault/session-titles/session-title-file-reader-wsl-stall.test.ts b/src/main/ai-vault/session-titles/session-title-file-reader-wsl-stall.test.ts index fe8ffeffa3d..2ee037fc239 100644 --- a/src/main/ai-vault/session-titles/session-title-file-reader-wsl-stall.test.ts +++ b/src/main/ai-vault/session-titles/session-title-file-reader-wsl-stall.test.ts @@ -81,6 +81,8 @@ describe('AI Vault session titles with a stalled WSL transcript lstat', () => { await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) const { titles } = await pending - expect(titles).toEqual([{ agent: 'claude', sessionId: LOCAL_SESSION_ID, title: 'hello world' }]) + expect(titles).toEqual([ + { agent: 'claude', sessionId: LOCAL_SESSION_ID, title: 'hello world', kind: 'fallback' } + ]) }) }) diff --git a/src/main/ai-vault/session-titles/session-title-file-reader.test.ts b/src/main/ai-vault/session-titles/session-title-file-reader.test.ts index ecc0d21d8b6..e1bb07c3b93 100644 --- a/src/main/ai-vault/session-titles/session-title-file-reader.test.ts +++ b/src/main/ai-vault/session-titles/session-title-file-reader.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CachedAiVaultSessionTitle } from './session-title-file-reader' import * as transcriptFs from '../../native-chat/wsl-transcript-fs-access' const parseAgentSessionFileCached = vi.hoisted(() => vi.fn()) @@ -51,7 +52,8 @@ describe('readAiVaultSessionTitlesFromFiles', () => { parseAgentSessionFileCached.mockResolvedValue({ agent: 'codex', sessionId: 'session-1', - title: ' Exact title ' + title: ' Exact title ', + titleKind: 'fallback' }) const controller = new AbortController() @@ -61,7 +63,7 @@ describe('readAiVaultSessionTitlesFromFiles', () => { { signal: controller.signal } ) ).resolves.toEqual({ - titles: [{ agent: 'codex', sessionId: 'session-1', title: 'Exact title' }] + titles: [{ agent: 'codex', sessionId: 'session-1', title: 'Exact title', kind: 'fallback' }] }) expect(parseAgentSessionFileCached.mock.calls[0]?.[4]).toBe(controller.signal) expect(parseAgentSessionFileCached).toHaveBeenCalledTimes(1) @@ -71,6 +73,88 @@ describe('readAiVaultSessionTitlesFromFiles', () => { }) }) + it('rereads a session cached before titles carried a kind once, so a renamed session counts as named', async () => { + const path = await transcriptPath() + const cached = { agent: 'claude', sessionId: 'session-1', title: 'Renamed' } + parseAgentSessionFileCached + .mockResolvedValueOnce(cached) + .mockResolvedValueOnce({ ...cached, titleKind: 'named' }) + + await expect( + readAiVaultSessionTitlesFromFiles([ + { agent: 'claude', sessionId: 'session-1', transcriptPath: path } + ]) + ).resolves.toEqual({ + titles: [{ agent: 'claude', sessionId: 'session-1', title: 'Renamed', kind: 'named' }] + }) + expect(parseAgentSessionFileCached).toHaveBeenCalledTimes(2) + expect(parseAgentSessionFileCached.mock.calls[1]?.[3]).toBe('whole') + + // A failed reread keeps the cached title, unclassified. + parseAgentSessionFileCached.mockResolvedValueOnce(cached).mockResolvedValueOnce(null) + await expect( + readAiVaultSessionTitlesFromFiles([ + { agent: 'claude', sessionId: 'session-1', transcriptPath: path } + ]) + ).resolves.toEqual({ + titles: [{ agent: 'claude', sessionId: 'session-1', title: 'Renamed' }] + }) + }) + + it('classifies a History-cached title from an old parse row when the request has only a session id', async () => { + const path = await transcriptPath() + parseAgentSessionFileCached.mockResolvedValue({ + agent: 'claude', + sessionId: 'session-1', + title: 'Login cleanup', + titleKind: 'named' + }) + const cached = new Map([ + [ + 'session-1', + { agent: 'claude', sessionId: 'session-1', title: 'Login cleanup', transcriptPath: path } + ] + ]) + const cache = { + get: (request: { sessionId: string }) => cached.get(request.sessionId) ?? null, + set: (title: CachedAiVaultSessionTitle) => cached.set(title.sessionId, title) + } + const read = () => + readAiVaultSessionTitlesFromFiles([{ agent: 'claude', sessionId: 'session-1' }], { cache }) + + const named = { agent: 'claude', sessionId: 'session-1', title: 'Login cleanup', kind: 'named' } + await expect(read()).resolves.toEqual({ titles: [named] }) + // The classified title replaces the cache entry, so the transcript is read only once. + await expect(read()).resolves.toEqual({ titles: [named] }) + expect(parseAgentSessionFileCached).toHaveBeenCalledTimes(1) + + // When the transcript cannot be read, the cached title still comes back, unclassified. + cached.set('session-1', { + agent: 'claude', + sessionId: 'session-1', + title: 'Login cleanup', + transcriptPath: `${path}.missing` + }) + await expect(read()).resolves.toEqual({ + titles: [{ agent: 'claude', sessionId: 'session-1', title: 'Login cleanup' }] + }) + + // A reread that fails after the first parse keeps the transcript in the cache, so a later request classifies it. + const unclassified = { + agent: 'claude' as const, + sessionId: 'session-1', + title: 'Login cleanup' + } + cached.set('session-1', { ...unclassified, transcriptPath: path }) + parseAgentSessionFileCached + .mockResolvedValueOnce(unclassified) + .mockRejectedValueOnce(new Error('transient')) + await expect(read()).resolves.toEqual({ titles: [unclassified] }) + expect(cached.get('session-1')?.transcriptPath).toBe(path) + parseAgentSessionFileCached.mockResolvedValueOnce({ ...unclassified, titleKind: 'named' }) + await expect(read()).resolves.toEqual({ titles: [{ ...unclassified, kind: 'named' }] }) + }) + it('rejects a transcript whose parsed identity does not match the request', async () => { const path = await transcriptPath() parseAgentSessionFileCached.mockResolvedValue({ @@ -106,7 +190,8 @@ describe('readAiVaultSessionTitlesFromFiles', () => { Array.from({ length: 9 }, () => ({ agent: 'codex', sessionId: 'named', - title: 'Indexed name' + title: 'Indexed name', + kind: 'named' })) ) expect( @@ -120,7 +205,8 @@ describe('readAiVaultSessionTitlesFromFiles', () => { parseAgentSessionFileCached.mockResolvedValue({ agent: 'codex', sessionId: 'session-0', - title: 'Title' + title: 'Title', + titleKind: 'fallback' }) await readAiVaultSessionTitlesFromFiles( diff --git a/src/main/ai-vault/session-titles/session-title-file-reader.ts b/src/main/ai-vault/session-titles/session-title-file-reader.ts index 747e2d193d0..993d3f7f0b4 100644 --- a/src/main/ai-vault/session-titles/session-title-file-reader.ts +++ b/src/main/ai-vault/session-titles/session-title-file-reader.ts @@ -15,9 +15,12 @@ import { const TITLE_PARSE_CONCURRENCY = 4 +/** A title the scanner already saw, with its transcript when the title's kind is not known yet. */ +export type CachedAiVaultSessionTitle = AiVaultSessionTitle & { transcriptPath?: string } + export type AiVaultSessionTitleCache = { - get(request: AiVaultSessionTitleRequest): AiVaultSessionTitle | null - set(title: AiVaultSessionTitle): void + get(request: AiVaultSessionTitleRequest): CachedAiVaultSessionTitle | null + set(title: CachedAiVaultSessionTitle): void } async function readOneTitle( @@ -30,7 +33,17 @@ async function readOneTitle( } const transcriptPath = request.transcriptPath?.trim() if (!transcriptPath) { - return cache?.get(request) ?? null + const cached = cache?.get(request) + if (!cached) { + return null + } + const { transcriptPath: cachedPath, ...title } = cached + // Why: History caches titles from parse rows saved before titles carried a kind; reading that + // transcript here classifies it once (the result replaces the cached entry, so this cannot repeat). + // A failed reread (a stalled WSL distro) still returns the cached title, unclassified. + return !title.kind && cachedPath + ? ((await readOneTitle({ ...request, transcriptPath: cachedPath }, signal, cache)) ?? title) + : title } try { const home = request.agent === 'codex' ? codexHomeFromSessionFilePath(transcriptPath) : null @@ -40,7 +53,12 @@ async function readOneTitle( return null } if (title) { - const resolved = { agent: request.agent, sessionId: request.sessionId, title } + const resolved: AiVaultSessionTitle = { + agent: request.agent, + sessionId: request.sessionId, + title, + kind: 'named' + } cache?.set(resolved) return resolved } @@ -50,22 +68,41 @@ async function readOneTitle( return null } // Raw lstat matches scanner keys only for providers without content dependencies: Claude and Codex. - const session = await parseAgentSessionFileCached( - { - agent: request.agent, - file: { - path: transcriptPath, - mtimeMs: stats.mtimeMs, - modifiedAt: stats.mtime.toISOString(), - sizeBytes: stats.size - }, - codexHome: null + const candidate = { + agent: request.agent, + file: { + path: transcriptPath, + mtimeMs: stats.mtimeMs, + modifiedAt: stats.mtime.toISOString(), + sizeBytes: stats.size }, + codexHome: null + } + let session = await parseAgentSessionFileCached( + candidate, process.platform, undefined, undefined, signal ) + // Why: entries cached before titles carried their kind would rank every name as a stand-in; reread + // such a transcript once so the cache learns the kind, instead of invalidating every cached session. + if ( + session?.agent === request.agent && + session.sessionId === request.sessionId && + !session.titleKind && + !signal?.aborted + ) { + // A failed reread keeps the first result, unclassified. + session = + (await parseAgentSessionFileCached( + candidate, + process.platform, + undefined, + 'whole', + signal + ).catch(() => null)) ?? session + } if ( signal?.aborted || session?.agent !== request.agent || @@ -74,12 +111,14 @@ async function readOneTitle( ) { return null } - const title = { + const title: AiVaultSessionTitle = { agent: request.agent, sessionId: request.sessionId, - title: session.title.trim() + title: session.title.trim(), + ...(session.titleKind ? { kind: session.titleKind } : {}) } - cache?.set(title) + // An unclassified title keeps its transcript so a later session-id-only request can still classify it. + cache?.set(title.kind ? title : { ...title, transcriptPath }) return title } catch { return null diff --git a/src/main/ai-vault/session-titles/session-title-result-validation.ts b/src/main/ai-vault/session-titles/session-title-result-validation.ts index f6094d9f867..9e8d94c9162 100644 --- a/src/main/ai-vault/session-titles/session-title-result-validation.ts +++ b/src/main/ai-vault/session-titles/session-title-result-validation.ts @@ -34,6 +34,8 @@ function parseTitle(value: unknown): AiVaultSessionTitle { return { agent: record.agent, sessionId: record.sessionId, - title: record.title.trim() + title: record.title.trim(), + // Unknown kinds from a newer host stay unclassified rather than failing the whole result. + ...(record.kind === 'named' || record.kind === 'fallback' ? { kind: record.kind } : {}) } } diff --git a/src/main/runtime/orchestration/orchestration-sender-name.ts b/src/main/runtime/orchestration/orchestration-sender-name.ts index 6c6184b888b..145812c09b1 100644 --- a/src/main/runtime/orchestration/orchestration-sender-name.ts +++ b/src/main/runtime/orchestration/orchestration-sender-name.ts @@ -65,7 +65,12 @@ export function terminalNameInputs(tab: MirroredTerminalTab): MirroredTerminalTa generatedTitle: tab.generatedTitle, defaultTitle: tab.defaultTitle, aiVaultTitle: provider - ? { agent: provider.agent, sessionId: provider.sessionId, title: provider.title } + ? { + agent: provider.agent, + sessionId: provider.sessionId, + title: provider.title, + ...(provider.kind ? { kind: provider.kind } : {}) + } : provider } } diff --git a/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-title-projection.test.ts b/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-title-projection.test.ts index 85c495ea2e2..b087f13ecaf 100644 --- a/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-title-projection.test.ts +++ b/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-title-projection.test.ts @@ -10,7 +10,7 @@ const tab: RuntimeMobileSessionTerminalTab = { leafId: 'leaf-1', title: 'Codex 公司 High', quickCommandLabel: 'Codex 公司 High', - aiVaultTitle: { agent: 'codex', sessionId: 'session-1', title: 'Login cleanup' }, + aiVaultTitle: { agent: 'codex', sessionId: 'session-1', title: 'Login cleanup', kind: 'named' }, resolvesSessionTitle: true, isActive: true } @@ -20,8 +20,20 @@ describe('paired desktop tab names', () => { const projected = projectRuntimeMobileSessionTitle({ worktree: 'wt-1' }, tab, { isSessionTitlePublisher: () => true }) - expect( - resolveTerminalTabTitle({ title: tab.title, customTitle: null, ...projected }, true) - ).toBe('Codex 公司 High') + const resolve = (overrides: Partial[0]>) => + resolveTerminalTabTitle( + { title: tab.title, customTitle: null, ...projected, ...overrides }, + true + ) + // The session's own name replaces the quick-command name; a name typed on the tab beats both. + expect(resolve({})).toBe('Login cleanup') + expect(resolve({ aiVaultTitle: undefined })).toBe('Codex 公司 High') + expect(resolve({ customTitle: 'My tab' })).toBe('My tab') + // A first-prompt stand-in, or a title from a host that does not say its kind, keeps the quick-command name. + const prompt = { agent: 'codex' as const, sessionId: 'session-1', title: 'fix the login page' } + expect(resolve({ aiVaultTitle: { ...prompt, kind: 'fallback' } })).toBe('Codex 公司 High') + expect(resolve({ aiVaultTitle: prompt })).toBe('Codex 公司 High') + // Without a quick-command name, a stand-in still names the tab as before. + expect(resolve({ aiVaultTitle: prompt, quickCommandLabel: null })).toBe('fix the login page') }) }) diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts index 5a5cf14d8ba..63e1d8d5486 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts @@ -1,6 +1,8 @@ import type { AgentProviderSessionMetadata } from '../../../shared/agent-session/lifecycle/agent-session-resume' -import { isAiVaultTitleAgent } from '../../../shared/ai-vault/contracts/ai-vault-session-title' -import type { TerminalTab } from '../../../shared/workspace-layout/terminal-tab-types' +import { + isAiVaultTitleAgent, + sameAiVaultSessionTitle +} from '../../../shared/ai-vault/contracts/ai-vault-session-title' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AppState } from '@/store/types' import { collectAiVaultTitleRequests } from './ai-vault-tab-title-requests' @@ -101,17 +103,6 @@ function agentRecordsEqual(current: AppState, previous: AppState): boolean { ) } -function titleEqual( - left: TerminalTab['aiVaultTitle'], - right: TerminalTab['aiVaultTitle'] -): boolean { - return ( - left?.agent === right?.agent && - left?.sessionId === right?.sessionId && - left?.title === right?.title - ) -} - function terminalTabsEqual(current: AppState, previous: AppState): boolean { const currentKeys = Object.keys(current.tabsByWorktree) const previousKeys = Object.keys(previous.tabsByWorktree) @@ -131,7 +122,7 @@ function terminalTabsEqual(current: AppState, previous: AppState): boolean { left.id !== right.id || left.worktreeId !== right.worktreeId || left.sessionTitleFromHost !== right.sessionTitleFromHost || - !titleEqual(left.aiVaultTitle, right.aiVaultTitle) + !sameAiVaultSessionTitle(left.aiVaultTitle, right.aiVaultTitle) ) { return false } diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts b/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts index f7bc90a0335..5b70c05c3ab 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts @@ -30,6 +30,38 @@ describe('AI Vault tab title sync', () => { stop() }) + it('asks once for a sleeping title saved before titles carried a kind', async () => { + const store = makeState({ + executionHostId: 'ssh:dev-box', + worktreeId: 'worktree-1', + path: '/workspace/albacore', + sleeping: true, + aiVaultTitle: { agent: 'codex', sessionId: 'codex-session', title: 'Login cleanup' } + }) + const resolveSessionTitles = vi.fn(async () => ({ + titles: [ + { + agent: 'codex' as const, + sessionId: 'codex-session', + title: 'Login cleanup', + kind: 'named' as const + } + ] + })) + const stop = startAiVaultTabTitleSync({ ...store, resolveSessionTitles }) + + await vi.waitFor(() => + expect(store.getState().tabsByWorktree['worktree-1'][0].aiVaultTitle?.kind).toBe('named') + ) + const tab = store.getState().tabsByWorktree['worktree-1'][0] as TerminalTab + expect(resolveTerminalTabTitle({ ...tab, quickCommandLabel: 'Codex High' }, false)).toBe( + 'Login cleanup' + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(resolveSessionTitles).toHaveBeenCalledTimes(1) + stop() + }) + it('drops a client result that returns after the paired host started naming the tab', async () => { const store = makeState({ executionHostId: 'runtime:server-1', diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync.ts b/src/renderer/src/lib/ai-vault-tab-title-sync.ts index b269b15481a..20cf796cc19 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync.ts @@ -1,4 +1,5 @@ import type { + AiVaultSessionTitle, AiVaultSessionTitlesArgs, AiVaultSessionTitlesResult } from '../../../shared/ai-vault/contracts/ai-vault-session-title' @@ -58,15 +59,23 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () => const lastSettledAttemptAt = new Map() const dirty = new Set() - const writeTitle = (request: AiVaultTitleRequest, title: string | null): void => { + const writeTitle = ( + request: AiVaultTitleRequest, + title: Pick | null + ): void => { writing = true try { - dependencies - .getState() - .setAiVaultTabTitle( - request.tabId, - title ? { agent: request.agent, sessionId: request.providerSession.id, title } : null - ) + dependencies.getState().setAiVaultTabTitle( + request.tabId, + title + ? { + agent: request.agent, + sessionId: request.providerSession.id, + title: title.title, + ...(title.kind ? { kind: title.kind } : {}) + } + : null + ) } finally { writing = false } @@ -87,13 +96,13 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () => if (stopped) { return } - const titleByIdentity = new Map() + const titleByIdentity = new Map>() for (const title of result.titles) { if (title.title.trim()) { - titleByIdentity.set( - `${first.executionHostId}\0${title.agent}\0${title.sessionId}`, - title.title.trim() - ) + titleByIdentity.set(`${first.executionHostId}\0${title.agent}\0${title.sessionId}`, { + title: title.title.trim(), + kind: title.kind + }) } } const currentByTabId = new Map( @@ -156,6 +165,8 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () => !identityMatches || !stored?.title.trim() || (request.refresh && last === undefined) || + // Titles saved before they carried a kind are asked for once, so a sleeping session's name can outrank its quick-command label. + (!stored?.kind && last === undefined) || (request.refresh && (dirty.has(aiVaultTitleRequestKey(request)) || now - last! >= diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-files.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-files.ts index e7a8ba583a6..4794ee459e0 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-files.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-files.ts @@ -2,6 +2,7 @@ import type { Tab, TabGroup } from '../../../../shared/tab-types' import type { OpenFile } from '../../store/slices/editor' import type { WebSessionTabsBatchContext, WebSessionTabsSyncState } from './state' import { sameStringArray } from './state-equality-core' +import { sameAiVaultSessionTitle } from '../../../../shared/ai-vault/contracts/ai-vault-session-title' export function openFileEqual(a: OpenFile, b: OpenFile): boolean { return ( @@ -85,9 +86,7 @@ export function tabEqual(a: Tab, b: Tab): boolean { a.label === b.label && // Include generated label in equality or unified titles diverge from terminal tabs. a.generatedLabel === b.generatedLabel && - a.aiVaultTitle?.agent === b.aiVaultTitle?.agent && - a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && - a.aiVaultTitle?.title === b.aiVaultTitle?.title && + sameAiVaultSessionTitle(a.aiVaultTitle, b.aiVaultTitle) && a.customLabel === b.customLabel && a.color === b.color && a.sortOrder === b.sortOrder && diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts index e0a8cb26e2f..aa86abbf8fc 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts @@ -10,6 +10,7 @@ import { sameRuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' import { sameStringArray } from './state-equality-core' +import { sameAiVaultSessionTitle } from '../../../../shared/ai-vault/contracts/ai-vault-session-title' export function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean { return ( @@ -21,9 +22,7 @@ export function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean { a.quickCommandLabel === b.quickCommandLabel && a.startupCwd === b.startupCwd && a.generatedTitle === b.generatedTitle && - a.aiVaultTitle?.agent === b.aiVaultTitle?.agent && - a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && - a.aiVaultTitle?.title === b.aiVaultTitle?.title && + sameAiVaultSessionTitle(a.aiVaultTitle, b.aiVaultTitle) && a.sessionTitleFromHost === b.sessionTitleFromHost && a.customTitle === b.customTitle && a.color === b.color && diff --git a/src/renderer/src/store/terminals/terminal-tab-presentation.ts b/src/renderer/src/store/terminals/terminal-tab-presentation.ts index c4e762c0d7a..2e518a66f6d 100644 --- a/src/renderer/src/store/terminals/terminal-tab-presentation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-presentation.ts @@ -11,6 +11,7 @@ import { } from '../slices/terminal-tab-owner-index' import { getTabIdFromPaneKey } from './terminal-pty-identities' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { sameAiVaultSessionTitle } from '../../../../shared/ai-vault/contracts/ai-vault-session-title' export function createTerminalTabPresentationActions( set: TerminalStoreSet, @@ -56,11 +57,7 @@ export function createTerminalTabPresentationActions( } const tabs = s.tabsByWorktree[ownerWorktreeId] ?? [] const current = tabs.find((tab) => tab.id === tabId) - const sameTitle = - current?.aiVaultTitle?.agent === aiVaultTitle?.agent && - current?.aiVaultTitle?.sessionId === aiVaultTitle?.sessionId && - current?.aiVaultTitle?.title === aiVaultTitle?.title - if (!current || sameTitle) { + if (!current || sameAiVaultSessionTitle(current.aiVaultTitle, aiVaultTitle)) { return s } const ownerTabs = tabs.map((tab) => (tab.id === tabId ? { ...tab, aiVaultTitle } : tab)) diff --git a/src/shared/agent-status/presentation/agent-row-conversation-name.ts b/src/shared/agent-status/presentation/agent-row-conversation-name.ts index da6880f50c0..db8f467cd75 100644 --- a/src/shared/agent-status/presentation/agent-row-conversation-name.ts +++ b/src/shared/agent-status/presentation/agent-row-conversation-name.ts @@ -122,7 +122,7 @@ export function getAgentRowConversationName( aiVaultTitle && aiVaultTitle.agent === agentType && aiVaultTitle.sessionId === providerSessionId - ? aiVaultTitle.title + ? aiVaultTitle : null, generatedTitle: tab.generatedTitle }, diff --git a/src/shared/ai-vault/contracts/ai-vault-session-title.ts b/src/shared/ai-vault/contracts/ai-vault-session-title.ts index a12c1d15dd8..a59fa9731af 100644 --- a/src/shared/ai-vault/contracts/ai-vault-session-title.ts +++ b/src/shared/ai-vault/contracts/ai-vault-session-title.ts @@ -1,4 +1,4 @@ -import type { AiVaultAgent } from './ai-vault-types' +import type { AiVaultAgent, AiVaultSessionTitleKind } from './ai-vault-types' import type { ExecutionHostId } from '../../execution-host' export const AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT = 64 @@ -7,6 +7,8 @@ export type AiVaultSessionTitle = { agent: Extract sessionId: string title: string + /** Absent from older hosts and saved tabs: unclassified, ranked like a fallback. */ + kind?: AiVaultSessionTitleKind } export type AiVaultSessionTitleRequest = { @@ -29,3 +31,16 @@ export function isAiVaultTitleAgent( ): agent is AiVaultSessionTitle['agent'] { return agent === 'claude' || agent === 'codex' } + +/** Kind is compared too, so a title whose text is unchanged can still become a known name. */ +export function sameAiVaultSessionTitle( + a: AiVaultSessionTitle | null | undefined, + b: AiVaultSessionTitle | null | undefined +): boolean { + return ( + a?.agent === b?.agent && + a?.sessionId === b?.sessionId && + a?.title === b?.title && + a?.kind === b?.kind + ) +} diff --git a/src/shared/ai-vault/contracts/ai-vault-types.ts b/src/shared/ai-vault/contracts/ai-vault-types.ts index c23bd935afc..1d35fd7bf34 100644 --- a/src/shared/ai-vault/contracts/ai-vault-types.ts +++ b/src/shared/ai-vault/contracts/ai-vault-types.ts @@ -107,6 +107,9 @@ export type AiVaultSessionSubagentInfo = { status: AiVaultSubagentRunStatus | null } +/** Where a title came from: the session's own name (a rename or provider-generated), or a stand-in such as its first prompt. */ +export type AiVaultSessionTitleKind = 'named' | 'fallback' + export type AiVaultSession = { /** Recorded account home; null proves System default, absent means unknown. */ accountHome?: AgentSessionAccountHome | null @@ -117,6 +120,8 @@ export type AiVaultSession = { agent: AiVaultAgent sessionId: string title: string + /** Claude and Codex only: whether `title` is the session's own name or a stand-in; absent means unclassified. */ + titleKind?: AiVaultSessionTitleKind cwd: string | null /** Host-proven spellings of `cwd` for matching scopes and resume targets; launch paths remain separate. */ cwdAliases?: string[] diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index 3864d51dfa6..fd7b9ca54d9 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -135,7 +135,8 @@ const MovePaneToNewTabCommand = z.object({ const AiVaultTitle = z.object({ agent: z.enum(['claude', 'codex']), sessionId: z.string(), - title: z.string() + title: z.string(), + kind: z.enum(['named', 'fallback']).optional().catch(undefined) }) /** A tab's rename: a title as the window stores it today; null clears it. */ diff --git a/src/shared/tab-title-resolution.ts b/src/shared/tab-title-resolution.ts index 0cc41146029..b30ce051939 100644 --- a/src/shared/tab-title-resolution.ts +++ b/src/shared/tab-title-resolution.ts @@ -1,3 +1,4 @@ +import type { AiVaultSessionTitle } from './ai-vault/contracts/ai-vault-session-title' import type { Tab } from './tab-types' import type { TerminalTab } from './workspace-layout/terminal-tab-types' import { isMeaningfulOpenCodeTerminalTitle } from './agent-status/title/providers/opencode-terminal-title' @@ -8,8 +9,8 @@ export type TabNameSources = { quickCommandLabel?: string | null /** The program's own terminal title right now. */ liveTitle: string - /** The agent session's own name, already checked to belong to this tab's session. */ - sessionTitle?: string | null + /** The agent session's title, already checked to belong to this tab's session. */ + sessionTitle?: Pick | null generatedTitle?: string | null } @@ -18,11 +19,16 @@ export function resolveTabNameSource( sources: TabNameSources, generatedTitlesEnabled: boolean ): string { + const sessionTitle = sources.sessionTitle?.title.trim() ?? '' + // Why: a quick-command label only names the tab until the session has a name of its own (a rename or + // a provider-generated name); a first-prompt stand-in does not replace it. A typed tab name wins over both. + const sessionNamed = sources.sessionTitle?.kind === 'named' return ( sources.customTitle?.trim() || - sources.quickCommandLabel?.trim() || (isMeaningfulOpenCodeTerminalTitle(sources.liveTitle) ? sources.liveTitle : '') || - sources.sessionTitle?.trim() || + (sessionNamed ? sessionTitle : '') || + sources.quickCommandLabel?.trim() || + sessionTitle || (generatedTitlesEnabled ? sources.generatedTitle?.trim() : '') || '' ) @@ -42,7 +48,7 @@ export function resolveTerminalTabTitle( customTitle: tab.customTitle, quickCommandLabel: tab.quickCommandLabel, liveTitle, - sessionTitle: tab.aiVaultTitle?.title, + sessionTitle: tab.aiVaultTitle, generatedTitle: tab.generatedTitle }, generatedTitlesEnabled @@ -66,7 +72,7 @@ export function resolveUnifiedTabLabel( customTitle: tab?.customLabel, quickCommandLabel: tab?.quickCommandLabel, liveTitle: liveLabel, - sessionTitle: tab?.aiVaultTitle?.title, + sessionTitle: tab?.aiVaultTitle, generatedTitle: tab?.generatedLabel }, generatedTitlesEnabled diff --git a/src/shared/workspace-session/workspace-session-schema.test.ts b/src/shared/workspace-session/workspace-session-schema.test.ts index 17b58d8872c..e560be5084e 100644 --- a/src/shared/workspace-session/workspace-session-schema.test.ts +++ b/src/shared/workspace-session/workspace-session-schema.test.ts @@ -177,7 +177,8 @@ describe('parseWorkspaceSession', () => { aiVaultTitle: { agent: 'codex', sessionId: 'session-1', - title: 'Provider thread name' + title: 'Provider thread name', + kind: 'named' }, customTitle: null, color: null, @@ -201,7 +202,8 @@ describe('parseWorkspaceSession', () => { aiVaultTitle: { agent: 'codex', sessionId: 'session-1', - title: 'Provider thread name' + title: 'Provider thread name', + kind: 'unknown-future-kind' }, customLabel: null, color: null, @@ -216,8 +218,12 @@ describe('parseWorkspaceSession', () => { if (result.ok) { expect(result.value.tabsByWorktree.wt[0].generatedTitle).toBe('Refactor auth') expect(result.value.tabsByWorktree.wt[0].aiVaultTitle?.title).toBe('Provider thread name') + // The kind survives a restore, so a session's name still outranks a quick-command label. + expect(result.value.tabsByWorktree.wt[0].aiVaultTitle?.kind).toBe('named') expect(result.value.unifiedTabs?.wt[0].generatedLabel).toBe('Refactor auth') expect(result.value.unifiedTabs?.wt[0].aiVaultTitle?.title).toBe('Provider thread name') + // An unknown kind from a newer build keeps the title, unclassified. + expect(result.value.unifiedTabs?.wt[0].aiVaultTitle?.kind).toBeUndefined() expect(result.value.unifiedTabs?.wt[0].executionHostId).toBe('runtime:host-b') } }) diff --git a/src/shared/workspace-session/workspace-session-schema.ts b/src/shared/workspace-session/workspace-session-schema.ts index 3343ca58c26..118b0fcb626 100644 --- a/src/shared/workspace-session/workspace-session-schema.ts +++ b/src/shared/workspace-session/workspace-session-schema.ts @@ -47,7 +47,8 @@ export const terminalTabSchema = z.object({ .object({ agent: z.enum(['claude', 'codex']), sessionId: z.string(), - title: z.string() + title: z.string(), + kind: z.enum(['named', 'fallback']).optional().catch(undefined) }) .nullable() .optional() @@ -92,7 +93,8 @@ const tabSchema = z.object({ .object({ agent: z.enum(['claude', 'codex']), sessionId: z.string(), - title: z.string() + title: z.string(), + kind: z.enum(['named', 'fallback']).optional().catch(undefined) }) .nullable() .optional()