fix(pi): report completion on agent_settled so intermediate agent_end stays silent (#8035) (#8826)

Pi's agent_end fires per low-level run — auto-retry, auto-compaction and
queued follow-ups all emit it while the agent is still working — so Orca
fired premature completion notifications. Pi >= 0.80.4 emits
agent_settled exactly once when nothing is left to run.

The extension now subscribes to both events: the first agent_settled
proves runtime support and mutes agent_end reporting from then on; on
older Pi/OMP runtimes (where unknown event names register silently and
never fire) agent_end keeps reporting done exactly as before. The
settled handler reports the existing agent_end hook event, so the
agent-hooks server contract is unchanged.
This commit is contained in:
Maxim Syabro 2026-07-17 04:16:30 +07:00 committed by GitHub
parent 4e232a030f
commit fc6570da67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 196 additions and 5 deletions

View File

@ -5,7 +5,11 @@ import { describe, expect, it, vi } from 'vitest'
import { getPiAgentStatusExtensionSource } from './agent-status-extension-source'
type HookHandler = (event?: unknown) => Promise<void> | void
type HookContext = {
isIdle: () => boolean
}
type HookHandler = (event?: unknown, context?: HookContext) => Promise<void> | void
type FakeCurlChild = {
on: ReturnType<typeof vi.fn>
@ -25,7 +29,7 @@ type Harness = {
}
handlers: Record<string, HookHandler>
processEnv: Record<string, string | undefined>
callHook: (name: string, event?: unknown) => Promise<void>
callHook: (name: string, event?: unknown, context?: HookContext) => Promise<void>
// Re-invoke the extension factory in the same process (as Pi does on an
// in-process extension reload), swapping in the freshly registered handlers.
reload: () => void
@ -160,8 +164,8 @@ function createHarness(args: {
fsMock,
handlers,
processEnv: processMock.env,
callHook: async (name, event) => {
await handlers[name]?.(event)
callHook: async (name, event, hookContext) => {
await handlers[name]?.(event, hookContext)
},
reload: () => {
for (const key of Object.keys(handlers)) {
@ -436,6 +440,130 @@ describe('getPiAgentStatusExtensionSource', () => {
}
})
it('reports only agent_settled after multiple first-run agent_end events', async () => {
vi.useFakeTimers()
try {
const harness = createHarness({ kind: 'pi' })
const context = { isIdle: vi.fn(() => false) }
for (let index = 0; index < 3; index += 1) {
await harness.callHook('agent_end', undefined, context)
await vi.advanceTimersByTimeAsync(700)
}
expect(harness.fetchMock).not.toHaveBeenCalled()
await harness.callHook('agent_settled')
await vi.advanceTimersByTimeAsync(0)
expect(harness.fetchMock).toHaveBeenCalledTimes(1)
expect(JSON.parse(String(harness.fetchMock.mock.calls[0]?.[1]?.body)).payload).toEqual({
hook_event_name: 'agent_end'
})
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('does not duplicate completion when idle is observed before agent_settled', async () => {
vi.useFakeTimers()
try {
const harness = createHarness({ kind: 'pi' })
const context = { isIdle: vi.fn(() => true) }
await harness.callHook('agent_end', undefined, context)
await vi.advanceTimersByTimeAsync(0)
expect(harness.fetchMock).toHaveBeenCalledTimes(1)
await harness.callHook('agent_settled')
await vi.advanceTimersByTimeAsync(0)
expect(harness.fetchMock).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('cancels an ambiguous agent_end when modern Pi resumes work', async () => {
vi.useFakeTimers()
try {
const harness = createHarness({ kind: 'pi' })
const context = { isIdle: vi.fn(() => false) }
await harness.callHook('agent_end', undefined, context)
await vi.advanceTimersByTimeAsync(100)
await harness.callHook('agent_start')
await harness.callHook('agent_end', undefined, context)
await vi.advanceTimersByTimeAsync(2_000)
await harness.callHook('agent_settled')
await vi.advanceTimersByTimeAsync(0)
const events = harness.fetchMock.mock.calls.map(
(call) => JSON.parse(String(call[1]?.body)).payload.hook_event_name
)
expect(events).toEqual(['agent_start', 'agent_end'])
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('drops a pending legacy fallback when its context becomes stale on reload', async () => {
vi.useFakeTimers()
try {
const harness = createHarness({ kind: 'pi' })
let active = true
const context = {
isIdle: vi.fn(() => {
if (!active) {
throw new Error('stale extension context')
}
return false
})
}
await harness.callHook('agent_end', undefined, context)
await vi.advanceTimersByTimeAsync(100)
active = false
harness.reload()
await vi.advanceTimersByTimeAsync(100)
expect(harness.fetchMock).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('keeps reporting legacy Pi and OMP once their agent_end handlers settle', async () => {
vi.useFakeTimers()
try {
for (const kind of ['pi', 'omp'] as const) {
const harness = createHarness({ kind })
let idle = false
const context = { isIdle: vi.fn(() => idle) }
await harness.callHook('agent_end', undefined, context)
await vi.advanceTimersByTimeAsync(100)
expect(harness.fetchMock).not.toHaveBeenCalled()
idle = true
await vi.advanceTimersByTimeAsync(100)
expect(harness.fetchMock).toHaveBeenCalledTimes(1)
}
} finally {
vi.useRealTimers()
}
})
it('keeps immediate agent_end fallback for runtimes without an idle context', async () => {
const harness = createHarness({ kind: 'omp' })
await harness.callHook('agent_end')
await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(1))
})
it('does not treat WSLENV alone as WSL evidence', async () => {
const harness = createHarness({
kind: 'omp',

View File

@ -41,6 +41,8 @@ export function getPiAgentStatusHandlerSourceLines(): string[] {
' })',
'',
" pi.on('agent_start', () => {",
' clearPendingAgentEndCheck()',
' agentEndReported = false',
" post('agent_start')",
' })',
'',
@ -75,8 +77,69 @@ export function getPiAgentStatusHandlerSourceLines(): string[] {
" post('message_end', { role: 'assistant', text })",
' })',
'',
" pi.on('agent_end', () => {",
' // Why: modern Pi stays non-idle across retry/compaction/follow-up work,',
' // while legacy Pi/OMP becomes idle after its final agent_end handlers.',
' const AGENT_END_IDLE_RECHECK_MS = 25',
' const AGENT_END_IDLE_RECHECK_MAX_MS = 250',
' let agentSettledSupported = false',
' let agentEndReported = false',
' let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS',
' let pendingAgentEndCheck: ReturnType<typeof setTimeout> | null = null',
' let pendingAgentEndContext: { isIdle: () => boolean } | null = null',
'',
' function clearPendingAgentEndCheck(): void {',
' if (pendingAgentEndCheck !== null) clearTimeout(pendingAgentEndCheck)',
' pendingAgentEndCheck = null',
' pendingAgentEndContext = null',
' }',
'',
' // Why: isIdle flips before agent_settled handlers run, so both paths',
' // share a per-run guard instead of racing duplicate completion posts.',
' function postAgentEndOnce(): void {',
' if (agentEndReported) return',
' agentEndReported = true',
" post('agent_end')",
' }',
'',
' function checkPendingAgentEnd(): void {',
' pendingAgentEndCheck = null',
' const ctx = pendingAgentEndContext',
' if (!ctx || agentSettledSupported || agentEndReported) {',
' pendingAgentEndContext = null',
' return',
' }',
' try {',
' if (ctx.isIdle()) {',
' pendingAgentEndContext = null',
' postAgentEndOnce()',
' return',
' }',
' } catch {',
' pendingAgentEndContext = null',
' return',
' }',
' pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, agentEndIdleRecheckMs)',
" if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()",
' agentEndIdleRecheckMs = Math.min(agentEndIdleRecheckMs * 2, AGENT_END_IDLE_RECHECK_MAX_MS)',
' }',
'',
" pi.on('agent_settled', () => {",
' agentSettledSupported = true',
' clearPendingAgentEndCheck()',
' postAgentEndOnce()',
' })',
'',
" pi.on('agent_end', (_event, ctx) => {",
' if (agentSettledSupported) return',
" if (!ctx || typeof ctx.isIdle !== 'function') {",
' postAgentEndOnce()',
' return',
' }',
' clearPendingAgentEndCheck()',
' agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS',
' pendingAgentEndContext = ctx',
' pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, 0)',
" if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()",
' })',
'}',
''