mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user