fix(session-history): list a chat once under its writer when pi and omp share a sessions folder (#33647)

This commit is contained in:
Kelvin Amoaba
2026-10-26 17:59:22 +00:00
committed by GitHub
parent 9aec987315
commit dc2f6fb7c8
14 changed files with 255 additions and 35 deletions
@@ -277,6 +277,16 @@ describe('validateAiVaultSessionDeleteTarget', () => {
expect(result.allowed).toBe(true)
})
it('allows an omp row the Pi walk labelled from under the Pi root', () => {
const result = validateAiVaultSessionDeleteTarget({
agent: 'omp',
filePath: join(PI_ROOT, '-repo-app', 'session-1.jsonl'),
executionHostId: 'local',
rootOptions: { piSessionsDir: PI_ROOT, ompSessionsDir: OMP_ROOT }
})
expect(result.allowed).toBe(true)
})
it('allows an uppercase extension since discovery folds extension case', () => {
const result = validateAiVaultSessionDeleteTarget({
agent: 'gemini',
+6 -3
View File
@@ -14,7 +14,11 @@ import {
type ExecutionHostId
} from '../../shared/execution-host'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import { AI_VAULT_AGENT_SOURCES, isDiscoverableSessionFile } from './session-scanner-agent-sources'
import {
AI_VAULT_AGENT_SOURCES,
isDiscoverableSessionFile,
sessionRowRootDirs
} from './session-scanner-agent-sources'
import { subagentTranscriptsDirFor } from './session-scanner-subagent-transcripts'
import type { AiVaultScanOptions } from './session-scanner-types'
@@ -57,8 +61,7 @@ export function validateAiVaultSessionDeleteTarget(
// Collapse traversal before textual containment checks.
const resolvedPath = resolve(filePath)
const source = AI_VAULT_AGENT_SOURCES[agent]
const roots = source
.rootDirs(args.rootOptions ?? {}, args.wslHomeDirs ?? [])
const roots = sessionRowRootDirs(agent, args.rootOptions ?? {}, args.wslHomeDirs ?? [])
// An empty root resolves to cwd and would silently allowlist it.
.filter((rootDir) => rootDir.trim().length > 0)
.map((rootDir) => resolve(rootDir))
@@ -20,7 +20,7 @@ import {
import { parsePersistedSidecar } from './session-sidecar-stat'
// Bump for row-layout or cached-semantic changes; mismatched databases are discarded.
export const SESSION_PARSE_CACHE_SCHEMA_VERSION = 7
export const SESSION_PARSE_CACHE_SCHEMA_VERSION = 8
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
@@ -20,7 +20,7 @@ import {
kiroTranscriptPathForMetadata
} from './session-scanner-kiro-parser'
import { OPENCLAW_AGENT_SOURCE } from './openclaw-session-layout'
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts'
import { isOmpConversationDirectory } from './session-scanner-omp-subagent-transcripts'
import {
claudeProjectsRootDirs,
codexHomeSessionsDir,
@@ -231,14 +231,15 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = {
'agent',
'sessions'
]),
extensions: ['.jsonl']
extensions: ['.jsonl'],
// OMP inherits PI_CODING_AGENT_DIR, so Pi's folder can hold OMP's task artifacts.
directoryPredicate: isOmpConversationDirectory
},
omp: {
rootDirs: (options, wslHomeDirs) =>
ompSessionsRootDirs({ ompSessionsDir: options.ompSessionsDir, wslHomeDirs }),
extensions: ['.jsonl'],
// Skip worker artifacts below workspace depth; OMP lists only top-level conversations (#9330).
directoryPredicate: (name, depth) => depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name)
directoryPredicate: isOmpConversationDirectory
},
'prime-agent': {
rootDirs: (options, wslHomeDirs) =>
@@ -301,4 +302,16 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = {
}
}
// The Pi walk labels the OMP transcripts it reads, so an OMP row can sit under Pi's roots too.
export function sessionRowRootDirs(
agent: AiVaultDeletableAgent,
options: AiVaultScanOptions,
wslHomeDirs: readonly string[]
): string[] {
const roots = AI_VAULT_AGENT_SOURCES[agent].rootDirs(options, wslHomeDirs)
return agent === 'omp'
? [...roots, ...AI_VAULT_AGENT_SOURCES.pi.rootDirs(options, wslHomeDirs)]
: roots
}
export { isDiscoverableSessionFile } from './session-file-discovery'
@@ -54,14 +54,28 @@ export function dropCodexHardlinkAliases(
return dedupeCodexRolloutFileAliases(candidates, CODEX_ALIAS_ACCESSORS)
}
// OMP inherits PI_CODING_AGENT_DIR; the Pi parse labels the OMP transcripts it reads, so read each once.
function dropOmpCandidatesListedByPi(
candidates: readonly SessionFileCandidate[]
): SessionFileCandidate[] {
const piPaths = new Set(
candidates.flatMap((candidate) => (candidate.agent === 'pi' ? [candidate.file.path] : []))
)
return candidates.filter(
(candidate) => candidate.agent !== 'omp' || !piPaths.has(candidate.file.path)
)
}
export async function sessionCandidatesFromDiscoveries(
discoveries: SessionFileDiscovery[],
options: AiVaultScanOptions
): Promise<SessionFileCandidate[]> {
const candidates = await dedupeCodexRolloutAliases(
discoveries
.flatMap((discovery) => discoveryFileCandidates(discovery, discovery.files, options))
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs),
dropOmpCandidatesListedByPi(
discoveries.flatMap((discovery) =>
discoveryFileCandidates(discovery, discovery.files, options)
)
).sort((left, right) => right.file.mtimeMs - left.file.mtimeMs),
CODEX_ALIAS_ACCESSORS,
(filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'),
options.signal
@@ -1,4 +1,5 @@
import { foldOmpTranscriptTitle, type OmpTranscriptTitle } from './session-scanner-omp-title'
import { isOmpWrittenRecord } from './session-scanner-omp-written-record'
import {
remoteSessionContentLines,
type RemoteSessionContent
@@ -221,12 +222,16 @@ function consumeMessageGraphRecordLine(state: MessageGraphParseState, line: stri
if (!record) {
return
}
if (accumulator.agent === 'pi' && isOmpWrittenRecord(record)) {
accumulator.agent = 'omp'
accumulator.fallbackTitle = accumulator.title
accumulator.title = null
}
updateTimeline(accumulator, extractString(record.timestamp))
if (accumulator.agent === 'omp') {
state.ompTitle = foldOmpTranscriptTitle(state.ompTitle, record)
if (state.ompTitle) {
accumulator.title = state.ompTitle.title
}
// Fold before the writer is known: an OMP title can precede the record that proves OMP wrote it.
state.ompTitle = foldOmpTranscriptTitle(state.ompTitle, record)
if (accumulator.agent === 'omp' && state.ompTitle) {
accumulator.title = state.ompTitle.title
}
if (record.type === 'session') {
const sessionId = extractString(record.id)
@@ -277,8 +282,7 @@ export function createMessageGraphSessionResumeState(
}),
ompTitle: null
})
// Only OMP has this task-artifact layout; avoid directory reads for the other graph agents.
return agent === 'omp' ? withOmpSubagentTranscriptCount(state, file.path) : state
return withOmpSubagentTranscriptCount(state, file.path)
}
function createMessageGraphResumeState(state: MessageGraphParseState): ResumableSessionParseState {
@@ -4,8 +4,8 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers'
import {
OMP_SESSION_ARTIFACT_DIR_PATTERN,
countOmpSubagentTranscripts,
isOmpConversationDirectory,
partitionOmpSubagentTranscriptPaths
} from './session-scanner-omp-subagent-transcripts'
@@ -24,11 +24,12 @@ async function makeWorkspaceDir(): Promise<string> {
return root
}
describe('OMP_SESSION_ARTIFACT_DIR_PATTERN', () => {
it('matches session artifact dir names and nothing else in the layout', () => {
expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test(SESSION_STEM)).toBe(true)
expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test('AuthAndPreflight')).toBe(false)
expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test('local')).toBe(false)
describe('isOmpConversationDirectory', () => {
it('skips session artifact dirs below the workspace and nothing else in the layout', () => {
expect(isOmpConversationDirectory(SESSION_STEM, 0)).toBe(true)
expect(isOmpConversationDirectory(SESSION_STEM, 1)).toBe(false)
expect(isOmpConversationDirectory('AuthAndPreflight', 1)).toBe(true)
expect(isOmpConversationDirectory('local', 1)).toBe(true)
// Remote partitioning has no depth context, so encoded workspace names must not match session stems.
for (const workspaceDirName of [
`-${SESSION_STEM}`,
@@ -36,7 +37,7 @@ describe('OMP_SESSION_ARTIFACT_DIR_PATTERN', () => {
`--${SESSION_STEM}--`,
'home-app-85dfa2f063812c58976deb581167a634a4'
]) {
expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test(workspaceDirName)).toBe(false)
expect(isOmpConversationDirectory(workspaceDirName, 1)).toBe(true)
}
})
})
@@ -7,7 +7,12 @@ import type { ResumableSessionParseState } from './session-scanner-types'
// Infer lineage from stamp_UUID artifact directories: task transcripts are label-named and omit parentSession (#9330).
const OMP_SESSION_DIR_NAME = String.raw`\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`
export const OMP_SESSION_ARTIFACT_DIR_PATTERN = new RegExp(`^${OMP_SESSION_DIR_NAME}$`, 'i')
const OMP_SESSION_ARTIFACT_DIR_PATTERN = new RegExp(`^${OMP_SESSION_DIR_NAME}$`, 'i')
/** Walk workspace buckets but not task artifact dirs; OMP lists only top-level conversations (#9330). */
export function isOmpConversationDirectory(name: string, depth: number): boolean {
return depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name)
}
// Remote counts include only direct artifact children, matching local readdir; nested grandchildren remain excluded.
const OMP_SUBAGENT_SUBTREE_PATTERN = new RegExp(String.raw`[\\/]${OMP_SESSION_DIR_NAME}[\\/]`, 'i')
@@ -60,7 +65,7 @@ export function partitionOmpSubagentTranscriptPaths(
return { sessionFilePaths, subagentTranscriptCounts }
}
/** Count on the transcript-owning host only; remote walks supply their own counts. */
/** Count OMP rows on the transcript-owning host only; remote walks supply their own counts. */
export function withOmpSubagentTranscriptCount(
state: ResumableSessionParseState,
transcriptFilePath: string
@@ -74,7 +79,7 @@ export function withOmpSubagentTranscriptCount(
const session = await state.finalize(platform, options)
const ownsTranscriptDisk =
!options?.executionHostId || options.executionHostId === LOCAL_EXECUTION_HOST_ID
if (!session || !ownsTranscriptDisk) {
if (session?.agent !== 'omp' || !ownsTranscriptDisk) {
return session
}
return {
@@ -0,0 +1,12 @@
import { asRecord, extractString } from './session-scanner-values'
// OMP inherits PI_CODING_AGENT_DIR, so Pi's folder can hold OMP transcripts; Pi never writes these.
export function isOmpWrittenRecord(record: Record<string, unknown>): boolean {
return (
(record.type === 'title' && record.v === 1) ||
asRecord(record.runtime) !== null ||
(record.type === 'model_change' &&
record.modelId === undefined &&
extractString(record.model) !== null)
)
}
@@ -255,9 +255,9 @@ async function reuseCachedSession(
// Zero-turn parents may never change again; recount late subagent transcripts outside the cached file signature.
if (session && session.messageCount === 0) {
const subagentTranscriptCount =
candidate.agent === 'claude'
session.agent === 'claude'
? await countSubagentTranscripts(candidate.file.path)
: candidate.agent === 'omp'
: session.agent === 'omp'
? await countOmpSubagentTranscripts(candidate.file.path)
: null
if (
@@ -0,0 +1,150 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { scanAiVaultSessions } from './session-scanner'
import { isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures'
const OMP_STEM = '2026-05-01T10-00-00-000Z_0199a000-0000-7000-8000-000000000002'
const runtime = { version: '18.12.2' }
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
// Shapes follow what Pi 0.84 and OMP 18.12 write: OMP adds a title slot and runtime stamps.
async function writeSharedRootTranscripts(sessionsDir: string): Promise<void> {
const piDir = join(sessionsDir, '--repo-app--')
await writeJsonlFile(join(piDir, '2026-05-01T09-00-00-000Z_pi.jsonl'), [
{
type: 'session',
version: 3,
id: '0199a000-0000-7000-8000-000000000001',
timestamp: '2026-05-01T09:00:00.000Z',
cwd: '/repo/app'
},
{
type: 'model_change',
id: 'a1',
parentId: null,
timestamp: '2026-05-01T09:00:00.100Z',
provider: 'anthropic',
modelId: 'claude-sonnet-4-5'
},
{
type: 'message',
id: 'a2',
parentId: 'a1',
timestamp: '2026-05-01T09:00:01.000Z',
message: { role: 'user', content: [{ type: 'text', text: 'Pi prompt' }] }
}
])
const ompDir = join(sessionsDir, '-repo-app')
await writeJsonlFile(join(ompDir, `${OMP_STEM}.jsonl`), [
{
type: 'title',
v: 1,
title: 'OMP named chat',
source: 'user',
updatedAt: '2026-05-01T10:00:02.000Z'
},
{
type: 'session',
version: 3,
id: '0199a000-0000-7000-8000-000000000002',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: '/repo/app'
},
{
type: 'model_change',
id: 'b1',
parentId: null,
timestamp: '2026-05-01T10:00:00.100Z',
model: 'anthropic/claude-sonnet-4-5',
runtime
},
{
type: 'message',
id: 'b2',
parentId: 'b1',
timestamp: '2026-05-01T10:00:01.000Z',
message: { role: 'user', content: [{ type: 'text', text: 'OMP prompt' }] },
runtime
}
])
// Older OMP builds wrote no title slot or runtime stamps, only the header name and model_change.model.
await writeJsonlFile(join(ompDir, '2026-04-01T10-00-00-000Z_legacy.jsonl'), [
{
type: 'session',
version: 3,
id: '0199a000-0000-7000-8000-000000000004',
title: 'Legacy OMP name',
timestamp: '2026-04-01T10:00:00.000Z',
cwd: '/repo/app'
},
{ type: 'model_change', timestamp: '2026-04-01T10:00:00.100Z', model: 'gpt-5.4-mini' },
{
type: 'message',
timestamp: '2026-04-01T10:00:01.000Z',
message: { role: 'user', content: [{ type: 'text', text: 'Legacy prompt' }] }
}
])
await writeJsonlFile(join(ompDir, OMP_STEM, 'Worker.jsonl'), [
{
type: 'session',
version: 3,
id: '0199a000-0000-7000-8000-000000000003',
timestamp: '2026-05-01T10:00:05.000Z',
cwd: '/repo/app'
},
{
type: 'message',
id: 'c1',
parentId: null,
timestamp: '2026-05-01T10:00:06.000Z',
message: { role: 'user', content: [{ type: 'text', text: 'Worker task' }] },
runtime
}
])
}
describe('scanAiVaultSessions with overlapping Pi and OMP roots', () => {
const sameFolder = (sessionsDir: string): string => sessionsDir
it.each([
{ layout: 'the same folder', piRoot: sameFolder, scope: {} },
// PI_CODING_AGENT_DIR=<dir>: Pi walks <dir>, OMP walks <dir>/sessions.
{ layout: 'a parent folder', piRoot: (dir: string) => join(dir, '..'), scope: {} },
// A one-row cap sends the rest through scope recovery: Pi's buckets and OMP's walk.
{
layout: 'the same folder for a workspace',
piRoot: sameFolder,
scope: { scopePaths: ['/repo/app'], limit: 1 }
}
])('lists each transcript once under its writer when Pi scans $layout', async (testCase) => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-pi-omp-shared-'))
tempRoots.push(root)
const sessionsDir = join(root, 'agent', 'sessions')
await writeSharedRootTranscripts(sessionsDir)
const result = await scanAiVaultSessions({
...isolatedScanRoots(root),
...testCase.scope,
piSessionsDir: testCase.piRoot(sessionsDir),
ompSessionsDir: sessionsDir,
platform: 'linux'
})
expect(
result.sessions
.map((session) => [session.agent, session.title, session.subagentTranscriptCount])
.sort()
).toEqual([
['omp', 'Legacy OMP name', 0],
['omp', 'OMP named chat', 1],
['pi', 'Pi prompt', 0]
])
})
})
@@ -77,7 +77,14 @@ export async function scanInScopeSessions(args: ScopeScanArgs): Promise<AiVaultS
}
args.inventory.length = 0
noteAiVaultScanWorkChanged(args.options.signal)
const walked = await walkInScopeSessions(args, scopePaths).finally(clearSessionScopeCwdKnowledge)
// OMP walks Pi's buckets when it inherits PI_CODING_AGENT_DIR; the Pi parse already labels them.
const alreadyParsedFilePaths = new Set(args.alreadyParsedFilePaths)
for (const candidate of candidates) {
alreadyParsedFilePaths.add(candidate.file.path)
}
const walked = await walkInScopeSessions({ ...args, alreadyParsedFilePaths }, scopePaths).finally(
clearSessionScopeCwdKnowledge
)
if (candidates.length === 0) {
return walked
}
+3 -2
View File
@@ -1,7 +1,8 @@
import { resolve } from 'node:path'
import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list'
import { listAiVaultSubagentSessionsInBackground } from '../ai-vault/session-scanner-background'
import { claudeProjectsRootDirs, ompSessionsRootDirs } from '../ai-vault/session-scanner-roots'
import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-roots'
import { sessionRowRootDirs } from '../ai-vault/session-scanner-agent-sources'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type {
@@ -32,7 +33,7 @@ export async function listAiVaultSubagentSessions(
const roots =
args.agent === 'claude'
? claudeProjectsRootDirs({ wslHomeDirs })
: ompSessionsRootDirs({ wslHomeDirs })
: sessionRowRootDirs('omp', {}, wslHomeDirs)
if (!roots.some((root) => isPathInsideOrEqual(resolve(root), parentFilePath))) {
return { sessions: [], issues: [] }
}
@@ -1,6 +1,6 @@
import { win32 } from 'node:path'
import { GROK_CHAT_HISTORY_FILE } from '../../shared/grok-session-paths'
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from '../ai-vault/session-scanner-omp-subagent-transcripts'
import { isOmpConversationDirectory } from '../ai-vault/session-scanner-omp-subagent-transcripts'
export type SessionIdLookupAgent = 'claude' | 'codex' | 'grok' | 'omp'
@@ -34,6 +34,6 @@ export const SESSION_FILE_ID_LAYOUTS: Record<SessionIdLookupAgent, SessionFileId
return name === id || name.endsWith(`_${id}`)
},
// Prune subagent artifact directories: their suffix can match the parent ID and steal its transcript.
directoryPredicate: (name, depth) => depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name)
directoryPredicate: isOmpConversationDirectory
}
}