From 9841f4f7903ba3fa280be2efb39d6836f7b3d935 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:29:55 -0700 Subject: [PATCH] fix(omp): keep child sessions from reporting into the parent pane (#20611) * fix(omp): fence pane status to the root session manager * test(omp): preserve root preview and recovery through child hooks * test(omp): exercise status ownership through actual runtime runner * fix(omp): honor runtime subagent provenance when available * test(omp): avoid writes to the read-only hook status view * fix(omp): preserve child status ownership provenance * fix(omp): normalize child transcript paths across platforms * fix(omp): clean status handler rebase * fix(omp): keep prefill inside session ownership fence --- .../omp-runtime-session-provenance.md | 23 ++ .../pi/agent-status-extension-source.test.ts | 29 ++- src/main/pi/agent-status-handler-source.ts | 26 +-- src/main/pi/agent-status-ui-prompt-source.ts | 6 +- .../pi/omp-session-status-owner-source.ts | 62 ++++++ src/main/pi/omp-session-status-owner.test.ts | 200 ++++++++++++++++++ src/main/pi/prefill-extension-source.ts | 8 +- .../omp-child-session-recovery.unit.test.ts | 124 +++++++++++ .../tools/omp-child-session-runtime-smoke.mjs | 146 +++++++++++++ 9 files changed, 591 insertions(+), 33 deletions(-) create mode 100644 docs/reference/omp-runtime-session-provenance.md create mode 100644 src/main/pi/omp-session-status-owner-source.ts create mode 100644 src/main/pi/omp-session-status-owner.test.ts create mode 100644 tests/e2e/omp-child-session-recovery.unit.test.ts create mode 100644 tests/tools/omp-child-session-runtime-smoke.mjs diff --git a/docs/reference/omp-runtime-session-provenance.md b/docs/reference/omp-runtime-session-provenance.md new file mode 100644 index 00000000000..8d6e33577d9 --- /dev/null +++ b/docs/reference/omp-runtime-session-provenance.md @@ -0,0 +1,23 @@ +# OMP runtime session provenance + +OMP computes whether a runtime session is a task child, but the released +`ExtensionContext` does not expose that value. The status extension therefore uses +the session manager's parent header and nested task transcript path only when a +root owner is already known. A nested transcript with no known owner remains +eligible because it may have been resumed directly as the pane's main session. + +The remaining child-first case is inherently ambiguous to Orca: task children and +resumed child transcripts have the same public session-manager shape. A complete +child-first fence requires OMP to expose its computed `agentKind` through +`ExtensionRunner.createContext`; until then the conservative fallback avoids +silencing valid resumed sessions. + +Older runtimes retain the manager-identity guard. That guard assumes the main +session reaches Orca's callback before any child. An earlier user extension can +initialize a child during session_start and violate that assumption. Keep the +ownership merge assessment conditional until the runtime API is available and the +combined flow is validated. Neither callback timeouts, UI presence, nor transcript +paths establish runtime ownership. + +The guard remains scoped to one pane and launch token. It does not define how +several independent SDK/ACP roots sharing one process and pane should be attributed. diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index 7df089f13b9..a32bc441667 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -195,15 +195,16 @@ describe('getPiAgentStatusExtensionSource', () => { it('tracks persistent OMP sessions and clears ephemeral session ids', async () => { const harness = createHarness({ kind: 'omp' }) let sessionId = 'omp-session-8' - const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => '/tmp/s' } + let sessionFile: string | undefined = '/tmp/s' + const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => sessionFile } await harness.callHook('agent_start', undefined, { sessionManager }) sessionId = 'omp-session-9' await harness.callHook('before_agent_start', { prompt: 'hi' }, { sessionManager }) await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(2)) - await harness.callHook('agent_end', undefined, { - sessionManager: { getSessionId: () => 'omp-ephemeral' } - }) + sessionId = 'omp-ephemeral' + sessionFile = undefined + await harness.callHook('agent_end', undefined, { sessionManager }) await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(3)) expect( @@ -236,21 +237,17 @@ describe('getPiAgentStatusExtensionSource', () => { ) }) - await harness.callHook('agent_start', undefined, { - sessionManager: { - getSessionId: () => 'omp-session-8', - getSessionFile: () => '/tmp/omp-session-8.jsonl' - } - }) + let sessionId = 'omp-session-8' + const sessionManager = { + getSessionId: () => sessionId, + getSessionFile: () => '/tmp/session.jsonl' + } + await harness.callHook('agent_start', undefined, { sessionManager }) + sessionId = 'omp-session-9' await harness.callHook( 'message_end', { message: { role: 'assistant', content: 'done' } }, - { - sessionManager: { - getSessionId: () => 'omp-session-9', - getSessionFile: () => '/tmp/omp-session-9.jsonl' - } - } + { sessionManager } ) await harness.callHook('message_end', { message: { role: 'user', content: 'next' } }, {}) diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index e6c2353aa0a..80081776111 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -1,5 +1,6 @@ import { getPiPrefillHandlerSourceLines } from './prefill-extension-source' import type { PiAgentKind } from '../../shared/pi-agent-kind' +import { getOmpSessionOwnerHandlerSourceLines } from './omp-session-status-owner-source' import { getPiAgentStatusUiPromptHandlerSourceLines } from './agent-status-ui-prompt-source' // Why: keep the generated handler registrations separate from hook transport; @@ -8,7 +9,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] const sessionStartHandler = kind !== 'omp' ? [ - " pi.on('session_start', (event, ctx) => {", + " onStatus('session_start', (event, ctx) => {", ' updateSessionMetadata(ctx)', ...(kind === 'pi' ? [' piUiPromptDepth = 0'] : []), ' // Why: /reload re-registers the active session, but it is not a', @@ -40,7 +41,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] kind === 'prime-agent' ? [] : [ - ` pi.on('tool_approval_requested', (event${ctxParam}) => {`, + ` onStatus('tool_approval_requested', (event${ctxParam}) => {`, ...captureSessionMetadata, ' if (!isOmpRuntime()) return', " post('tool_approval_requested', {", @@ -50,7 +51,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' })', ' })', '', - ` pi.on('tool_approval_resolved', (event${ctxParam}) => {`, + ` onStatus('tool_approval_resolved', (event${ctxParam}) => {`, ...captureSessionMetadata, ' if (!isOmpRuntime()) return', " post('tool_approval_resolved', {", @@ -129,14 +130,15 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' })' ] : []), + ...getOmpSessionOwnerHandlerSourceLines(), ...sessionStartHandler, - ...(kind === 'omp' ? getPiPrefillHandlerSourceLines('omp') : []), - ` pi.on('before_agent_start', (event${ctxParam}) => {`, + ...(kind === 'omp' ? getPiPrefillHandlerSourceLines('omp', true) : []), + ` onStatus('before_agent_start', (event${ctxParam}) => {`, ...captureSessionMetadata, " post('before_agent_start', { prompt: event.prompt ?? '' })", ' })', '', - ` pi.on('agent_start', (${bareCtxParams}) => {`, + ` onStatus('agent_start', (${bareCtxParams}) => {`, ...captureSessionMetadata, ' clearPendingAgentEndCheck()', ' agentEndReported = false', @@ -146,7 +148,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] " post('agent_start')", ' })', '', - ` pi.on('tool_execution_start', (event${ctxParam}) => {`, + ` onStatus('tool_execution_start', (event${ctxParam}) => {`, ...captureSessionMetadata, " post('tool_execution_start', {", ' tool_name: event.toolName,', @@ -154,7 +156,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' })', ' })', '', - ` pi.on('tool_call', (event${ctxParam}) => {`, + ` onStatus('tool_call', (event${ctxParam}) => {`, ...captureSessionMetadata, " post('tool_call', {", ' tool_name: event.toolName,', @@ -162,7 +164,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' })', ' })', '', - ` pi.on('tool_execution_end', (event${ctxParam}) => {`, + ` onStatus('tool_execution_end', (event${ctxParam}) => {`, ...captureSessionMetadata, " post('tool_execution_end', {", ' tool_name: event.toolName,', @@ -175,7 +177,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' // so the dashboard preview reflects the most recent reply even before', ' // agent_end fires. message_end is the right hook because pi guarantees', ' // it fires after the message is finalized (post-streaming).', - ` pi.on('message_end', (event${ctxParam}) => {`, + ` onStatus('message_end', (event${ctxParam}) => {`, ...captureSessionMetadata, " if (event.message?.role !== 'assistant') return", ' const text = extractAssistantText(event.message)', @@ -234,14 +236,14 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' agentEndIdleRecheckMs = Math.min(agentEndIdleRecheckMs * 2, AGENT_END_IDLE_RECHECK_MAX_MS)', ' }', '', - ` pi.on('agent_settled', (${bareCtxParams}) => {`, + ` onStatus('agent_settled', (${bareCtxParams}) => {`, ...captureSessionMetadata, ' agentSettledSupported = true', ' clearPendingAgentEndCheck()', ' postAgentEndOnce()', ' })', '', - " pi.on('agent_end', (event, ctx) => {", + " onStatus('agent_end', (event, ctx) => {", ...captureSessionMetadata, ' if (event?.willContinue === true) {', ' clearPendingAgentEndCheck()', diff --git a/src/main/pi/agent-status-ui-prompt-source.ts b/src/main/pi/agent-status-ui-prompt-source.ts index b641a5b3328..9a67f3a1bd6 100644 --- a/src/main/pi/agent-status-ui-prompt-source.ts +++ b/src/main/pi/agent-status-ui-prompt-source.ts @@ -7,14 +7,14 @@ export function getPiAgentStatusUiPromptHandlerSourceLines(kind: PiAgentKind): s } return [ - " pi.on('ui_prompt_start', () => {", + " onStatus('ui_prompt_start', () => {", ' if (isOmpRuntime()) return', ' piUiPromptDepth++', ' if (piUiPromptDepth > 1) return', " post('ui_prompt_start')", ' })', '', - " pi.on('ui_prompt_end', (_event, ctx) => {", + " onStatus('ui_prompt_end', (_event, ctx) => {", ' if (isOmpRuntime() || piUiPromptDepth === 0) return', ' piUiPromptDepth--', ' if (piUiPromptDepth > 0) return', @@ -31,7 +31,7 @@ export function getPiAgentStatusUiPromptHandlerSourceLines(kind: PiAgentKind): s " post('ui_prompt_end', { is_idle: isIdle })", ' })', '', - " pi.on('session_shutdown', () => {", + " onStatus('session_shutdown', () => {", ' resetPostQueue()', ' clearPendingAgentEndCheck()', ' if (isOmpRuntime()) return', diff --git a/src/main/pi/omp-session-status-owner-source.ts b/src/main/pi/omp-session-status-owner-source.ts new file mode 100644 index 00000000000..fe16868e2ca --- /dev/null +++ b/src/main/pi/omp-session-status-owner-source.ts @@ -0,0 +1,62 @@ +// OMP loads the same extension in each in-process task session. +export function getOmpSessionOwnerHandlerSourceLines(): string[] { + return [ + ' // SessionManager survives reload/new/resume; task children own a different instance.', + ' function sessionProvenance(ctx): { manager: unknown; id?: string; file?: string; parent?: string } | undefined {', + ' const manager = ctx?.sessionManager', + " if (!manager || typeof manager !== 'object') return undefined", + ' const id = typeof manager.getSessionId === "function" ? manager.getSessionId() : undefined', + ' const file = typeof manager.getSessionFile === "function" ? manager.getSessionFile() : undefined', + ' const header = typeof manager.getHeader === "function" ? manager.getHeader() : undefined', + ' return { manager, id, file, parent: typeof header?.parentSession === "string" ? header.parentSession : undefined }', + ' }', + '', + ' function normalizeSessionPath(file): string {', + ' const normalized = file.replace(/\\\\/g, "/")', + ' return /^[A-Za-z]:\\//.test(normalized) ? normalized.toLowerCase() : normalized', + ' }', + '', + ' function isNestedTaskTranscript(parentFile, candidateFile): boolean {', + ' if (typeof parentFile !== "string" || typeof candidateFile !== "string") return false', + ' const parent = normalizeSessionPath(parentFile)', + ' const candidate = normalizeSessionPath(candidateFile)', + ' const root = parent.endsWith(".jsonl") ? parent.slice(0, -6) : parent', + ' return candidate.startsWith(`${root}/`) && candidate !== parent', + ' }', + '', + ' function ownsSessionStatus(ctx): boolean {', + ' if (!isOmpRuntime()) return true', + ' // Newer OMP builds may expose this computed runtime provenance directly.', + ' if (ctx?.agentKind === "sub") return false', + ' const current = sessionProvenance(ctx)', + ' if (!current) return true', + ' // Keep ownership through module reload and shutdown while child sessions drain.', + " const key = Symbol.for('orca.omp.status-session-owners')", + ' let owners = Reflect.get(globalThis, key)', + ' if (!(owners instanceof Map)) {', + ' owners = new Map()', + ' Reflect.set(globalThis, key, owners)', + ' }', + ' const pane = JSON.stringify([process.env.ORCA_PANE_KEY, process.env.ORCA_AGENT_LAUNCH_TOKEN])', + ' const owner = owners.get(pane)', + ' if (owner) {', + ' if (owner.manager === current.manager) return true', + ' if (current.parent === owner.file || current.parent === owner.id) return false', + ' if (isNestedTaskTranscript(owner.file, current.file)) return false', + ' return false', + ' }', + ' owners.set(pane, current)', + ' return true', + ' }', + '', + ' function onStatus(name, handler): void {', + ' pi.on(name, (event, ctx) => {', + ' if (!ownsSessionStatus(ctx)) return', + ' return handler(event, ctx)', + ' })', + ' }', + '', + " onStatus('session_start', () => {})", + '' + ] +} diff --git a/src/main/pi/omp-session-status-owner.test.ts b/src/main/pi/omp-session-status-owner.test.ts new file mode 100644 index 00000000000..ea805d26aef --- /dev/null +++ b/src/main/pi/omp-session-status-owner.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' +import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness' + +const settle = async (): Promise => { + for (let i = 0; i < 10; i++) { + await Promise.resolve() + } +} + +describe('OMP session status ownership', () => { + it.each(['omp', 'pi'] as const)( + 'fences child callbacks before they change %s pane metadata', + async (kind) => { + const harness = createAgentStatusExtensionHarness({ kind, argv: ['bun', '/opt/omp/bin/omp'] }) + const root = { + sessionManager: { getSessionId: () => 'root', getSessionFile: () => '/root.jsonl' } + } + await harness.callHook('session_start', {}, root) + await settle() + harness.fetchMock.mockClear() + const rootHandlers = { ...harness.handlers } + harness.reload() + const child = { + sessionManager: { getSessionId: () => 'child', getSessionFile: () => '/child.jsonl' } + } + for (const name of [ + 'session_start', + 'before_agent_start', + 'agent_start', + 'tool_call', + 'tool_execution_start', + 'tool_execution_end', + 'tool_approval_requested', + 'tool_approval_resolved', + 'message_end', + 'agent_end', + 'agent_settled' + ]) { + await harness.callHook( + name, + { message: { role: 'assistant', content: 'child answer' } }, + child + ) + await settle() + } + expect(harness.fetchMock).not.toHaveBeenCalled() + await rootHandlers.agent_start({}, root) + await settle() + await rootHandlers.agent_end({}, root) + 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.at(-1).payload.hook_event_name).toBe('agent_end') + } + ) + + it('preserves a headless owner through reload, new, and resume', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + let sessionId = 'initial' + const root = { + hasUI: false, + sessionManager: { getSessionId: () => sessionId, getSessionFile: () => '/session.jsonl' } + } + await harness.callHook('session_start', {}, root) + for (const next of ['initial', 'new', 'resumed']) { + sessionId = next + harness.reload() + await harness.callHook('session_start', { reason: 'reload' }, root) + await harness.callHook('agent_start', {}, root) + await settle() + } + expect( + harness.fetchMock.mock.calls.map((call) => JSON.parse(call[1].body).payload.session_id) + ).toEqual(['initial', 'new', 'resumed']) + }) + it('uses a distinct ownership key when pane and launch change before callbacks', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const parent = { + sessionManager: { getSessionId: () => 'parent', getSessionFile: () => '/parent.jsonl' } + } + const separate = { + sessionManager: { getSessionId: () => 'separate', getSessionFile: () => '/separate.jsonl' } + } + await harness.callHook('session_start', {}, parent) + harness.processEnv.ORCA_PANE_KEY = 'pane-2' + harness.processEnv.ORCA_AGENT_LAUNCH_TOKEN = 'launch-2' + harness.reload() + await harness.callHook('agent_start', {}, separate) + await settle() + expect(JSON.parse(harness.fetchMock.mock.calls[0][1].body).payload.session_id).toBe('separate') + }) + + it('uses OMP parent metadata and nested task paths when the root already owns the pane', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const rootFile = '/sessions/root.jsonl' + const root = { + sessionManager: { + getSessionId: () => 'root', + getSessionFile: () => rootFile, + getHeader: () => ({ parentSession: undefined }) + } + } + await harness.callHook('session_start', {}, root) + await settle() + harness.fetchMock.mockClear() + harness.reload() + const child = { + sessionManager: { + getSessionId: () => 'child', + getSessionFile: () => '/sessions/root/child.jsonl', + getHeader: () => ({ parentSession: rootFile }) + } + } + await harness.callHook('agent_start', {}, child) + await settle() + expect(harness.fetchMock).not.toHaveBeenCalled() + }) + + it('normalizes Windows task transcript paths', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const rootFile = 'C:\\Users\\orca\\root.jsonl' + const root = { + sessionManager: { + getSessionId: () => 'root-win', + getSessionFile: () => rootFile, + getHeader: () => ({}) + } + } + await harness.callHook('session_start', {}, root) + harness.reload() + const child = { + sessionManager: { + getSessionId: () => 'child-win', + getSessionFile: () => 'c:\\users\\orca\\root\\child.jsonl', + getHeader: () => ({}) + } + } + await harness.callHook('agent_start', {}, child) + await settle() + expect(harness.fetchMock).not.toHaveBeenCalled() + }) + it('keeps reporting for legacy callbacks without a session manager', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + await harness.callHook('agent_start') + await settle() + await harness.callHook('agent_end') + await settle() + expect( + harness.fetchMock.mock.calls.map((call) => JSON.parse(call[1].body).payload.hook_event_name) + ).toEqual(['agent_start', 'agent_end']) + }) +}) + +describe('OMP runtime session provenance', () => { + it.each(['omp', 'pi'] as const)( + 'rejects an earlier child callback before it can claim the %s pane', + async (kind) => { + const harness = createAgentStatusExtensionHarness({ kind, argv: ['bun', '/opt/omp/bin/omp'] }) + const child = { + agentKind: 'sub', + hasUI: false, + sessionManager: { getSessionId: () => 'child', getSessionFile: () => '/child.jsonl' } + } + await harness.callHook('session_start', {}, child) + await harness.callHook('agent_start', {}, child) + await settle() + expect(harness.fetchMock).not.toHaveBeenCalled() + harness.reload() + const root = { + agentKind: 'main', + hasUI: false, + sessionManager: { getSessionId: () => 'root', getSessionFile: () => '/root.jsonl' } + } + await harness.callHook('session_start', {}, root) + await settle() + harness.fetchMock.mockClear() + await harness.callHook('agent_start', {}, root) + await settle() + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(JSON.parse(harness.fetchMock.mock.calls[0][1].body).payload.session_id).toBe('root') + } + ) + + it('allows a former child transcript resumed as the runtime main session', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const root = { + hasUI: false, + sessionManager: { + getSessionId: () => 'resumed-child', + getSessionFile: () => '/sessions/parent/subagent/child.jsonl' + } + } + await harness.callHook('session_start', {}, root) + await harness.callHook('agent_start', {}, root) + await settle() + expect(JSON.parse(harness.fetchMock.mock.calls[0][1].body).payload.session_id).toBe( + 'resumed-child' + ) + }) +}) diff --git a/src/main/pi/prefill-extension-source.ts b/src/main/pi/prefill-extension-source.ts index ad7aa6e9c1d..81414fed67a 100644 --- a/src/main/pi/prefill-extension-source.ts +++ b/src/main/pi/prefill-extension-source.ts @@ -18,10 +18,14 @@ export function getPiPrefillExtensionSource(kind: PrefillAgentKind): string { ) } -export function getPiPrefillHandlerSourceLines(kind: PrefillAgentKind): string[] { +export function getPiPrefillHandlerSourceLines( + kind: PrefillAgentKind, + wrapInStatusOwner = false +): string[] { const envVar = PREFILL_ENV_VAR_BY_KIND[kind] + const register = wrapInStatusOwner && kind === 'omp' ? 'onStatus' : 'pi.on' return [ - " pi.on('session_start', async (event, ctx) => {", + ` ${register}('session_start', async (event, ctx) => {`, ' if (!process.env.ORCA_PANE_KEY || ctx?.hasUI === false) return', ...(kind === 'pi' ? [" if (event.reason !== 'startup') return"] : []), ` const prefill = process.env.${envVar}`, diff --git a/tests/e2e/omp-child-session-recovery.unit.test.ts b/tests/e2e/omp-child-session-recovery.unit.test.ts new file mode 100644 index 00000000000..87228545f5e --- /dev/null +++ b/tests/e2e/omp-child-session-recovery.unit.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { createAgentStatusExtensionHarness } from '../../src/main/pi/agent-status-extension-test-harness' +import { normalizeHookPayload } from '../../src/shared/agent-hook-listener' +import { createHookListenerState } from '../../src/shared/agent-hook-listener/listener-state' +import { getAgentResumeArgv } from '../../src/shared/agent-session-resume' +import { buildAgentResumeStartupPlan } from '../../src/shared/tui-agent-resume-startup' +import { sleepingAgentSessionsByPaneKeySchema } from '../../src/shared/workspace-session-sleeping-agents' +import { createTestStore, makeTab } from '../../src/renderer/src/store/slices/store-test-helpers' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' +const ROOT_SESSION = '22222222-2222-4222-8222-222222222222' +const CHILD_SESSION = '33333333-3333-4333-8333-333333333333' + +async function rootWithActiveChild(): Promise> { + const store = createTestStore() + store.setState({ tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] } }) + const listener = createHookListenerState() + const harness = createAgentStatusExtensionHarness({ + kind: 'omp', + env: { + ORCA_PANE_KEY: PANE, + ORCA_WORKTREE_ID: 'wt-1', + ORCA_AGENT_HOOK_VERSION: '1', + ORCA_AGENT_HOOK_ENV: 'production' + }, + fetchImpl: async (_url, init) => { + const event = normalizeHookPayload( + listener, + 'omp', + JSON.parse(String(init?.body)), + 'production' + ) + if (!event) { + throw new Error('Expected a normalized OMP hook') + } + store + .getState() + .setAgentStatus( + PANE, + event.payload, + 'OMP', + { updatedAt: Date.now(), stateStartedAt: Date.now() }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession: event.providerSession, launchToken: event.launchToken } + ) + return { ok: true } + } + }) + const emit = async ( + name: string, + event: unknown, + sessionManager: { + getSessionId: () => string + getSessionFile: () => string + } + ): Promise => { + await harness.callHook(name, event, { sessionManager }) + for (let i = 0; i < 10; i++) { + await Promise.resolve() + } + } + const root = { getSessionId: () => ROOT_SESSION, getSessionFile: () => '/sessions/root.jsonl' } + await emit('session_start', {}, root) + await emit('before_agent_start', { prompt: 'ROOT distinctive user request' }, root) + await emit( + 'message_end', + { message: { role: 'assistant', content: 'ROOT assistant preview' } }, + root + ) + expect(store.getState().agentStatusByPaneKey[PANE]?.providerSession?.id).toBe(ROOT_SESSION) + + // OMP binds each task's extension factory to a different SessionManager in the same process. + harness.reload() + const child = { + getSessionId: () => CHILD_SESSION, + getSessionFile: () => '/sessions/root/worker.jsonl' + } + await emit('session_start', {}, child) + await emit('before_agent_start', { prompt: 'CHILD delegated bootstrap' }, child) + await emit( + 'message_end', + { message: { role: 'assistant', content: 'CHILD assistant preview' } }, + child + ) + return store +} + +describe('OMP child lifecycle recovery boundaries', () => { + it('retains the normalized root prompt and assistant preview while its child works (#9348)', async () => { + const store = await rootWithActiveChild() + expect(store.getState().agentStatusByPaneKey[PANE]).toMatchObject({ + prompt: 'ROOT distinctive user request', + lastAssistantMessage: 'ROOT assistant preview', + state: 'working', + providerSession: { id: ROOT_SESSION } + }) + }) + + it('captures and hydrates the root identity into resume startup after child hooks (#16353)', async () => { + const store = await rootWithActiveChild() + store.getState().captureAllSleepingAgentSessions('quit') + const serialized = JSON.stringify(store.getState().sleepingAgentSessionsByPaneKey) + const hydrated = sleepingAgentSessionsByPaneKeySchema.parse(JSON.parse(serialized)) + const record = hydrated?.[PANE] + if (!record) { + throw new Error('Expected the root sleeping record to survive hydration') + } + expect(record).toMatchObject({ origin: 'quit', providerSession: { id: ROOT_SESSION } }) + expect(getAgentResumeArgv(record.agent, record.providerSession)).toEqual([ + 'omp', + '--resume', + ROOT_SESSION + ]) + const startup = buildAgentResumeStartupPlan({ + agent: record.agent, + providerSession: record.providerSession, + cmdOverrides: {}, + platform: 'linux', + ...record.launchConfig + }) + expect(startup?.launchCommand).toBe(`omp '--resume' '${ROOT_SESSION}'`) + expect(serialized).not.toContain(CHILD_SESSION) + }) +}) diff --git a/tests/tools/omp-child-session-runtime-smoke.mjs b/tests/tools/omp-child-session-runtime-smoke.mjs new file mode 100644 index 00000000000..3f09904bdcb --- /dev/null +++ b/tests/tools/omp-child-session-runtime-smoke.mjs @@ -0,0 +1,146 @@ +// Run with Bun and a read-only OMP checkout path as the first argument. +import assert from 'node:assert/strict' +import { mkdtemp, 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 { build } from 'esbuild' + +const reference = process.argv[2] +assert.ok(reference, 'Pass the read-only oh-my-pi source checkout path') +const source = (path) => + pathToFileURL(join(resolve(reference), 'packages/coding-agent/src', path)).href +const { loadExtensions } = await import(source('extensibility/extensions/loader.ts')) +const { ExtensionRunner } = await import(source('extensibility/extensions/runner.ts')) +const { EventBus } = await import(source('utils/event-bus.ts')) +const { SessionManager } = await import(source('session/session-manager.ts')) +const scratch = await mkdtemp(join(tmpdir(), 'orca-omp-child-status-')) +const posts = [] +const errors = [] +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const server = createServer(async (request, response) => { + try { + let body = '' + for await (const chunk of request) { + body += chunk + } + posts.push(JSON.parse(body).payload) + response.writeHead(200).end() + } catch (error) { + errors.push(error) + response.writeHead(500).end() + } +}) +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) +try { + await build({ + entryPoints: ['src/main/pi/agent-status-extension-source.ts'], + bundle: true, + platform: 'node', + format: 'esm', + outfile: join(scratch, 'generator.mjs') + }) + const { getPiAgentStatusExtensionSource } = await import( + pathToFileURL(join(scratch, 'generator.mjs')).href + ) + const extensionPath = join(scratch, 'orca-agent-status.ts') + await writeFile(extensionPath, getPiAgentStatusExtensionSource('omp')) + process.env.ORCA_PANE_KEY = 'test-parent-pane' + process.env.ORCA_AGENT_LAUNCH_TOKEN = 'test-parent-launch' + 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 + const load = async (manager) => { + const result = await loadExtensions([extensionPath], scratch, new EventBus()) + assert.deepEqual(result.errors, []) + assert.equal(result.extensions.length, 1) + // These lifecycle handlers never query models or invoke agent actions. + const runner = new ExtensionRunner(result.extensions, result.runtime, scratch, manager, {}) + runner.onError((error) => errors.push(error)) + return runner + } + const emit = async (runner, type, expectedCount) => { + await runner.emit({ type }) + if (expectedCount !== undefined) { + const deadline = Date.now() + 2000 + while (posts.length < expectedCount && errors.length === 0 && Date.now() < deadline) { + await delay(10) + } + assert.equal(posts.length, expectedCount, `HTTP delivery after ${type}`) + } + assert.deepEqual(errors, []) + } + const assertQuiet = async (expectedCount) => { + // Suppressed events have no completion callback; allow local HTTP dispatch to settle. + await delay(100) + assert.deepEqual(errors, []) + assert.equal(posts.length, expectedCount, 'child lifecycle must not post pane status') + } + const rootManager = SessionManager.create(scratch, join(scratch, 'sessions')) + const childManager = SessionManager.inMemory(scratch) + const root = await load(rootManager) + await emit(root, 'session_start') + await emit(root, 'agent_start', 1) + const child = await load(childManager) + assert.notEqual(root, child) + assert.notEqual(rootManager, childManager) + await emit(child, 'session_start') + await emit(child, 'agent_start') + await emit(child, 'agent_end') + await assertQuiet(1) + await emit(root, 'agent_end', 2) + await writeFile(extensionPath, `${getPiAgentStatusExtensionSource('omp')}\n// Reloaded module\n`) + const reloaded = await load(rootManager) + await emit(reloaded, 'session_start') + const previousId = rootManager.getSessionId() + await rootManager.newSession() + assert.notEqual(rootManager.getSessionId(), previousId) + await emit(reloaded, 'agent_start', 3) + await emit(reloaded, 'agent_end', 4) + assert.equal(posts.at(-1).session_id, rootManager.getSessionId()) + + const resumedPath = join(scratch, 'resume-target.jsonl') + await writeFile( + resumedPath, + `${JSON.stringify({ + type: 'session', + version: 3, + id: 'runtime-resume-target', + timestamp: new Date().toISOString(), + cwd: scratch + })}\n` + ) + // Resume/reopen the transcript through the same manager. + for (const expectedCount of [5, 7]) { + await rootManager.setSessionFile(resumedPath) + assert.equal(rootManager.getSessionId(), 'runtime-resume-target') + await emit(reloaded, 'session_switch') + await emit(reloaded, 'agent_start', expectedCount) + await emit(reloaded, 'agent_end', expectedCount + 1) + assert.equal(posts.at(-1).session_id, 'runtime-resume-target') + } + await emit(reloaded, 'session_shutdown') + await emit(child, 'agent_start') + await emit(child, 'agent_end') + await emit(child, 'session_shutdown') + await assertQuiet(8) + assert.deepEqual( + posts.map((post) => post.hook_event_name), + Array.from({ length: 4 }, () => ['agent_start', 'agent_end']).flat() + ) + console.log( + JSON.stringify({ + platform: process.platform, + posts: posts.map((post) => post.hook_event_name), + distinctManagers: rootManager !== childManager, + scope: + 'Actual OMP loader, ExtensionRunner and SessionManager; controlled lifecycle order; real native HTTP' + }) + ) +} finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(resolve)) + await rm(scratch, { recursive: true, force: true }) +}