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 572df1515ce..b699f0ace5e 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 @@ -1215,6 +1215,46 @@ describe('agent completion coordinator', () => { expect(dispatchCompletion).not.toHaveBeenCalled() }) + it('does not announce the all-clear after first seeing a stamped turn tail', () => { + const dispatchCompletion = vi.fn() + const firstCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + firstCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000, + turnCompletedAt: 1_700_000_005_000 + }) + firstCoordinator.dispose() + + const remounted = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + remounted.observeHookStatus({ + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_055_000, + turnCompletedAt: 1_700_000_005_000 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + it('announces a gated Stop immediately and treats the later all-clear as the same turn', () => { const dispatchCompletion = vi.fn() const dispatchHookLifecycle = vi.fn() @@ -1379,6 +1419,56 @@ describe('agent completion coordinator', () => { expect(dispatchCompletion).toHaveBeenCalledTimes(1) }) + it('keeps stamped replay suppression through a working title and live remount', () => { + const dispatchCompletion = vi.fn() + const firstCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + firstCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000 + }) + firstCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000, + turnCompletedAt: 1_700_000_005_000 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(2_000) + firstCoordinator.observeTitleWorking() + firstCoordinator.dispose() + + const remounted = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + remounted.observeHookStatus({ + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_055_000, + turnCompletedAt: 1_700_000_005_000 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + it('does not let a vetoed gated Stop swallow the later all-clear', () => { 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 f9da6ca62f2..73395fdca73 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -37,6 +37,8 @@ type LastCompletionIdentity = { // Why: worktree switches remount a pane while the PTY/hook stream stays live, so stale completion replays must outlive one coordinator. 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() const IDLE_POLL_INTERVAL_MS = 2_000 const ACTIVE_POLL_INTERVAL_MS = 750 @@ -194,10 +196,11 @@ export function createAgentCompletionCoordinator( return completionIdentityFor(payload.state, payload.agentType, timestamp) } - function turnCompletedAtAlreadyNotified(turnCompletedAt: number): boolean { + function turnCompletedAtAlreadyHandled(turnCompletedAt: number): boolean { return ( + handledTurnCompletedAtByPaneKey.get(options.paneKey) === turnCompletedAt || lastCompletionIdentityByPaneKey.get(options.paneKey)?.lastTurnCompletedAtNotified === - turnCompletedAt + turnCompletedAt ) } @@ -320,6 +323,12 @@ export function createAgentCompletionCoordinator( clearPendingCodexAttention() if (optionsOverride.completionIdentity) { lastCompletionIdentityByPaneKey.set(options.paneKey, optionsOverride.completionIdentity) + if (optionsOverride.completionIdentity.lastTurnCompletedAtNotified !== undefined) { + handledTurnCompletedAtByPaneKey.set( + options.paneKey, + optionsOverride.completionIdentity.lastTurnCompletedAtNotified + ) + } } if ( source === 'hook' && @@ -834,7 +843,7 @@ export function createAgentCompletionCoordinator( // 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 && - !turnCompletedAtAlreadyNotified(turnCompletedAt) && + !turnCompletedAtAlreadyHandled(turnCompletedAt) && lastCompletionIdentity?.lastTurnCompletedAtNotified !== turnCompletedAt ) { const completionSnapshot: AgentCompletionStatusSnapshot = { @@ -857,6 +866,8 @@ export function createAgentCompletionCoordinator( if (committed) { lastCompletionIdentity = completionIdentity } + } else if (!workingStatusObserved) { + handledTurnCompletedAtByPaneKey.set(options.paneKey, turnCompletedAt) } options.dispatchHookLifecycle?.(payload) return @@ -898,7 +909,7 @@ export function createAgentCompletionCoordinator( : undefined if ( turnCompletedAt !== undefined && - (turnCompletedAtAlreadyNotified(turnCompletedAt) || + (turnCompletedAtAlreadyHandled(turnCompletedAt) || lastCompletionIdentity?.lastTurnCompletedAtNotified === turnCompletedAt || lastCompletedTurn === currentTurn) ) { @@ -996,6 +1007,7 @@ export function createAgentCompletionCoordinator( // Why: module-scoped identity survives a live-stream remount; evict only on genuine teardown (isLive() false) so it can't leak per closed pane. if (!options.isLive()) { lastCompletionIdentityByPaneKey.delete(options.paneKey) + handledTurnCompletedAtByPaneKey.delete(options.paneKey) } } @@ -1014,8 +1026,12 @@ export function createAgentCompletionCoordinator( export function resetAgentCompletionCoordinatorIdentitiesForTest(): void { lastCompletionIdentityByPaneKey.clear() + handledTurnCompletedAtByPaneKey.clear() } export function getAgentCompletionCoordinatorIdentityCountForTest(): number { - return lastCompletionIdentityByPaneKey.size + return new Set([ + ...lastCompletionIdentityByPaneKey.keys(), + ...handledTurnCompletedAtByPaneKey.keys() + ]).size } diff --git a/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts index 417e61a1d31..13361240db3 100644 --- a/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts @@ -29,7 +29,8 @@ const initialState = useAppStore.getInitialState() function makeAgentSnapshot( snapshotVersion: number, updatedAt: number, - turnCompletedAt?: number + turnCompletedAt?: number, + state: 'working' | 'done' = 'working' ): RuntimeMobileSessionTabsResult { return { worktree: WORKTREE_ID, @@ -50,7 +51,7 @@ function makeAgentSnapshot( terminal: 'terminal-1', ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}), agentStatus: { - state: 'working', + state, prompt: 'review the PR', updatedAt, stateStartedAt: NOW, @@ -87,7 +88,7 @@ describe('paired session-tab agent completion notifications', () => { vi.useRealTimers() }) - it('seeds accepted working snapshots and excludes completed replays', () => { + it('forwards accepted working snapshots and live statuses', () => { applySnapshot(makeAgentSnapshot(1, NOW), false) expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(1) @@ -107,7 +108,7 @@ describe('paired session-tab agent completion notifications', () => { }) applySnapshot(makeAgentSnapshot(4, NOW + 3_000, turnCompletedAt + 1_000), false) - expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(3) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(4) }) it('announces one live stamped completion after a late-pair working seed', () => { @@ -130,9 +131,24 @@ describe('paired session-tab agent completion notifications', () => { expect(dispatchCompletion).toHaveBeenCalledTimes(1) }) - it('does not seed a stamped reconnect replay', () => { - applySnapshot(makeAgentSnapshot(1, NOW, NOW), false) + it('suppresses a stamped reconnect tail and its live all-clear', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID), + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + mocks.observeAgentHookCompletionForNotification.mockImplementation(({ payload }) => + coordinator.observeHookStatus(payload) + ) - expect(mocks.observeAgentHookCompletionForNotification).not.toHaveBeenCalled() + applySnapshot(makeAgentSnapshot(1, NOW, NOW), false) + applySnapshot(makeAgentSnapshot(2, NOW + 1_000, NOW, 'done'), true) + + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(2) + expect(dispatchCompletion).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index fcb81344140..bd46ccbf659 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -3455,10 +3455,7 @@ export function applyWebSessionTabsStorePatch( surface.turnCompletedAt, accepted.state ) - if ( - !allowCompletionNotification && - (accepted.state !== 'working' || turnCompletedAt !== undefined) - ) { + if (!allowCompletionNotification && accepted.state !== 'working') { continue } acceptedNotificationStatuses.push({