fix(omp): fence pane status to the root session manager

This commit is contained in:
Neil
2026-09-19 00:21:38 -07:00
parent 6a7d86ef50
commit cafecc5d13
6 changed files with 270 additions and 30 deletions
@@ -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' } }, {})
+21 -10
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,24 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
' })'
]
: []),
...getOmpSessionOwnerHandlerSourceLines(),
...sessionStartHandler,
...(kind === 'omp' ? getPiPrefillHandlerSourceLines('omp') : []),
` onStatus('before_agent_start', (event${ctxParam}) => {`,
...sessionStartHandler,
<<<<<<< HEAD
...(kind === 'omp' ? getPiPrefillHandlerSourceLines('omp') : []),
` pi.on('before_agent_start', (event${ctxParam}) => {`,
||||||| parent of 2d948aac594 (fix(omp): fence pane status to the root session manager)
` pi.on('before_agent_start', (event${ctxParam}) => {`,
=======
` onStatus('before_agent_start', (event${ctxParam}) => {`,
>>>>>>> 2d948aac594 (fix(omp): fence pane status to the root session manager)
...captureSessionMetadata,
" post('before_agent_start', { prompt: event.prompt ?? '' })",
' })',
'',
` pi.on('agent_start', (${bareCtxParams}) => {`,
` onStatus('agent_start', (${bareCtxParams}) => {`,
...captureSessionMetadata,
' clearPendingAgentEndCheck()',
' agentEndReported = false',
@@ -146,7 +157,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 +165,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 +173,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 +186,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 +245,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()',
+4 -4
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,9 +31,9 @@ export function getPiAgentStatusUiPromptHandlerSourceLines(kind: PiAgentKind): s
" post('ui_prompt_end', { is_idle: isIdle })",
' })',
'',
" pi.on('session_shutdown', () => {",
" onStatus('session_shutdown', () => {",
' resetPostQueue()',
' clearPendingAgentEndCheck()',
' clearPendingAgentEndCheck()'
' if (isOmpRuntime()) return',
' // Why: pi tears an open dialog down through resetExtensionUI without resolving its',
' // promise, so a replaced session never emits the matching ui_prompt_end and the wait',
@@ -0,0 +1,33 @@
// 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 ownsSessionStatus(ctx): boolean {',
' if (!isOmpRuntime()) return true',
' const manager = ctx?.sessionManager',
" if (!manager || typeof manager !== 'object') 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) return owner === manager',
' owners.set(pane, manager)',
' 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,102 @@
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('isolates separately launched panes in the same host process', 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('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'])
})
})
@@ -0,0 +1,97 @@
// 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 { 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 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))
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_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 () => {
const result = await loadExtensions([extensionPath], scratch, new EventBus())
assert.deepEqual(result.errors, [])
return result.extensions[0]
}
const rootManager = SessionManager.inMemory(scratch)
const childManager = SessionManager.inMemory(scratch)
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, 40))
}
const root = await load()
await emit(root, 'session_start', rootManager)
await emit(root, 'agent_start', rootManager)
const child = await load()
assert.notEqual(root, child)
await emit(child, 'session_start', childManager)
await emit(child, 'agent_start', childManager)
await emit(child, 'agent_end', childManager)
assert.deepEqual(
posts.map((post) => post.hook_event_name),
['agent_start']
)
await emit(root, 'agent_end', rootManager)
await writeFile(extensionPath, `${getPiAgentStatusExtensionSource('omp')}\n// Reloaded module\n`)
const reloaded = await load()
await emit(reloaded, 'session_start', rootManager)
const previousId = rootManager.getSessionId()
await rootManager.newSession()
assert.notEqual(rootManager.getSessionId(), previousId)
await emit(reloaded, 'agent_start', rootManager)
await emit(reloaded, 'agent_end', rootManager)
assert.deepEqual(
posts.map((post) => post.hook_event_name),
['agent_start', 'agent_end', 'agent_start', 'agent_end']
)
console.log(
JSON.stringify({
platform: process.platform,
posts: posts.map((post) => post.hook_event_name),
distinctManagers: rootManager !== childManager,
scope:
'Actual OMP loader, EventBus and in-memory SessionManager; synthetic lifecycle callbacks; real native HTTP'
})
)
} finally {
server.closeAllConnections()
await new Promise((resolve) => server.close(resolve))
await rm(scratch, { recursive: true, force: true })
}