diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 1f85743071c..7882172f218 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -22,6 +22,7 @@ import { import { applyGraphSessionTitle, cloneGraphSessionTitleState, + type GraphSessionAgent, type GraphSessionTitleState } from './session-scanner-graph-session-title' import { @@ -174,7 +175,7 @@ export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): str // Agents whose transcripts are append-only message-graph JSONL (session + // model_change + message records). OMP and Prime Agent are Pi forks and // share the format. -export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp' | 'prime-agent' +export type MessageGraphAgent = GraphSessionAgent export async function parseMessageGraphSessionFile( agent: MessageGraphAgent, @@ -255,6 +256,7 @@ export function createMessageGraphSessionResumeState( file: FileWithMtime ): ResumableSessionParseState { const resume = createMessageGraphResumeFrom({ + agent, accumulator: createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path) }), source: null }) 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 index 9ed87a426ee..e57833bb3c1 100644 --- a/src/main/ai-vault/session-scanner-graph-session-title.test.ts +++ b/src/main/ai-vault/session-scanner-graph-session-title.test.ts @@ -31,6 +31,8 @@ const firstPrompt = { message: { role: 'user', content: 'first user prompt' } } +const piFamilyAgents = ['pi', 'openclaw', 'prime-agent'] as const + 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', [ @@ -60,6 +62,31 @@ describe('message-graph harness session titles', () => { expect(session?.title).toBe('first user prompt') }) + it.each(piFamilyAgents)( + 'uses %s session_info.name but ignores OMP-only title records', + async (agent) => { + const session = await parseGraph(agent, [ + { type: 'session', id: `${agent}-session`, timestamp: '2026-05-01T10:00:00.000Z' }, + firstPrompt, + { type: 'session_info', name: `${agent} name` }, + { type: 'title', title: 'wrong slot title', source: 'user' }, + { type: 'session', id: `${agent}-session`, title: 'wrong header title' }, + { type: 'title_change', title: 'wrong changed title', source: 'user' }, + { type: 'session_info', title: 'wrong session info title' } + ]) + expect(session?.title).toBe(`${agent} name`) + } + ) + + it('accepts legacy OMP session_info.name but rejects session_info.title', async () => { + const [unsupported, legacy] = await Promise.all([ + parseGraph('omp', [firstPrompt, { type: 'session_info', title: 'unsupported title' }]), + parseGraph('omp', [firstPrompt, { type: 'session_info', name: 'legacy OMP name' }]) + ]) + expect(unsupported?.title).toBe('first user prompt') + expect(legacy?.title).toBe('legacy OMP name') + }) + it('uses the OMP session header title ahead of the first user prompt', async () => { const session = await parseGraph('omp', [ { @@ -127,7 +154,26 @@ describe('message-graph harness session titles', () => { expect(session?.title).toBe('useful title') }) - it('uses a user title slot even after a later first prompt', async () => { + it('lets a later automatic OMP title update a source-less legacy header', async () => { + const session = await parseGraph('omp', [ + { + type: 'session', + id: 'omp-session', + title: 'legacy automatic title', + timestamp: '2026-05-01T10:00:00.000Z' + }, + firstPrompt, + { + type: 'title_change', + title: 'new automatic title', + source: 'auto', + timestamp: '2026-05-01T10:00:30.000Z' + } + ]) + expect(session?.title).toBe('new automatic title') + }) + + it('keeps a user title slot ahead of a stale source-less header', async () => { const session = await parseGraph('omp', [ { type: 'title', @@ -142,6 +188,7 @@ describe('message-graph harness session titles', () => { version: 3, id: 'omp-session', cwd: '/tmp/omp', + title: 'stale automatic title', timestamp: '2026-05-01T10:00:00.000Z' }, firstPrompt diff --git a/src/main/ai-vault/session-scanner-graph-session-title.ts b/src/main/ai-vault/session-scanner-graph-session-title.ts index f9e4aad2b98..97e3bb7aef5 100644 --- a/src/main/ai-vault/session-scanner-graph-session-title.ts +++ b/src/main/ai-vault/session-scanner-graph-session-title.ts @@ -2,28 +2,29 @@ import { cloneSessionAccumulator } from './session-scanner-accumulator' import type { SessionAccumulator } from './session-scanner-types' import { extractString, normalizeTitleText } from './session-scanner-values' +export type GraphSessionAgent = 'openclaw' | 'pi' | 'omp' | 'prime-agent' export type GraphSessionTitleSource = 'user' | 'auto' export type GraphSessionTitleState = { + agent: GraphSessionAgent accumulator: SessionAccumulator source: GraphSessionTitleSource | null } export function cloneGraphSessionTitleState(state: GraphSessionTitleState): GraphSessionTitleState { return { + agent: state.agent, 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. +// Harness names are persisted metadata; explicit names outrank automatic and prompt fallbacks. export function applyGraphSessionTitle( state: GraphSessionTitleState, record: Record ): void { - const incoming = graphSessionTitleFromRecord(record) + const incoming = graphSessionTitleFromRecord(state.agent, record) if (!incoming) { return } @@ -35,16 +36,15 @@ export function applyGraphSessionTitle( } function graphSessionTitleFromRecord( + agent: GraphSessionAgent, 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) ?? '' - ) + const title = normalizeTitleText(extractString(record.name) ?? '') return title ? { title, source: 'user' } : null } - if (type !== 'title' && type !== 'title_change' && type !== 'session') { + if (agent !== 'omp' || (type !== 'title' && type !== 'title_change' && type !== 'session')) { return null } const title = normalizeTitleText(extractString(record.title) ?? '') @@ -52,5 +52,8 @@ function graphSessionTitleFromRecord( return null } const raw = extractString(record.source) ?? extractString(record.titleSource) + if (raw !== null && raw !== 'user' && raw !== 'auto') { + return null + } 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 bc7948e17e9..64aaaff248a 100644 --- a/src/main/ai-vault/session-scanner-incremental-fixtures.ts +++ b/src/main/ai-vault/session-scanner-incremental-fixtures.ts @@ -169,6 +169,13 @@ export function piFixture(): IncrementalAgentFixture { }) ], appendLines: [ + JSON.stringify({ + type: 'session_info', + id: 'pi-name-1', + parentId: null, + name: 'Pi renamed session', + timestamp: '2026-05-01T10:00:20.000Z' + }), JSON.stringify({ type: 'model_change', modelId: 'pi-2', @@ -192,8 +199,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 header title outranks the first -// user prompt, matching a real `/rename`. +// branch and that model-key difference. The appended title change mirrors a +// persisted `/rename` after the initial scan. export function ompFixture(): IncrementalAgentFixture { return { agent: 'omp', @@ -204,7 +211,8 @@ export function ompFixture(): IncrementalAgentFixture { version: 3, id: 'cccccccc-dddd-4eee-8fff-000000000000', cwd: '/repo/app', - title: 'OMP session title', + title: 'OMP automatic title', + titleSource: 'auto', timestamp: '2026-05-01T10:00:00.000Z' }), JSON.stringify({ @@ -219,6 +227,15 @@ export function ompFixture(): IncrementalAgentFixture { }) ], appendLines: [ + JSON.stringify({ + type: 'title_change', + id: 'omp-title-1', + parentId: null, + title: 'OMP renamed session', + previousTitle: 'OMP automatic title', + source: 'user', + timestamp: '2026-05-01T10:00:30.000Z' + }), JSON.stringify({ type: 'message', message: { diff --git a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts index edb5b1db20a..cea20dd24ce 100644 --- a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts +++ b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts @@ -8,7 +8,11 @@ import { codexFixture, codexWorkerFixtureLines } from './session-scanner-codex-fixtures' -import { allIncrementalAgentFixtures } from './session-scanner-incremental-fixtures' +import { + allIncrementalAgentFixtures, + ompFixture, + piFixture +} from './session-scanner-incremental-fixtures' import { createSessionParseStats, parseAgentSessionFileCached, @@ -18,6 +22,11 @@ import type { SessionFileCandidate } from './session-scanner-types' let tempRoots: string[] = [] +const titleMetadataFixtures = [ + { name: 'pi', fixture: piFixture(), expectedTitle: 'Pi renamed session' }, + { name: 'omp', fixture: ompFixture(), expectedTitle: 'OMP renamed session' } +] + beforeEach(() => { resetSessionParseCacheForTests() }) @@ -136,6 +145,78 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', }) }) +describe.each(titleMetadataFixtures)('incremental title metadata: $name', (entry) => { + it('adopts a harness rename appended after the cached scan', async () => { + const root = await makeTempDir() + const path = join(root, entry.fixture.fileName) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${entry.fixture.seedLines.join('\n')}\n`) + await parseAgentSessionFileCached( + await candidateFor(entry.fixture.agent, path), + process.platform + ) + + await appendFile(path, `${entry.fixture.appendLines.join('\n')}\n`) + const grownCandidate = await candidateFor(entry.fixture.agent, path) + const stats = createSessionParseStats() + const incremental = await parseAgentSessionFileCached(grownCandidate, process.platform, stats) + expect(stats.incremental).toBe(1) + expect(incremental?.title).toBe(entry.expectedTitle) + expect(incremental).toEqual(await parseAgentSessionFile(grownCandidate, process.platform)) + }) +}) + +describe('incremental OMP title precedence', () => { + it('keeps a user title ahead of an appended automatic title', async () => { + const root = await makeTempDir() + const path = join(root, ompFixture().fileName) + await writeFile( + path, + `${[ + JSON.stringify({ + type: 'title', + v: 1, + title: 'Persisted user title', + source: 'user', + updatedAt: '2026-05-01T10:00:00.000Z', + pad: '' + }), + JSON.stringify({ + type: 'session', + id: 'omp-session', + cwd: '/repo/app', + title: 'Stale source-less title', + timestamp: '2026-05-01T10:00:00.000Z' + }), + JSON.stringify({ + type: 'message', + message: { role: 'user', content: 'first prompt' }, + timestamp: '2026-05-01T10:00:05.000Z' + }) + ].join('\n')}\n` + ) + await parseAgentSessionFileCached(await candidateFor('omp', path), process.platform) + + await appendFile( + path, + `${JSON.stringify({ + type: 'title_change', + title: 'Later automatic title', + source: 'auto', + timestamp: '2026-05-01T10:00:30.000Z' + })}\n` + ) + const stats = createSessionParseStats() + const incremental = await parseAgentSessionFileCached( + await candidateFor('omp', path), + process.platform, + stats + ) + expect(stats.incremental).toBe(1) + expect(incremental?.title).toBe('Persisted user title') + }) +}) + describe('codex-specific resume behavior', () => { it('keeps rejecting worker sessions across incremental appends', async () => { const root = await makeTempDir()