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 0d928991a29..04a94417e44 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 @@ -1574,6 +1574,70 @@ describe('agent completion coordinator', () => { expect(dispatchCompletion).toHaveBeenCalledTimes(2) }) + it.each(['pty-first', 'host-first'] as const)( + 'releases stamped fallback dedupe when the next turn arrives %s', + (workingOrder) => { + const dispatchCompletion = vi.fn() + const localCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + statusLane: 'pty', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const hostCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + statusLane: 'hook', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const observeHostNextTurn = () => + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'second turn', + agentType: 'claude', + stateStartedAt: 2_000 + }) + + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 5_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000, + turnCompletedAt: 2_500 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + if (workingOrder === 'pty-first') { + localCoordinator.observeTitleWorking() + observeHostNextTurn() + } else { + observeHostNextTurn() + localCoordinator.observeTitleWorking() + } + localCoordinator.observeClassifiedTitleCompletion('Claude done') + + expect(dispatchCompletion).toHaveBeenCalledTimes(2) + } + ) + it.each(['before', 'after'] as const)( 'keeps sibling tail replay suppression when the pane remounts %s its all-clear', (remountOrder) => { 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 2e41f53d83e..f0802bd123a 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -1035,6 +1035,12 @@ export function createAgentCompletionCoordinator( const pendingStampedTail = pendingStampedTailByPaneKey.get(options.paneKey) if (pendingStampedTail?.originLane === coordinatorLane) { pendingStampedTailByPaneKey.delete(options.paneKey) + if ( + lastCompletionIdentityByPaneKey.get(options.paneKey)?.lastTurnCompletedAtNotified === + pendingStampedTail.turnCompletedAt + ) { + lastCompletionIdentityByPaneKey.delete(options.paneKey) + } } recordWorkingBoundary(payload.stateStartedAt) clearPendingHookDone() diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index e046fe5600b..a052b41aab7 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -281,12 +281,18 @@ export function observeAgentHookCompletionForNotification({ payload: AgentCompletionStatusSnapshot seedOnly?: boolean }): void { - pruneClosedPaneCoordinators() - if (!paneCanReceiveHookCompletion(paneKey)) { - return + // Why: replay seeds already passed indexed snapshot ownership; re-resolving every row makes startup batches quadratic. + if (seedOnly !== true) { + pruneClosedPaneCoordinators() + if (!paneCanReceiveHookCompletion(paneKey)) { + return + } } - const trackingEnabled = syncAgentHookCompletionNotificationSettings() + const trackingEnabled = + seedOnly === true + ? isAgentTaskCompleteTrackingEnabled() + : syncAgentHookCompletionNotificationSettings() let entry = coordinatorsByPaneKey.get(paneKey) if (!entry || entry.worktreeId !== worktreeId) { diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 69ae4c15c96..b829ffaa018 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -9160,6 +9160,7 @@ describe('useIpcEvents agent status snapshot integration', () => { it('queues replayed startup snapshots until the pane hydrates', async () => { const setAgentStatus = vi.fn() + const observeAgentHookCompletionForNotification = vi.fn() const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null } let resolveSnapshot!: (entries: AgentStatusSetData[]) => void const getSnapshot = vi.fn( @@ -9190,6 +9191,12 @@ describe('useIpcEvents agent status snapshot integration', () => { getState: () => storeState } })) + vi.doMock('./agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification, + resetAgentHookCompletionNotificationCoordinators: vi.fn(), + syncAgentHookCompletionNotificationSettings: vi.fn(), + syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() + })) stubAuxiliaryModules() vi.stubGlobal( 'window', @@ -9215,6 +9222,7 @@ describe('useIpcEvents agent status snapshot integration', () => { agentType: 'codex', toolName: 'Bash', toolInput: 'pnpm test', + turnCompletedAt: 1_699_999_999_500, terminalHandle: 'term-future', receivedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 @@ -9254,6 +9262,17 @@ describe('useIpcEvents agent status snapshot integration', () => { expectWorktreeRouting('wt-1'), undefined ) + expect(observeAgentHookCompletionForNotification).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: FUTURE_PANE_KEY, + worktreeId: 'wt-1', + seedOnly: true, + payload: expect.objectContaining({ + state: 'working', + turnCompletedAt: 1_699_999_999_500 + }) + }) + ) }) it('suppresses notifications for replayed done snapshots after pane hydration', async () => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 7475837a69e..8c3a6fa17e4 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3386,7 +3386,7 @@ export function useIpcEvents(): void { : undefined } const applyPostCommitNotification = (): void => { - if (options?.replay !== true && statusWorktreeId) { + if (statusWorktreeId && (options?.replay !== true || resolvedPayload.state === 'working')) { // Why: local Codex/Claude hooks arrive via this main-process IPC path, not the PTY OSC fallback, so task-complete notifications must observe accepted hook state here too. const notificationPayload = typeof data.stateStartedAt === 'number' @@ -3395,7 +3395,8 @@ export function useIpcEvents(): void { observeAgentHookCompletionForNotification({ paneKey, worktreeId: statusWorktreeId, - payload: notificationPayload + payload: notificationPayload, + ...(options?.replay === true ? { seedOnly: true } : {}) }) } }