diff --git a/src/main/ai-vault/ai-vault-resume-command.test.ts b/src/main/ai-vault/ai-vault-resume-command.test.ts new file mode 100644 index 00000000000..805ee1c76ff --- /dev/null +++ b/src/main/ai-vault/ai-vault-resume-command.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { buildAiVaultResumeCommand } from '../../shared/ai-vault-types' + +describe('buildAiVaultResumeCommand', () => { + it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: 'C:\\Users\\Ada Lovelace\\repo', + platform: 'win32' + }) + ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""') + + expect( + buildAiVaultResumeCommand({ + agent: 'omp', + sessionId: 'omp session', + cwd: 'C:\\Users\\Ada Lovelace\\repo', + platform: 'win32' + }) + ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && omp --resume ""omp session"""') + }) + + it('carries non-default Codex homes in copied resume commands', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: '/repo/app', + platform: 'darwin', + codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' + }) + ).toBe( + "cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'" + ) + + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: 'C:\\Users\\Ada Lovelace\\repo', + platform: 'win32', + codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' + }) + ).toBe( + 'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""' + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index adfb2aa86db..a2a5d23acc4 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -67,6 +67,8 @@ export async function parseAgentSessionFile( return parseMessageGraphSessionFile('openclaw', candidate.file, platform) case 'pi': return parseMessageGraphSessionFile('pi', candidate.file, platform) + case 'omp': + return parseMessageGraphSessionFile('omp', candidate.file, platform) case 'droid': return parseDroidSessionFile(candidate.file, platform) case 'devin': diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 6f99151f059..069bcc96fd8 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -117,6 +117,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { openclawStateDir: join(root, 'openclaw-state'), openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), piSessionsDir: join(root, 'pi-sessions'), + ompSessionsDir: join(root, 'omp-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions'), diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 612e5690146..08fe55cf9b6 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -155,7 +155,7 @@ export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): str } export async function parseMessageGraphSessionFile( - agent: 'openclaw' | 'pi', + agent: 'openclaw' | 'pi' | 'omp', file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { @@ -181,10 +181,12 @@ export async function parseMessageGraphSessionFile( accumulator.sessionId = sessionId } accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd + accumulator.title ??= normalizeTitleText(extractString(record.title) ?? '') continue } if (record.type === 'model_change') { - accumulator.model = extractString(record.modelId) ?? accumulator.model + accumulator.model = + extractString(record.model) ?? extractString(record.modelId) ?? accumulator.model continue } if (record.type !== 'message') { diff --git a/src/main/ai-vault/session-scanner-grok-envelope.test.ts b/src/main/ai-vault/session-scanner-grok-envelope.test.ts new file mode 100644 index 00000000000..4c3cea1ceed --- /dev/null +++ b/src/main/ai-vault/session-scanner-grok-envelope.test.ts @@ -0,0 +1,85 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' + +let tempRoots: string[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function isolatedGrokScanRoots(root: string) { + return { + claudeProjectsDir: join(root, 'claude-projects'), + codexSessionsDir: join(root, 'codex-sessions'), + geminiSessionsDir: join(root, 'gemini-sessions'), + copilotSessionsDir: join(root, 'copilot-sessions'), + cursorProjectsDir: join(root, 'cursor-projects'), + opencodeStorageDir: join(root, 'opencode-storage'), + opencodeDbPaths: [] as readonly string[], + grokSessionsDir: join(root, 'grok-sessions'), + devinTranscriptsDir: join(root, 'devin-transcripts'), + hermesSessionsDir: join(root, 'hermes-sessions'), + rovoSessionsDir: join(root, 'rovo-sessions'), + openclawStateDir: join(root, 'openclaw-state'), + openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), + piSessionsDir: join(root, 'pi-sessions'), + ompSessionsDir: join(root, 'omp-sessions'), + droidSessionsDir: join(root, 'droid-sessions'), + droidProjectsDir: join(root, 'droid-projects'), + kimiSessionsDir: join(root, 'kimi-sessions') + } +} + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +describe('scanAiVaultSessions Grok envelopes', () => { + it('strips newline-heavy Grok user_query envelopes without regex matching', async () => { + const matchSpy = vi.spyOn(String.prototype, 'match') + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-grok-large-')) + tempRoots.push(root) + const roots = isolatedGrokScanRoots(root) + const sessionDir = join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'large-session') + const requestText = 'Grok large title\n'.repeat(300) + await mkdir(sessionDir, { recursive: true }) + await writeFile( + join(sessionDir, 'summary.json'), + JSON.stringify({ + info: { id: 'large-session', cwd: '/tmp/grok' }, + created_at: '2026-05-01T10:04:00.000Z' + }) + ) + await writeFile( + join(sessionDir, 'chat_history.jsonl'), + jsonLines([ + { + type: 'user', + content: `context\n${requestText}` + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'darwin', + limit: 5 + }) + + expect(result.issues).toEqual([]) + expect(result.sessions[0]?.title).toContain('Grok large title') + expect(result.sessions[0]?.title).not.toContain('USER_QUERY') + const usedGrokWrapperMatch = matchSpy.mock.calls.some( + ([pattern]) => + pattern instanceof RegExp && + pattern.source.includes('') && + pattern.source.includes('[\\s\\S]') + ) + expect(usedGrokWrapperMatch).toBe(false) + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index 4ae4ea22284..8e36d982e55 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -34,6 +34,7 @@ function isolatedScanRoots(root: string) { openclawStateDir: join(root, 'openclaw-state'), openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), piSessionsDir: join(root, 'pi-sessions'), + ompSessionsDir: join(root, 'omp-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions') diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index b4e376e606d..1651567c491 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -28,6 +28,7 @@ const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedi const PI_SESSIONS_DIR = normalizePiSessionsDir( process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions') ) +const OMP_SESSIONS_DIR = normalizePiSessionsDir(join(homedir(), '.omp', 'agent', 'sessions')) // Why: Devin ATIF transcripts are stored under /transcripts. const DEVIN_TRANSCRIPTS_DIR = join( process.env.DEVIN_HOME?.trim() || join(homedir(), '.local', 'share', 'devin', 'cli'), @@ -114,7 +115,8 @@ function standardDiscoveries( ...devinDiscoveries(options, wslHomeDirs, limit, issues), ...hermesDiscoveries(options, wslHomeDirs, limit, issues), ...rovoDiscoveries(options, wslHomeDirs, limit, issues), - ...piDiscoveries(options, wslHomeDirs, limit, issues) + ...piDiscoveries(options, wslHomeDirs, limit, issues), + ...ompDiscoveries(options, wslHomeDirs, limit, issues) ] } @@ -234,6 +236,21 @@ function piDiscoveries( ) } +function ompDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + return sessionRootDirs(options.ompSessionsDir ?? OMP_SESSIONS_DIR, wslHomeDirs, [ + '.omp', + 'agent', + 'sessions' + ]).map((rootDir) => + discoverFiles({ rootDir, limit, agent: 'omp', issues, extensions: ['.jsonl'] }) + ) +} + function openClawDiscovery( options: AiVaultScanOptions, wslHomeDirs: readonly string[], diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index fc7cf389b7d..9722da61b9b 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -24,6 +24,7 @@ export type AiVaultScanOptions = { openclawStateDir?: string openclawLegacyStateDir?: string piSessionsDir?: string + ompSessionsDir?: string droidSessionsDir?: string droidProjectsDir?: string kimiSessionsDir?: string diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index a4c5cf6540f..25262548626 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -1,14 +1,13 @@ import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types' +import { afterEach, describe, expect, it } from 'vitest' +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { scanAiVaultSessions } from './session-scanner' let tempRoots: string[] = [] afterEach(async () => { - vi.restoreAllMocks() await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) tempRoots = [] }) @@ -31,6 +30,7 @@ function isolatedScanRoots(root: string) { openclawStateDir: join(root, 'openclaw-state'), openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), piSessionsDir: join(root, 'pi-sessions'), + ompSessionsDir: join(root, 'omp-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions') @@ -611,6 +611,42 @@ describe('scanAiVaultSessions', () => { ]) ) + await mkdir(join(roots.ompSessionsDir, encodeURIComponent('/tmp/omp')), { recursive: true }) + await writeFile( + join(roots.ompSessionsDir, encodeURIComponent('/tmp/omp'), '2026-05-01_omp-session.jsonl'), + jsonLines([ + { + type: 'session', + id: 'omp-session', + title: 'OMP header title', + timestamp: '2026-05-01T10:08:30.000Z', + cwd: '/tmp/omp' + }, + { + type: 'model_change', + timestamp: '2026-05-01T10:08:31.000Z', + model: 'omp-model' + }, + { + type: 'message', + timestamp: '2026-05-01T10:08:32.000Z', + message: { + role: 'user', + content: [{ type: 'text', text: 'OMP first user fallback title' }] + } + }, + { + type: 'message', + timestamp: '2026-05-01T10:08:33.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Done' }], + usage: { input: 3, output: 5, cacheRead: 7, cacheWrite: 11 } + } + } + ]) + ) + await mkdir(roots.devinTranscriptsDir, { recursive: true }) await writeFile( join(roots.devinTranscriptsDir, 'devin-session.json'), @@ -746,92 +782,21 @@ describe('scanAiVaultSessions', () => { "cd '/tmp/openclaw' && openclaw --resume 'openclaw-session'" ) expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'") + expect(commandByAgent.get('omp')).toBe("cd '/tmp/omp' && omp --resume 'omp-session'") expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'") expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") expect(commandByAgent.get('kimi')).toBe( "cd '/tmp/kimi' && kimi --session 'session_kimi-session'" ) - }) - it('strips newline-heavy Grok user_query envelopes without regex matching', async () => { - const matchSpy = vi.spyOn(String.prototype, 'match') - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-grok-large-')) - tempRoots.push(root) - const roots = isolatedScanRoots(root) - const sessionDir = join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'large-session') - const requestText = 'Grok large title\n'.repeat(300) - await mkdir(sessionDir, { recursive: true }) - await writeFile( - join(sessionDir, 'summary.json'), - JSON.stringify({ - info: { id: 'large-session', cwd: '/tmp/grok' }, - created_at: '2026-05-01T10:04:00.000Z' - }) - ) - await writeFile( - join(sessionDir, 'chat_history.jsonl'), - jsonLines([ - { - type: 'user', - content: `context\n${requestText}` - } - ]) - ) - - const result = await scanAiVaultSessions({ - ...roots, - platform: 'darwin', - limit: 5 + const omp = result.sessions.find((session) => session.agent === 'omp') + expect(omp).toMatchObject({ + sessionId: 'omp-session', + title: 'OMP header title', + cwd: '/tmp/omp', + model: 'omp-model', + messageCount: 2, + totalTokens: 26 }) - - expect(result.issues).toEqual([]) - expect(result.sessions[0]?.title).toContain('Grok large title') - expect(result.sessions[0]?.title).not.toContain('USER_QUERY') - const usedGrokWrapperMatch = matchSpy.mock.calls.some( - ([pattern]) => - pattern instanceof RegExp && - pattern.source.includes('') && - pattern.source.includes('[\\s\\S]') - ) - expect(usedGrokWrapperMatch).toBe(false) - }) -}) - -describe('buildAiVaultResumeCommand', () => { - it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => { - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: 'C:\\Users\\Ada Lovelace\\repo', - platform: 'win32' - }) - ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""') - }) - - it('carries non-default Codex homes in copied resume commands', () => { - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: '/repo/app', - platform: 'darwin', - codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' - }) - ).toBe( - "cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'" - ) - - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: 'C:\\Users\\Ada Lovelace\\repo', - platform: 'win32', - codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' - }) - ).toBe( - 'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""' - ) }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts index 5acfd125b09..e4ea55e89b1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { AI_VAULT_AGENTS, type AiVaultSession } from '../../../../shared/ai-vault-types' +import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES, deriveAiVaultWorkspaceScopePaths, @@ -30,6 +31,11 @@ const baseSession: AiVaultSession = { } describe('filterAiVaultSessions', () => { + it('offers OMP in the agent filter because OMP is a supported TUI agent', () => { + expect(TUI_AGENT_CONFIG.omp).toBeDefined() + expect(AI_VAULT_AGENTS).toContain('omp') + }) + it('filters by workspace, agent, plain terms, repo: and path: operators', () => { const sessions: AiVaultSession[] = [ baseSession, diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 1d44f82a78f..41678497d68 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -9,6 +9,23 @@ vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' })) +vi.mock('@/lib/local-preflight-context', () => ({ + getLocalProjectExecutionRuntimeContext: ( + state: Pick, + _worktreeId: string | null | undefined, + platform: NodeJS.Platform + ) => { + if (platform !== 'win32') { + return undefined + } + const preference = state.projects[0]?.localWindowsRuntimePreference + if (preference?.kind !== 'wsl') { + return undefined + } + return { status: 'resolved', runtime: preference } + } +})) + type RuntimePreference = { kind: 'windows-host' } | { kind: 'wsl'; distro: string } function makeState(args: { @@ -63,6 +80,19 @@ describe('ai vault resume command runtime', () => { } }) ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && claude --resume ""session one"""') + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'omp', + sessionId: 'omp session', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && omp --resume ""omp session"""') }) it('uses POSIX command wrapping for Windows-path projects forced to WSL', () => { diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index b977489b4bc..ebb4b72ce8e 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -6,6 +6,7 @@ export const AI_VAULT_AGENTS = [ 'codex', 'hermes', 'pi', + 'omp', 'cursor', 'gemini', 'rovo', @@ -28,6 +29,7 @@ export const AI_VAULT_AGENT_LABELS = { codex: 'Codex', hermes: 'Hermes', pi: 'Pi', + omp: 'OMP', cursor: 'Cursor', gemini: 'Gemini', rovo: 'Rovo Dev', @@ -147,6 +149,9 @@ function buildAgentResumeInvocation( return `${baseCommand} --session ${sessionArg}` case 'copilot': return `${baseCommand} --resume=${sessionArg}` + // Why: OMP stores Pi-shaped transcripts, but its CLI resumes with + // `omp --resume ` rather than Pi's `--session` flag. + case 'omp': case 'claude': case 'cursor': case 'gemini':