fix(pi): scope status completion to the run that ended (#20840)

A sibling extension can queue a follow-up run from inside its own
`agent_settled` handler (a memory reminder does exactly that). Pi
dispatches extension handlers in registration order and starts that run
synchronously, so the status extension sees the follow-up's `agent_start`
before its own `agent_settled` for the run that just ended.

The completion post was deduped by one `agentEndReported` boolean that
`agent_start` reset, so the older settlement consumed the latch and the
follow-up run's own settlement was swallowed. The host kept the
follow-up's last `message_end` (working) as its last word about the pane,
with nothing left to correct it until the freshness window expired.

Key the dedupe on the generation of the run that ENDED instead: the older
settlement still reports its own completion (that run did finish), and
the follow-up run now reports its own end exactly once.

Fixes #20838
This commit is contained in:
mmarabel
2026-09-19 00:31:54 -07:00
committed by GitHub
parent 9841f4f790
commit f960bc767e
2 changed files with 81 additions and 8 deletions
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest'
import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness'
function postedHookNames(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map((call) => {
const body: { payload?: { hook_event_name?: unknown } } = JSON.parse(String(call[1]?.body))
return typeof body.payload?.hook_event_name === 'string' ? body.payload.hook_event_name : ''
})
}
async function flushPosts(): Promise<void> {
// Each delivery has a bounded promise chain; no wall-clock sleeps in the harness.
for (let i = 0; i < 20; i++) {
await Promise.resolve()
}
}
function assistantMessage(text: string): { message: Record<string, unknown> } {
return { message: { role: 'assistant', content: [{ type: 'text', text }] } }
}
/**
* A sibling extension (the Basic Memory reminder is one) can queue a follow-up run
* from inside its own `agent_settled` handler. Pi dispatches handlers in registration
* order and starts the follow-up run synchronously, so this extension sees the NEXT
* run's `agent_start` before its own `agent_settled` for the run that just ended.
*
* Completion belongs to the run that ended, not to a "last run posted" latch: a
* boolean latch reset on `agent_start` is consumed by the older run's settlement and
* then swallows the follow-up run's own completion, leaving the host on that run's
* last working event.
*/
describe('a follow-up run started from another extension settlement', () => {
it('still reports the follow-up run completion', async () => {
const harness = createAgentStatusExtensionHarness({ kind: 'pi' })
const context = { isIdle: vi.fn(() => true) }
// First turn establishes that this runtime settles runs.
await harness.callHook('agent_start', undefined, context)
await harness.callHook('agent_end', undefined, context)
await harness.callHook('agent_settled', undefined, context)
await flushPosts()
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start', 'agent_end'])
// Turn 1 ends; a sibling extension's settle handler starts turn 2.
await harness.callHook('agent_start', undefined, context)
await harness.callHook('message_end', assistantMessage('all done'), context)
await harness.callHook('agent_end', undefined, context)
await harness.callHook('agent_start', undefined, context)
// Turn 1's settlement reaches this extension only now, after turn 2 started.
await harness.callHook('agent_settled', undefined, context)
await harness.callHook('message_end', assistantMessage('nothing durable to save'), context)
await harness.callHook('agent_end', undefined, context)
await harness.callHook('agent_settled', undefined, context)
await flushPosts()
// The host's last word about the pane must be the follow-up run's completion, not
// the follow-up's last working frame.
const posted = postedHookNames(harness.fetchMock)
expect(posted.at(-1), `posted: ${posted.join(' -> ')}`).toBe('agent_end')
})
})
+18 -8
View File
@@ -141,7 +141,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
` onStatus('agent_start', (${bareCtxParams}) => {`,
...captureSessionMetadata,
' clearPendingAgentEndCheck()',
' agentEndReported = false',
' runGeneration += 1',
// Why: a turn cannot begin under a dialog holding input focus, so this is the one
// boundary that can recover a modal whose close never arrived.
...(kind === 'pi' ? [' piUiPromptDepth = 0', ' piTurnInFlight = true'] : []),
@@ -192,7 +192,15 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
' const AGENT_END_IDLE_RECHECK_MS = 25',
' const AGENT_END_IDLE_RECHECK_MAX_MS = 250',
' let agentSettledSupported = false',
' let agentEndReported = false',
// Why: completion is a per-RUN fact. A sibling extension (the memory reminder is one)
// can start the next run from inside its own agent_settled handler, and Pi dispatches
// handlers in registration order, so this extension sees that run's agent_start
// BEFORE its own agent_settled for the run that just ended. A boolean "already
// posted" latch reset on agent_start then eats the newer run's completion and leaves
// the host stuck on that run's last working event.
' let runGeneration = 0',
' let endedRunGeneration = 0',
' let completionPostedGeneration = -1',
' let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS',
' let pendingAgentEndCheck: ReturnType<typeof setTimeout> | null = null',
' let pendingAgentEndContext: { isIdle: () => boolean } | null = null',
@@ -204,12 +212,13 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
' }',
'',
' // Why: isIdle flips before agent_settled handlers run, so both paths',
' // share a per-run guard instead of racing duplicate completion posts.',
' // share a guard instead of racing duplicate completion posts — one keyed on the',
' // generation of the run that ENDED, so a later run still reports its own end.',
' function postAgentEndOnce(): void {',
' if (agentEndReported) return',
' agentEndReported = true',
// Why: distinct from agentEndReported, which also dedupes the completion post and so
// starts false on a pane that has not run a turn yet — that pane is idle, not busy.
' if (completionPostedGeneration === endedRunGeneration) return',
' completionPostedGeneration = endedRunGeneration',
// Why: distinct from the completion guard, which holds the generation of the posted run
// and so starts clean on a pane that has not run a turn yet — that pane is idle, not busy.
...(kind === 'pi' ? [' piTurnInFlight = false'] : []),
" post('agent_end')",
' }',
@@ -217,7 +226,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
' function checkPendingAgentEnd(): void {',
' pendingAgentEndCheck = null',
' const ctx = pendingAgentEndContext',
' if (!ctx || agentSettledSupported || agentEndReported) {',
' if (!ctx || agentSettledSupported || completionPostedGeneration === endedRunGeneration) {',
' pendingAgentEndContext = null',
' return',
' }',
@@ -249,6 +258,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
' clearPendingAgentEndCheck()',
' return',
' }',
' endedRunGeneration = runGeneration',
' if (isOmpRuntime()) {',
' postAgentEndOnce()',
' return',