From 6154aded34c3c9dd2e3efba3ef1fc7fd9a11ff3e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:52:55 -0700 Subject: [PATCH] Quiet-window Pi/OMP intermediate done so resumed work cancels notification (#6361) (#6558) Pi (pi.dev) and OMP are goal/mission agents whose normalizePiCompatibleEvent maps milestone agent_end -> hook state 'done' while they are still working. observeHookStatus gated the quiet window on `workingStatusObserved`, so these intermediate 'done' events (workingStatusObserved === false) fell through to an immediate "agent finished" notification while the TUI kept spinning, and a follow-up working event could not cancel it. Route Pi/OMP 'done' through the existing quiet window via a new doneShouldUseQuietWindow() predicate (delegating to a shared isPiCompatibleAgentType on the canonical PiAgentKind), so resumed work cancels the premature notification. Codex and other turn-end-only producers keep their immediate dispatch. Also harden the process-exit backstop: skip the agent-evidence teardown while a quiet-window 'done' is pending, otherwise a poll that finds the agent gone would clear hasAgentRunEvidence and the timer would silently drop the real completion. Co-authored-by: Orca --- .../agent-completion-coordinator.test.ts | 128 +++++++++++++++++- .../agent-completion-coordinator.ts | 15 +- src/shared/pi-agent-kind.ts | 11 ++ 3 files changed, 151 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts index bcdeeb1c4..491a9fa8b 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts @@ -983,8 +983,6 @@ describe('agent completion coordinator', () => { 'gemini', 'opencode', 'cursor', - 'pi', - 'omp', 'droid', 'grok', 'devin', @@ -1010,6 +1008,132 @@ describe('agent completion coordinator', () => { expect(dispatchCompletion).toHaveBeenCalledWith(agentType) }) + it.each(['pi', 'omp'])( + 'defers a %s milestone done without prior working through the quiet window', + (agentType) => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + // Pi/OMP emit agent_end ('done') between milestones with no prior 'working'; + // the done must wait out the quiet window instead of firing immediately. + coordinator.observeHookStatus({ + state: 'done', + prompt: 'run the mission', + agentType + }) + expect(coordinator.hasPendingHookDoneCompletion()).toBe(true) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS - 1) + expect(dispatchCompletion).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith( + agentType, + expect.objectContaining({ source: 'hook', quietedHookDone: true }) + ) + } + ) + + it('suppresses a Pi milestone done when work resumes before the quiet window', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'done', + prompt: 'run the mission', + agentType: 'pi' + }) + expect(coordinator.hasPendingHookDoneCompletion()).toBe(true) + + // Pi resumes (a tool_call mapped to 'working') before the window elapses, + // which must cancel the premature "finished". + coordinator.observeHookStatus({ + state: 'working', + prompt: 'run the mission', + agentType: 'pi' + }) + expect(coordinator.hasPendingHookDoneCompletion()).toBe(false) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + + it('still dispatches a Codex done-without-prior-working immediately', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + // Codex only emits 'done' at turn end, so it must keep its immediate dispatch. + coordinator.observeHookStatus({ + state: 'done', + prompt: 'fix the bug', + agentType: 'codex' + }) + + expect(coordinator.hasPendingHookDoneCompletion()).toBe(false) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + + it('still fires a pending Pi done when process inspection sees the agent exit first', async () => { + // Why: a process-exit probe landing inside the quiet window must not tear + // down agent evidence, or the pending hook 'done' would be silently dropped. + let foregroundProcess: string | null = 'pi' + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult(foregroundProcess)), + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + + coordinator.observeHookStatus({ + state: 'done', + prompt: 'run the mission', + agentType: 'pi' + }) + expect(coordinator.hasPendingHookDoneCompletion()).toBe(true) + + // The agent process disappears mid-window; the cadence poll must not drop + // the pending completion. + foregroundProcess = null + vi.advanceTimersByTime(750) + await flushAsyncTicks() + expect(dispatchCompletion).not.toHaveBeenCalled() + + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith( + 'pi', + expect.objectContaining({ source: 'hook' }) + ) + }) + it('notifies once after a Cursor tool-heavy turn, not on each shell hook', () => { const dispatchCompletion = vi.fn() const coordinator = createAgentCompletionCoordinator({ diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index f1aac9f39..db4e7a299 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -16,6 +16,7 @@ import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types' import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' +import { isPiCompatibleAgentType } from '../../../../shared/pi-agent-kind' import { titleHasExplicitAgentIdentity, titleIsInconclusiveNativeDroidTitle @@ -157,6 +158,12 @@ export function createAgentCompletionCoordinator( return payload.agentType?.trim().toLowerCase() || null } + function doneShouldUseQuietWindow(payload: AgentCompletionStatusSnapshot): boolean { + // Why: Pi/OMP emit milestone 'done' while still working, so route their done + // through the quiet window (like a resumed turn) so later work can cancel it. + return workingStatusObserved || isPiCompatibleAgentType(hookCompletionAgentIdentity(payload)) + } + function hookAttentionToken(payload: AgentCompletionStatusSnapshot): string { const identity = hookCompletionIdentity(payload) if (identity) { @@ -416,6 +423,12 @@ export function createAgentCompletionCoordinator( handleRecognizedProcess(recognized) return true } + if (pendingHookDoneTimer !== null) { + // Why: a pending quiet-window 'done' is the authoritative completion; + // tearing down agent evidence here would make the timer drop it. + scheduleNextPoll() + return false + } if (lastForegroundAgent && hasAgentRunEvidence) { if (result.hasChildProcesses) { // Why: Codex can briefly report a shell/null foreground while its TUI or @@ -708,7 +721,7 @@ export function createAgentCompletionCoordinator( // backstops duplicate the same completion. currentTurn += 1 } - if (payload.state === 'done' && workingStatusObserved) { + if (payload.state === 'done' && doneShouldUseQuietWindow(payload)) { lastCompletionIdentity = hookIdentity ? { source: 'hook', diff --git a/src/shared/pi-agent-kind.ts b/src/shared/pi-agent-kind.ts index 603353c2a..109f0caae 100644 --- a/src/shared/pi-agent-kind.ts +++ b/src/shared/pi-agent-kind.ts @@ -13,6 +13,17 @@ import { getCommandTokenPathBasename, getFirstCommandToken } from './command-tok */ export type PiAgentKind = 'pi' | 'omp' +/** + * True when `agentType` names a Pi-compatible (goal/mission) kind. These agents + * emit milestone `agent_end` events between steps while still working, so they + * are treated differently from agents that only signal completion at turn end. + */ +export function isPiCompatibleAgentType( + agentType: string | null | undefined +): agentType is PiAgentKind { + return agentType === 'pi' || agentType === 'omp' +} + const OMP_LAUNCH_CMD = TUI_AGENT_CONFIG.omp.launchCmd // Why: regex carved to avoid matching `pi` inside `pip`, `mpi`, `api`,