diff --git a/src/main/native-chat/session-file-resolver-wsl.test.ts b/src/main/native-chat/session-file-resolver-wsl.test.ts index aad7b2df745..360c3d8e556 100644 --- a/src/main/native-chat/session-file-resolver-wsl.test.ts +++ b/src/main/native-chat/session-file-resolver-wsl.test.ts @@ -87,32 +87,40 @@ describe('resolveSessionFilePath on a Windows host with WSL', () => { expect(resolved).toBe(ROLLOUT_UNC) }) - it('keeps an attested distro when another guest has the same transcript path', async () => { - READABLE_WSL_UNC_PATHS.add(DEBIAN_ROLLOUT_UNC) + it.each(['codex', 'omp'] as const)( + 'keeps an attested distro when another guest has the same transcript path (%s)', + async (agent) => { + READABLE_WSL_UNC_PATHS.add(DEBIAN_ROLLOUT_UNC) - const resolved = await resolveSessionFilePath('codex', 'wsl-sess', { - transcriptPath: ROLLOUT_LINUX, - wslDistro: 'Ubuntu', - codexSessionsDirs: [] - }) - - expect(resolved).toBe(ROLLOUT_UNC) - expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled() - expect(vi.mocked(getWslHomeAsync)).not.toHaveBeenCalled() - }) - - it('does not fall through to another guest when the attested path is missing', async () => { - READABLE_WSL_UNC_PATHS.delete(ROLLOUT_UNC) - READABLE_WSL_UNC_PATHS.add(DEBIAN_ROLLOUT_UNC) - - await expect( - resolveSessionFilePath('codex', 'wsl-sess', { + const resolved = await resolveSessionFilePath(agent, 'wsl-sess', { transcriptPath: ROLLOUT_LINUX, wslDistro: 'Ubuntu', codexSessionsDirs: [] }) - ).resolves.toBeNull() - }) + + expect(resolved).toBe(ROLLOUT_UNC) + expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled() + expect(vi.mocked(getWslHomeAsync)).not.toHaveBeenCalled() + } + ) + + it.each(['codex', 'omp'] as const)( + 'does not fall through to another guest when the attested path is missing (%s)', + async (agent) => { + READABLE_WSL_UNC_PATHS.delete(ROLLOUT_UNC) + READABLE_WSL_UNC_PATHS.add(DEBIAN_ROLLOUT_UNC) + scanned.hostRootHasRollout = true + + await expect( + resolveSessionFilePath(agent, 'wsl-sess', { + transcriptPath: ROLLOUT_LINUX, + wslDistro: 'Ubuntu', + codexSessionsDirs: [] + }) + ).resolves.toBeNull() + expect(scanned.dirs).toEqual([]) + } + ) it('does not return a UNC twin that no distro actually has', async () => { const resolved = await resolveSessionFilePath('codex', 'wsl-sess', { @@ -122,18 +130,21 @@ describe('resolveSessionFilePath on a Windows host with WSL', () => { expect(resolved).toBeNull() }) - it('does not fall back by id from an unattested guest hook path', async () => { - READABLE_WSL_UNC_PATHS.delete(ROLLOUT_UNC) - scanned.hostRootHasRollout = true + it.each(['codex', 'omp'] as const)( + 'does not fall back by id from an unattested guest hook path (%s)', + async (agent) => { + READABLE_WSL_UNC_PATHS.delete(ROLLOUT_UNC) + scanned.hostRootHasRollout = true - await expect( - resolveSessionFilePath('codex', 'wsl-sess', { - transcriptPath: ROLLOUT_LINUX, - codexSessionsDirs: ['C:\\host\\sessions'] - }) - ).resolves.toBeNull() - expect(scanned.dirs).toEqual([]) - }) + await expect( + resolveSessionFilePath(agent, 'wsl-sess', { + transcriptPath: ROLLOUT_LINUX, + codexSessionsDirs: ['C:\\host\\sessions'] + }) + ).resolves.toBeNull() + expect(scanned.dirs).toEqual([]) + } + ) it('does not fall back to a host id match for an unattested guest hook path', async () => { scanned.hostRootHasRollout = true diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index a32bc441667..aa2a2176d8e 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -210,11 +210,12 @@ describe('getPiAgentStatusExtensionSource', () => { expect( harness.fetchMock.mock.calls.map(([_event, init]) => JSON.parse(String(init?.body)).payload) ).toEqual([ - { hook_event_name: 'agent_start', session_id: 'omp-session-8' }, + { hook_event_name: 'agent_start', session_id: 'omp-session-8', session_file: '/tmp/s' }, { hook_event_name: 'before_agent_start', prompt: 'hi', - session_id: 'omp-session-9' + session_id: 'omp-session-9', + session_file: '/tmp/s' }, { hook_event_name: 'agent_end' } ]) @@ -240,7 +241,7 @@ describe('getPiAgentStatusExtensionSource', () => { let sessionId = 'omp-session-8' const sessionManager = { getSessionId: () => sessionId, - getSessionFile: () => '/tmp/session.jsonl' + getSessionFile: () => `/tmp/${sessionId}.jsonl` } await harness.callHook('agent_start', undefined, { sessionManager }) sessionId = 'omp-session-9' @@ -258,9 +259,9 @@ describe('getPiAgentStatusExtensionSource', () => { hook_event_name: 'message_end', role: 'assistant', text: 'done', - session_id: 'omp-session-9' + session_id: 'omp-session-9', + session_file: '/tmp/omp-session-9.jsonl' }) - expect(body.payload).not.toHaveProperty('session_file') expect(harness.fetchMock.mock.calls[1]?.[0]).toBe('http://127.0.0.1:4321/hook/omp') expect(harness.spawnMock).not.toHaveBeenCalled() finishDeliveries[1]?.() diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index ed2da5965ca..602dc8ae0e5 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -19,7 +19,7 @@ import { getPiAgentStatusWslCurlSourceLines } from './agent-status-wsl-curl-sour export const ORCA_PI_AGENT_STATUS_EXTENSION_FILE = 'orca-agent-status.ts' export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): string { - // Why: OMP needs the file only to reject ephemeral sessions; disclose just its resume id. + // OMP resumes by id; its persistent file path lets native chat skip discovery. const sessionMetadataSourceLines = kind !== 'omp' ? [ @@ -41,7 +41,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => unknown; getSessionFile?: () => unknown } } | null)?.sessionManager', ' const sessionId = sessionManager?.getSessionId?.()', ' const sessionFile = sessionManager?.getSessionFile?.()', - " runtimeOmpSessionMetadata = typeof sessionId === 'string' && sessionId && typeof sessionFile === 'string' && sessionFile ? { session_id: sessionId } : {}", + " runtimeOmpSessionMetadata = typeof sessionId === 'string' && sessionId && typeof sessionFile === 'string' && sessionFile ? { session_id: sessionId, session_file: sessionFile } : {}", '}', '', 'function getPostSessionMetadata(ompRuntime: boolean): Record {', @@ -69,7 +69,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => unknown; getSessionFile?: () => unknown } } | null)?.sessionManager', ' const sessionId = sessionManager?.getSessionId?.()', ' const sessionFile = sessionManager?.getSessionFile?.()', - " sessionMetadata = typeof sessionId === 'string' && sessionId && typeof sessionFile === 'string' && sessionFile ? { session_id: sessionId } : {}", + " sessionMetadata = typeof sessionId === 'string' && sessionId && typeof sessionFile === 'string' && sessionFile ? { session_id: sessionId, session_file: sessionFile } : {}", '}', '', 'function updateRuntimeOmpSessionMetadata(ctx: unknown): void {', diff --git a/src/main/pi/omp-session-status-owner.test.ts b/src/main/pi/omp-session-status-owner.test.ts index ea805d26aef..7d1c5dc42b4 100644 --- a/src/main/pi/omp-session-status-owner.test.ts +++ b/src/main/pi/omp-session-status-owner.test.ts @@ -50,6 +50,10 @@ describe('OMP session status ownership', () => { await settle() const bodies = harness.fetchMock.mock.calls.map((call) => JSON.parse(call[1].body)) expect(bodies.map((body) => body.payload.session_id)).toEqual(['root', 'root']) + expect(bodies.map((body) => body.payload.session_file)).toEqual([ + '/root.jsonl', + '/root.jsonl' + ]) expect(bodies.at(-1).payload.hook_event_name).toBe('agent_end') } ) diff --git a/src/main/pi/omp-transcript-metadata.test.ts b/src/main/pi/omp-transcript-metadata.test.ts new file mode 100644 index 00000000000..4c396555849 --- /dev/null +++ b/src/main/pi/omp-transcript-metadata.test.ts @@ -0,0 +1,116 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { extractAgentProviderSession, getAgentResumeArgv } from '../../shared/agent-session-resume' +import type * as SessionScannerDiscovery from '../ai-vault/session-scanner-discovery' +import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness' + +vi.mock('../ai-vault/session-scanner-discovery', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, walkSessionFiles: vi.fn(actual.walkSessionFiles) } +}) + +let root: string +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-omp-metadata-')) + vi.mocked(walkSessionFiles).mockClear() +}) +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe.each([ + ['dedicated OMP', { kind: 'omp' as const }], + ['Pi-routed OMP', { kind: 'pi' as const, title: 'omp' }] +])('%s transcript metadata', (_name, args) => { + it('resolves custom files without scanning and retains id-based resume across switches', async () => { + const harness = createAgentStatusExtensionHarness(args) + let id = '' + let file = '' + const sessionManager = { getSessionId: () => id, getSessionFile: () => file } + for (const next of ['first', 'second']) { + id = next + file = join(root, `${id}.jsonl`) + await writeFile(file, `${JSON.stringify({ type: 'session', id })}\n`) + await harness.callHook('agent_start', undefined, { + sessionManager + }) + await vi.waitFor(() => + expect(harness.fetchMock).toHaveBeenCalledTimes(id === 'first' ? 1 : 2) + ) + const payload = JSON.parse(String(harness.fetchMock.mock.lastCall?.[1]?.body)).payload + const session = extractAgentProviderSession('omp', payload) + expect(session).toEqual({ key: 'session_id', id, transcriptPath: file }) + expect(getAgentResumeArgv('omp', session!)).toEqual(['omp', '--resume', id]) + expect(getAgentResumeArgv('omp', session!, 'explicit.jsonl')).toEqual([ + 'omp', + '--resume', + 'explicit.jsonl' + ]) + await expect( + resolveSessionFilePath('omp', id, { + transcriptPath: session!.transcriptPath, + ompSessionsDir: join(root, 'unused-default') + }) + ).resolves.toBe(file) + } + expect(walkSessionFiles).not.toHaveBeenCalled() + expect(harness.fsMock.existsSync).not.toHaveBeenCalled() + }) + + it('publishes planned persistent paths, then resolves after delayed creation', async () => { + const harness = createAgentStatusExtensionHarness(args) + const file = join(root, 'delayed.jsonl') + await harness.callHook('agent_start', undefined, { + sessionManager: { getSessionId: () => 'delayed', getSessionFile: () => file } + }) + const payload = JSON.parse(String(harness.fetchMock.mock.lastCall?.[1]?.body)).payload + const session = extractAgentProviderSession('omp', payload)! + expect(session.transcriptPath).toBe(file) + const options = { transcriptPath: session.transcriptPath, ompSessionsDir: join(root, 'empty') } + await expect(resolveSessionFilePath('omp', session.id, options)).resolves.toBeNull() + expect(walkSessionFiles).toHaveBeenCalledTimes(1) + await writeFile(file, '{}\n') + vi.mocked(walkSessionFiles).mockClear() + await expect(resolveSessionFilePath('omp', session.id, options)).resolves.toBe(file) + expect(walkSessionFiles).not.toHaveBeenCalled() + const controller = new AbortController() + controller.abort() + await expect( + resolveSessionFilePath('omp', session.id, options, controller.signal) + ).rejects.toThrow() + expect(walkSessionFiles).not.toHaveBeenCalled() + }) + + it.each([undefined, '', 123])( + 'clears persistent metadata for ephemeral path %s', + async (file) => { + const harness = createAgentStatusExtensionHarness(args) + let sessionFile: unknown = join(root, 'p.jsonl') + const sessionManager = { getSessionId: () => 'same-owner', getSessionFile: () => sessionFile } + await harness.callHook('agent_start', undefined, { sessionManager }) + sessionFile = file + await harness.callHook('agent_end', undefined, { sessionManager }) + await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(2)) + const payload = JSON.parse(String(harness.fetchMock.mock.lastCall?.[1]?.body)).payload + expect(payload).toEqual({ hook_event_name: 'agent_end' }) + expect(extractAgentProviderSession('omp', payload)).toBeNull() + } + ) +}) + +it('retains id-based discovery with an old extension and rejects malformed optional paths', async () => { + const id = 'legacy-session' + const file = join(root, `${id}.jsonl`) + await writeFile(file, '{}\n') + for (const session_file of [undefined, '', 123, '/bad\npath.jsonl']) { + const session = extractAgentProviderSession('omp', { session_id: id, session_file })! + expect(session).toEqual({ key: 'session_id', id }) + expect(getAgentResumeArgv('omp', session)).toEqual(['omp', '--resume', id]) + await expect(resolveSessionFilePath('omp', id, { ompSessionsDir: root })).resolves.toBe(file) + } + expect(walkSessionFiles).toHaveBeenCalledTimes(4) +}) diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 9ae49955d84..b7e60f64dd3 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -224,10 +224,10 @@ export function extractAgentProviderSession( const id = readSessionId(payload, ['session_id', 'sessionId']) return id ? { key: 'session_id', id } : null } - // Why: OMP's managed extension reports the authoritative CLI resume id. + // OMP keeps id-based resume while optionally locating its native-chat transcript. case 'omp': { const id = readSessionId(payload, ['session_id']) - return id ? { key: 'session_id', id } : null + return id ? withTranscriptPath({ key: 'session_id', id }, payload, ['session_file']) : null } // Why: Copilot's hook `session_id` is also its `~/.copilot/session-state//` // directory name, so the same id is the CLI's resume locator. diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index b5bd27c0016..70fa92a92bf 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -20,10 +20,8 @@ export function isNativeChatSupportedAgent(agent: string | null | undefined): bo return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) } -/** Agents whose hook discloses no transcript path (`extractAgentProviderSession`), - * so native chat can only reach the session file by scanning a sessions root on - * a disk THIS process can read. Under Model-A SSH that disk is the wrong host, - * so the chat view must stay closed instead of loading forever. */ +/** Agents whose Model-A SSH transcript reader is not supported. A hook path alone + * does not establish owning-host reads, so OMP remains gated even with metadata. */ export function nativeChatRequiresLocalTranscript(agent: string | null | undefined): boolean { const transcriptAgent = resolveNativeChatTranscriptAgent(agent) return transcriptAgent === 'grok' || transcriptAgent === 'omp' diff --git a/tests/tools/omp-transcript-runtime-smoke.mjs b/tests/tools/omp-transcript-runtime-smoke.mjs new file mode 100644 index 00000000000..4c685f80221 --- /dev/null +++ b/tests/tools/omp-transcript-runtime-smoke.mjs @@ -0,0 +1,116 @@ +// Run with Bun and a read-only OMP checkout path as the first argument. +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { getPiAgentStatusExtensionSource } from '../../src/main/pi/agent-status-extension-source.ts' +import { + extractAgentProviderSession, + getAgentResumeArgv +} from '../../src/shared/agent-session-resume.ts' +import { readNativeChatTranscript } from '../../src/main/native-chat/transcript-reader.ts' + +const reference = process.argv[2] +assert.ok(reference, 'Pass the read-only oh-my-pi source checkout path') +const scratch = await mkdtemp(join(tmpdir(), 'orca-omp-transcript-')) +process.env.HOME = join(scratch, 'home') +process.env.USERPROFILE = process.env.HOME +process.env.OMP_CODING_AGENT_DIR = join(scratch, 'agent') +await mkdir(process.env.HOME, { recursive: true }) +const source = (path) => + pathToFileURL(join(resolve(reference), 'packages/coding-agent/src', path)).href +const { loadExtensions } = await import(source('extensibility/extensions/loader.ts')) +const { EventBus } = await import(source('utils/event-bus.ts')) +const { SessionManager } = await import(source('session/session-manager.ts')) +const posts = [] +const server = createServer(async (request, response) => { + let body = '' + for await (const chunk of request) { + body += chunk + } + posts.push(JSON.parse(body).payload) + response.writeHead(200).end() +}) +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) +const managers = [] +try { + process.env.ORCA_AGENT_HOOK_PORT = String(server.address().port) + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + delete process.env.ORCA_PI_STATUS_OWNED + process.title = 'omp' + for (const kind of ['omp', 'pi']) { + process.env.ORCA_PANE_KEY = `transcript-${kind}` + process.env.ORCA_AGENT_LAUNCH_TOKEN = `transcript-${kind}` + const extensionPath = join(scratch, `${kind}-agent-status.ts`) + await writeFile(extensionPath, getPiAgentStatusExtensionSource(kind)) + const load = async () => { + const result = await loadExtensions([extensionPath], scratch, new EventBus()) + assert.deepEqual(result.errors, []) + return result.extensions[0] + } + const root = SessionManager.create(scratch, join(scratch, `${kind}-custom-sessions`)) + const child = SessionManager.create(scratch, join(scratch, `${kind}-child-sessions`)) + managers.push(root, child) + const emit = async (extension, type, manager) => { + for (const handler of extension.handlers.get(type) ?? []) { + await handler({ type }, { sessionManager: manager, hasUI: false }) + } + await new Promise((resolve) => setTimeout(resolve, 60)) + } + const extension = await load() + await emit(extension, 'session_start', root) + for (const phase of ['initial', 'new']) { + if (phase === 'new') { + await root.newSession() + } + assert.equal(root.isSessionOnDisk(), false) + await emit(extension, 'agent_start', root) + const session = extractAgentProviderSession('omp', posts.at(-1)) + assert.equal(session.transcriptPath, root.getSessionFile()) + assert.deepEqual(getAgentResumeArgv('omp', session), ['omp', '--resume', root.getSessionId()]) + const options = { + transcriptPath: session.transcriptPath, + ompSessionsDir: join(scratch, 'unused') + } + assert.equal((await readNativeChatTranscript('omp', session.id, options)).notFound, true) + root.appendMessage({ + role: 'user', + content: `Transcript proof ${kind} ${phase}`, + timestamp: Date.now() + }) + await root.ensureOnDisk() + await root.flush() + const transcript = await readNativeChatTranscript('omp', session.id, options) + assert.ok('messages' in transcript, JSON.stringify(transcript)) + assert.equal(transcript.messages.length, 1) + assert.ok(JSON.stringify(transcript.messages).includes(`Transcript proof ${kind} ${phase}`)) + const beforeChild = posts.length + const childExtension = await load() + await emit(childExtension, 'session_start', child) + await emit(childExtension, 'agent_start', child) + await emit(childExtension, 'agent_end', child) + assert.equal(posts.length, beforeChild) + } + } + console.log( + JSON.stringify({ + platform: process.platform, + producers: ['omp', 'pi'], + reports: posts.length, + proof: + 'Actual OMP loader and persistent SessionManager, custom directory, lazy creation, new-session switch, HTTP metadata, Orca transcript reader, child suppression', + modelCalls: 0, + rendered: false + }) + ) +} finally { + for (const manager of managers) { + await manager.close() + } + server.closeAllConnections() + await new Promise((resolve) => server.close(resolve)) + await rm(scratch, { recursive: true, force: true }) +}