mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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' } }, {})
|
||||
|
||||
|
||||
@@ -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()',
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user