diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 23aef22a7b4..099c0013997 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -8920,6 +8920,36 @@ describe('AgentHookServer ingestRemote', () => { }) describe('AgentHookServer ingestTerminalStatus', () => { + it('preserves a hook turn stamp across an equivalent OSC repaint', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + + server.ingestRemote( + { + paneKey: PANE, + payload: { + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + turnCompletedAt: 1_700_000_005_000 + } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { state: 'working', prompt: 'review the PR', agentType: 'claude' } + }) + + expect(listener).toHaveBeenCalledOnce() + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + turnCompletedAt: 1_700_000_005_000 + }) + }) + // Why: the OSC 9999 payload cannot carry a provider session, so letting it overwrite the row // erased the session id from persisted rows and from headless `orca serve` — which serves these // rows straight to mobile — leaving Chat UI with no transcript to subscribe to (#10630). diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index b75f541bed4..ec972ac8bca 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -430,7 +430,8 @@ function equivalentParsedAgentStatusPayload( // Why: a session-boundary done must never be deduped against a cached real done — // the flag has to reach receivers deterministically (STA-3386). a.sessionBoundary === b.sessionBoundary && - a.turnCompletedAt === b.turnCompletedAt + // Why: OSC cannot carry the hook-only turn stamp; an otherwise equivalent repaint must preserve it, while an incoming stamp still advances the row. + (b.turnCompletedAt === undefined || a.turnCompletedAt === b.turnCompletedAt) ) } 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 b893af70f68..f26126f559a 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 @@ -808,6 +808,17 @@ describe('agent completion coordinator', () => { await flushAsyncTicks() expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + result = processResult('codex') + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + result = processResult('zsh', false) + vi.advanceTimersByTime(750) + await flushAsyncTicks() + vi.advanceTimersByTime(750) + await flushAsyncTicks() + + expect(dispatchCompletion).toHaveBeenCalledTimes(2) }) it('keeps duplicate done-only hooks inside replay guard suppressed after process inspection', async () => { @@ -1509,26 +1520,21 @@ describe('agent completion coordinator', () => { expect(dispatchCompletion).toHaveBeenCalledTimes(1) - hookCoordinator.observeHookStatus({ - state: 'working', - prompt: 'continue the review', - agentType: 'claude', - stateStartedAt: 1_700_000_010_000 - }) hookCoordinator.observeHookStatus({ state: 'done', - prompt: 'continue the review', + prompt: 'review the PR', agentType: 'claude', - stateStartedAt: 1_700_000_020_000 + stateStartedAt: 1_700_000_055_000, + turnCompletedAt: 1_700_000_005_000 }) vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) - expect(dispatchCompletion).toHaveBeenCalledTimes(2) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) paneCoordinator.observeTitleWorking() paneCoordinator.observeClassifiedTitleCompletion('Claude done') - expect(dispatchCompletion).toHaveBeenCalledTimes(3) + expect(dispatchCompletion).toHaveBeenCalledTimes(2) }) it('does not let a vetoed gated Stop swallow the later all-clear', () => { 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 02b89352103..1e4ae501433 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -39,6 +39,8 @@ type LastCompletionIdentity = { const lastCompletionIdentityByPaneKey = new Map() // Why: a late-pair stamped tail must suppress its replayed all-clear even though no banner was eligible. const handledTurnCompletedAtByPaneKey = new Map() +// Why: title fallback stays blocked only until the stamped background tail reaches its all-clear. +const pendingStampedTailByPaneKey = new Map() const IDLE_POLL_INTERVAL_MS = 2_000 const ACTIVE_POLL_INTERVAL_MS = 750 @@ -329,7 +331,7 @@ export function createAgentCompletionCoordinator( optionsOverride.completionIdentity.lastTurnCompletedAtNotified ) } else { - handledTurnCompletedAtByPaneKey.delete(options.paneKey) + pendingStampedTailByPaneKey.delete(options.paneKey) } } if ( @@ -495,6 +497,17 @@ export function createAgentCompletionCoordinator( function handleRecognizedProcess(process: RecognizedAgentProcess): void { pendingProcessExitAgent = null + const replayIdentity = lastCompletionIdentityByPaneKey.get(options.paneKey) + if ( + !lastForegroundAgent && + processSession > 0 && + !pendingStampedTailByPaneKey.has(options.paneKey) && + replayIdentity?.source === 'hook' && + replayIdentity.agentIdentity === process.agent + ) { + // Why: the prior observed process exited and this is a new same-agent process session, so its fallback completion cannot replay the old hook turn. + lastCompletionIdentityByPaneKey.delete(options.paneKey) + } if (lastForegroundAgent?.agent !== process.agent) { if (lastForegroundAgent && hasAgentRunEvidence) { if ( @@ -555,7 +568,8 @@ export function createAgentCompletionCoordinator( const exited = lastForegroundAgent pendingProcessExitAgent = null if (options.shouldSuppressConfirmedProcessExitCompletion?.(exited) !== true) { - dispatchCompletion('process-exit', exited.processName, { + const replayIdentityBeforeExit = lastCompletionIdentityByPaneKey.get(options.paneKey) + const committed = dispatchCompletion('process-exit', exited.processName, { terminalIdleConfirmed: true, completionIdentity: { source: 'process-exit', @@ -563,6 +577,15 @@ export function createAgentCompletionCoordinator( agentIdentity: exited.agent } }) + if ( + !committed && + !pendingStampedTailByPaneKey.has(options.paneKey) && + replayIdentityBeforeExit?.source === 'hook' && + replayIdentityBeforeExit.agentIdentity === exited.agent + ) { + // Why: this confirmed exit consumed the hook turn's process backstop; a later process instance must be allowed to complete independently. + lastCompletionIdentityByPaneKey.delete(options.paneKey) + } } lastForegroundAgent = null clearAgentRunEvidence() @@ -743,7 +766,7 @@ export function createAgentCompletionCoordinator( clearPendingCodexAttention() workingStatusObserved = true requiresFreshWorking = false - if (!handledTurnCompletedAtByPaneKey.has(options.paneKey)) { + if (!pendingStampedTailByPaneKey.has(options.paneKey)) { lastCompletionIdentityByPaneKey.delete(options.paneKey) } currentTurn += 1 @@ -844,10 +867,14 @@ export function createAgentCompletionCoordinator( ? payload.turnCompletedAt : undefined if (turnCompletedAt !== undefined) { + const alreadyHandled = turnCompletedAtAlreadyHandled(turnCompletedAt) + if (!alreadyHandled) { + pendingStampedTailByPaneKey.set(options.paneKey, turnCompletedAt) + } // Why: Claude's lead Stop already ended the turn; the pane stays `working` only for background inventory. Announce now, keep lifecycle on the reported working row. if ( workingStatusObserved && - !turnCompletedAtAlreadyHandled(turnCompletedAt) && + !alreadyHandled && lastCompletionIdentity?.lastTurnCompletedAtNotified !== turnCompletedAt ) { const completionSnapshot: AgentCompletionStatusSnapshot = { @@ -918,6 +945,9 @@ export function createAgentCompletionCoordinator( lastCompletedTurn === currentTurn) ) { // Why: this `done` is the background all-clear of a turn already announced (or completed from another source). Keep pane lifecycle; do not raise a second banner. + if (pendingStampedTailByPaneKey.get(options.paneKey) === turnCompletedAt) { + pendingStampedTailByPaneKey.delete(options.paneKey) + } options.dispatchHookLifecycle?.(payload) return } @@ -1012,6 +1042,7 @@ export function createAgentCompletionCoordinator( if (!options.isLive()) { lastCompletionIdentityByPaneKey.delete(options.paneKey) handledTurnCompletedAtByPaneKey.delete(options.paneKey) + pendingStampedTailByPaneKey.delete(options.paneKey) } } @@ -1031,11 +1062,13 @@ export function createAgentCompletionCoordinator( export function resetAgentCompletionCoordinatorIdentitiesForTest(): void { lastCompletionIdentityByPaneKey.clear() handledTurnCompletedAtByPaneKey.clear() + pendingStampedTailByPaneKey.clear() } export function getAgentCompletionCoordinatorIdentityCountForTest(): number { return new Set([ ...lastCompletionIdentityByPaneKey.keys(), - ...handledTurnCompletedAtByPaneKey.keys() + ...handledTurnCompletedAtByPaneKey.keys(), + ...pendingStampedTailByPaneKey.keys() ]).size }