From dc2f6fb7c846f68b49619144d25b4a9dc99ff84a Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:59:22 +0000 Subject: [PATCH] fix(session-history): list a chat once under its writer when pi and omp share a sessions folder (#33647) --- .../ai-vault/session-delete-target.test.ts | 10 ++ src/main/ai-vault/session-delete-target.ts | 9 +- .../ai-vault/session-parse-cache-database.ts | 2 +- .../ai-vault/session-scanner-agent-sources.ts | 21 ++- .../ai-vault/session-scanner-candidates.ts | 20 ++- .../ai-vault/session-scanner-graph-parsers.ts | 18 ++- ...n-scanner-omp-subagent-transcripts.test.ts | 15 +- ...ession-scanner-omp-subagent-transcripts.ts | 11 +- .../session-scanner-omp-written-record.ts | 12 ++ .../ai-vault/session-scanner-parse-cache.ts | 4 +- ...session-scanner-pi-omp-shared-root.test.ts | 150 ++++++++++++++++++ .../ai-vault/session-scanner-scope-walk.ts | 9 +- src/main/ipc/ai-vault-subagent-list.ts | 5 +- .../native-chat/session-file-id-layouts.ts | 4 +- 14 files changed, 255 insertions(+), 35 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-omp-written-record.ts create mode 100644 src/main/ai-vault/session-scanner-pi-omp-shared-root.test.ts diff --git a/src/main/ai-vault/session-delete-target.test.ts b/src/main/ai-vault/session-delete-target.test.ts index 3b5c4db67c5..86213557396 100644 --- a/src/main/ai-vault/session-delete-target.test.ts +++ b/src/main/ai-vault/session-delete-target.test.ts @@ -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', diff --git a/src/main/ai-vault/session-delete-target.ts b/src/main/ai-vault/session-delete-target.ts index 66a039ccae0..e1aa970cb3e 100644 --- a/src/main/ai-vault/session-delete-target.ts +++ b/src/main/ai-vault/session-delete-target.ts @@ -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)) diff --git a/src/main/ai-vault/session-parse-cache-database.ts b/src/main/ai-vault/session-parse-cache-database.ts index 02dc3ac5590..6ee3b7fac95 100644 --- a/src/main/ai-vault/session-parse-cache-database.ts +++ b/src/main/ai-vault/session-parse-cache-database.ts @@ -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); diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index 1f6a516613c..5ef959af616 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -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' diff --git a/src/main/ai-vault/session-scanner-candidates.ts b/src/main/ai-vault/session-scanner-candidates.ts index 5d145f57182..1bd23526406 100644 --- a/src/main/ai-vault/session-scanner-candidates.ts +++ b/src/main/ai-vault/session-scanner-candidates.ts @@ -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 { 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 diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 8d1dc8b60fa..57be235e0b3 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -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 { diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts index 554af8eea14..808ecce92bd 100644 --- a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts @@ -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 { 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) } }) }) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts index fce10694daa..9ea00d8dbb3 100644 --- a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts @@ -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 { diff --git a/src/main/ai-vault/session-scanner-omp-written-record.ts b/src/main/ai-vault/session-scanner-omp-written-record.ts new file mode 100644 index 00000000000..fed932ccfc4 --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-written-record.ts @@ -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): boolean { + return ( + (record.type === 'title' && record.v === 1) || + asRecord(record.runtime) !== null || + (record.type === 'model_change' && + record.modelId === undefined && + extractString(record.model) !== null) + ) +} diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index e7d19feec54..dddb918eb94 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -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 ( diff --git a/src/main/ai-vault/session-scanner-pi-omp-shared-root.test.ts b/src/main/ai-vault/session-scanner-pi-omp-shared-root.test.ts new file mode 100644 index 00000000000..36542699e9b --- /dev/null +++ b/src/main/ai-vault/session-scanner-pi-omp-shared-root.test.ts @@ -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 { + 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=: Pi walks , OMP walks /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] + ]) + }) +}) diff --git a/src/main/ai-vault/session-scanner-scope-walk.ts b/src/main/ai-vault/session-scanner-scope-walk.ts index 1989170f0d1..9ce9bb1c567 100644 --- a/src/main/ai-vault/session-scanner-scope-walk.ts +++ b/src/main/ai-vault/session-scanner-scope-walk.ts @@ -77,7 +77,14 @@ export async function scanInScopeSessions(args: ScopeScanArgs): Promise isPathInsideOrEqual(resolve(root), parentFilePath))) { return { sessions: [], issues: [] } } diff --git a/src/main/native-chat/session-file-id-layouts.ts b/src/main/native-chat/session-file-id-layouts.ts index 40e38d005b8..eb8be04ea14 100644 --- a/src/main/native-chat/session-file-id-layouts.ts +++ b/src/main/native-chat/session-file-id-layouts.ts @@ -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 depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name) + directoryPredicate: isOmpConversationDirectory } }