From f960bc767e3f8a9dcc47c6d1aa9513457f6b671c Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:31:54 +0200 Subject: [PATCH] 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 --- ...us-extension-nested-run-settlement.test.ts | 63 +++++++++++++++++++ src/main/pi/agent-status-handler-source.ts | 26 +++++--- 2 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 src/main/pi/agent-status-extension-nested-run-settlement.test.ts diff --git a/src/main/pi/agent-status-extension-nested-run-settlement.test.ts b/src/main/pi/agent-status-extension-nested-run-settlement.test.ts new file mode 100644 index 00000000000..37ac0c874a1 --- /dev/null +++ b/src/main/pi/agent-status-extension-nested-run-settlement.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness' + +function postedHookNames(fetchMock: ReturnType): 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 { + // 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 } { + 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') + }) +}) diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index 80081776111..dc4b8b19028 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -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 | 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',