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 <help@stably.ai>
This commit is contained in:
parent
8c51ab12fa
commit
6154aded34
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue