diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index d99893acaa2..1f85743071c 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -11,14 +11,19 @@ import type { SessionAccumulator } from './session-scanner-types' import { - accumulatorFoldResumeState, addPreviewContent, addPreviewMessage, + cloneSessionAccumulator, createAccumulator, finalizeSession, sessionIdFromFileName, updateTimeline } from './session-scanner-accumulator' +import { + applyGraphSessionTitle, + cloneGraphSessionTitleState, + type GraphSessionTitleState +} from './session-scanner-graph-session-title' import { arrayValue, asRecord, @@ -205,12 +210,14 @@ export async function parseMessageGraphSessionContent( }) } -function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: string): void { +function consumeMessageGraphRecordLine(state: GraphSessionTitleState, line: string): void { const record = parseJsonObject(line) if (!record) { return } + const { accumulator } = state updateTimeline(accumulator, extractString(record.timestamp)) + applyGraphSessionTitle(state, record) if (record.type === 'session') { const sessionId = extractString(record.id) if (sessionId) { @@ -234,7 +241,7 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st if (role === 'user' || role === 'assistant') { accumulator.messageCount++ if (role === 'user') { - accumulator.title ??= extractMessageText(message) + accumulator.fallbackTitle ??= extractMessageText(message) } else { accumulator.model = extractString(message?.model) ?? accumulator.model accumulator.totalTokens += tokenTotal(message?.usage) @@ -247,14 +254,26 @@ export function createMessageGraphSessionResumeState( agent: MessageGraphAgent, file: FileWithMtime ): ResumableSessionParseState { - const state = accumulatorFoldResumeState( - createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path) }), - consumeMessageGraphRecordLine - ) + const resume = createMessageGraphResumeFrom({ + accumulator: createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path) }), + source: null + }) // Why: only OMP materializes task-subagent transcripts beside its sessions // (in the same-named artifact dir); the row UI shows the count without // expanding details. Pi/OpenClaw/Prime Agent have no such layout — skip the readdir. - return agent === 'omp' ? withOmpSubagentTranscriptCount(state, file.path) : state + return agent === 'omp' ? withOmpSubagentTranscriptCount(resume, file.path) : resume +} + +function createMessageGraphResumeFrom(state: GraphSessionTitleState): ResumableSessionParseState { + return { + consumeLine: (line) => consumeMessageGraphRecordLine(state, line), + clone: () => createMessageGraphResumeFrom(cloneGraphSessionTitleState(state)), + touchFile: (nextFile) => { + state.accumulator.modifiedAt = nextFile.modifiedAt + }, + finalize: (platform, options) => + finalizeSession(cloneSessionAccumulator(state.accumulator), platform, options) + } } async function parseMessageGraphSessionLines(args: { diff --git a/src/main/ai-vault/session-scanner-graph-session-title.test.ts b/src/main/ai-vault/session-scanner-graph-session-title.test.ts new file mode 100644 index 00000000000..9ed87a426ee --- /dev/null +++ b/src/main/ai-vault/session-scanner-graph-session-title.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest' +import { + parseMessageGraphSessionContent, + type MessageGraphAgent +} from './session-scanner-graph-parsers' +import type { FileWithMtime } from './session-scanner-types' + +function graphFile(sessionFile: string): FileWithMtime { + return { + path: `/tmp/${sessionFile}`, + mtimeMs: 1, + modifiedAt: '2026-05-01T10:00:00.000Z' + } +} + +function jsonl(records: unknown[]): string { + return `${records.map((record) => JSON.stringify(record)).join('\n')}\n` +} + +async function parseGraph( + agent: MessageGraphAgent, + records: unknown[], + sessionFile = `${agent}-session.jsonl` +) { + return parseMessageGraphSessionContent(agent, graphFile(sessionFile), jsonl(records), 'darwin') +} + +const firstPrompt = { + type: 'message', + timestamp: '2026-05-01T10:00:05.000Z', + message: { role: 'user', content: 'first user prompt' } +} + +describe('message-graph harness session titles', () => { + it('uses the latest Pi session_info.name ahead of the first user prompt', async () => { + const session = await parseGraph('pi', [ + { + type: 'session', + id: 'pi-session', + cwd: '/tmp/pi', + timestamp: '2026-05-01T10:00:00.000Z' + }, + firstPrompt, + { type: 'session_info', name: 'old name', timestamp: '2026-05-01T10:00:10.000Z' }, + { type: 'session_info', name: '111', timestamp: '2026-05-01T10:00:20.000Z' } + ]) + expect(session?.title).toBe('111') + }) + + it('keeps the first Pi user prompt when no session_info name exists', async () => { + const session = await parseGraph('pi', [ + { + type: 'session', + id: 'pi-session', + cwd: '/tmp/pi', + timestamp: '2026-05-01T10:00:00.000Z' + }, + firstPrompt + ]) + expect(session?.title).toBe('first user prompt') + }) + + it('uses the OMP session header title ahead of the first user prompt', async () => { + const session = await parseGraph('omp', [ + { + type: 'session', + version: 3, + id: 'omp-session', + cwd: '/tmp/omp', + title: 'OMP session title', + timestamp: '2026-05-01T10:00:00.000Z' + }, + firstPrompt + ]) + expect(session?.title).toBe('OMP session title') + }) + + it('lets a later OMP user title replace an automatic title', async () => { + const session = await parseGraph('omp', [ + { + type: 'title', + v: 1, + title: 'auto generated', + source: 'auto', + updatedAt: '2026-05-01T10:00:00.000Z', + pad: '' + }, + { + type: 'session', + version: 3, + id: 'omp-session', + cwd: '/tmp/omp', + title: 'auto generated', + titleSource: 'auto', + timestamp: '2026-05-01T10:00:00.000Z' + }, + firstPrompt, + { + type: 'title_change', + title: 'useful title', + source: 'user', + timestamp: '2026-05-01T10:00:30.000Z' + } + ]) + expect(session?.title).toBe('useful title') + }) + + it('does not let an automatic OMP title overwrite a user title', async () => { + const session = await parseGraph('omp', [ + { + type: 'session', + version: 3, + id: 'omp-session', + cwd: '/tmp/omp', + title: 'useful title', + titleSource: 'user', + timestamp: '2026-05-01T10:00:00.000Z' + }, + firstPrompt, + { + type: 'title_change', + title: 'auto generated', + source: 'auto', + timestamp: '2026-05-01T10:00:30.000Z' + } + ]) + expect(session?.title).toBe('useful title') + }) + + it('uses a user title slot even after a later first prompt', async () => { + const session = await parseGraph('omp', [ + { + type: 'title', + v: 1, + title: 'useful title', + source: 'user', + updatedAt: '2026-05-01T10:00:00.000Z', + pad: '' + }, + { + type: 'session', + version: 3, + id: 'omp-session', + cwd: '/tmp/omp', + timestamp: '2026-05-01T10:00:00.000Z' + }, + firstPrompt + ]) + expect(session?.title).toBe('useful title') + }) +}) diff --git a/src/main/ai-vault/session-scanner-graph-session-title.ts b/src/main/ai-vault/session-scanner-graph-session-title.ts new file mode 100644 index 00000000000..f9e4aad2b98 --- /dev/null +++ b/src/main/ai-vault/session-scanner-graph-session-title.ts @@ -0,0 +1,56 @@ +import { cloneSessionAccumulator } from './session-scanner-accumulator' +import type { SessionAccumulator } from './session-scanner-types' +import { extractString, normalizeTitleText } from './session-scanner-values' + +export type GraphSessionTitleSource = 'user' | 'auto' + +export type GraphSessionTitleState = { + accumulator: SessionAccumulator + source: GraphSessionTitleSource | null +} + +export function cloneGraphSessionTitleState(state: GraphSessionTitleState): GraphSessionTitleState { + return { + accumulator: cloneSessionAccumulator(state.accumulator), + source: state.source + } +} + +// Why: Pi `/name` and OMP `/rename` persist as harness metadata, not as the +// first user prompt. A later user title must replace auto/prompt titles, and +// an automatic title must never clobber a user title. +export function applyGraphSessionTitle( + state: GraphSessionTitleState, + record: Record +): void { + const incoming = graphSessionTitleFromRecord(record) + if (!incoming) { + return + } + if (state.source === 'user' && incoming.source !== 'user') { + return + } + state.accumulator.title = incoming.title + state.source = incoming.source +} + +function graphSessionTitleFromRecord( + record: Record +): { title: string; source: GraphSessionTitleSource } | null { + const type = extractString(record.type) + if (type === 'session_info') { + const title = normalizeTitleText( + extractString(record.name) ?? extractString(record.title) ?? '' + ) + return title ? { title, source: 'user' } : null + } + if (type !== 'title' && type !== 'title_change' && type !== 'session') { + return null + } + const title = normalizeTitleText(extractString(record.title) ?? '') + if (!title) { + return null + } + const raw = extractString(record.source) ?? extractString(record.titleSource) + return { title, source: raw === 'user' ? 'user' : 'auto' } +} diff --git a/src/main/ai-vault/session-scanner-incremental-fixtures.ts b/src/main/ai-vault/session-scanner-incremental-fixtures.ts index 809096dbacb..bc7948e17e9 100644 --- a/src/main/ai-vault/session-scanner-incremental-fixtures.ts +++ b/src/main/ai-vault/session-scanner-incremental-fixtures.ts @@ -192,8 +192,8 @@ export function piFixture(): IncrementalAgentFixture { // OMP is a Pi fork sharing the message-graph format, but keys the model on // `model` (not Pi's `modelId`); its own fixture exercises the registry's 'omp' -// branch and that model-key difference. (The session `title` is included for -// realism only — the parser derives the title from the first user message.) +// branch and that model-key difference. The header title outranks the first +// user prompt, matching a real `/rename`. export function ompFixture(): IncrementalAgentFixture { return { agent: 'omp', diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index feff1b19f89..ab4d8f21bc9 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -747,6 +747,7 @@ describe('scanAiVaultSessions', () => { const ompSession = result.sessions.find((session) => session.agent === 'omp') expect(ompSession?.model).toBe('gpt-5.4-mini') expect(ompSession?.totalTokens).toBe(160) + expect(ompSession?.title).toBe('OMP session title') // Prime Agent keeps Pi's `model_change.modelId` key, so the pre-reply model // must come through even though no assistant message was written yet.