fix(ai-vault): scope graph session title metadata

This commit is contained in:
Brennan Benson
2026-08-23 18:23:34 -07:00
parent 6323b0a609
commit aa8d44ce59
5 changed files with 164 additions and 14 deletions
@@ -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
})
@@ -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
@@ -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<string, unknown>
): 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<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) ?? ''
)
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' }
}
@@ -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: {
@@ -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()