fix(ai-vault): prefer Pi/OMP harness names in session history (STA-2467)

Agent Session History ignored Pi `/name` and OMP `/rename` metadata, so
rows stayed on the first user prompt. Read session_info, title slots,
and title_change records in the shared graph parser and keep user titles
above automatic titles and first-prompt fallbacks.
This commit is contained in:
Brennan Benson
2026-08-20 18:42:07 -07:00
parent 012e9f410c
commit 6323b0a609
5 changed files with 237 additions and 10 deletions
@@ -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: {
@@ -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')
})
})
@@ -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<string, unknown>
): 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<string, unknown>
): { 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' }
}
@@ -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',
@@ -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.