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
This commit is contained in:
Neil
2026-09-19 00:29:55 -07:00
committed by GitHub
parent 5d3a979b10
commit 9841f4f790
9 changed files with 591 additions and 33 deletions
@@ -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.
@@ -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' } }, {})
+14 -12
View File
@@ -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()',
+3 -3
View File
@@ -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',
@@ -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', () => {})",
''
]
}
@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest'
import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness'
const settle = async (): Promise<void> => {
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'
)
})
})
+6 -2
View File
@@ -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}`,
@@ -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<ReturnType<typeof createTestStore>> {
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<void> => {
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)
})
})
@@ -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 })
}