diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts index a388ec17457..4084287cd4b 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -5,6 +5,8 @@ import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-termina export type AgentCompletionStatusSnapshot = ParsedAgentStatusPayload & { stateStartedAt?: number + /** Renderer-local boundary used only to reject a delayed cross-host completion. */ + localStateStartedAt?: number } export type AgentCompletionDispatchMeta = { 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 f26126f559a..7799ac82d7b 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 @@ -1328,6 +1328,70 @@ describe('agent completion coordinator', () => { ) }) + it.each(['before', 'after'] as const)( + 'does not let a sibling OSC all-clear arriving %s the host stamp duplicate it', + (allClearOrder) => { + const dispatchCompletion = vi.fn() + const localCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const hostCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 5_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 2_000 + }) + if (allClearOrder === 'before') { + localCoordinator.observeHookStatus({ + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 5_500 + }) + } + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 2_000, + turnCompletedAt: 2_500 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + if (allClearOrder === 'after') { + localCoordinator.observeHookStatus({ + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 5_500 + }) + } + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + } + ) + it('does not replay a stamped turn or its all-clear after a working title blip', () => { const dispatchCompletion = vi.fn() const dispatchHookLifecycle = vi.fn() 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 b4aa720ae69..1122dc1c4c0 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -211,6 +211,21 @@ export function createAgentCompletionCoordinator( ) } + function consumePendingStampedTailForAgent(agentIdentity: string | null): boolean { + const pendingStampedTail = pendingStampedTailByPaneKey.get(options.paneKey) + const stampedCompletion = lastCompletionIdentityByPaneKey.get(options.paneKey) + if ( + pendingStampedTail === undefined || + stampedCompletion?.lastTurnCompletedAtNotified !== pendingStampedTail || + agentIdentity === null || + stampedCompletion.agentIdentity !== agentIdentity + ) { + return false + } + pendingStampedTailByPaneKey.delete(options.paneKey) + return true + } + function hookCompletionAgentIdentity(payload: AgentCompletionStatusSnapshot): string | null { return payload.agentType?.trim().toLowerCase() || null } @@ -283,7 +298,11 @@ export function createAgentCompletionCoordinator( return false } if (previous.source === completionIdentity.source) { - return previous.identity === completionIdentity.identity + return ( + previous.identity === completionIdentity.identity || + (completionIdentity.source === 'hook' && + consumePendingStampedTailForAgent(completionIdentity.agentIdentity)) + ) } return ( previous.agentIdentity !== null && @@ -943,6 +962,14 @@ export function createAgentCompletionCoordinator( const turnCompletedAt = isFiniteTurnCompletedAt(payload.turnCompletedAt) ? payload.turnCompletedAt : undefined + if ( + turnCompletedAt === undefined && + consumePendingStampedTailForAgent(hookCompletionAgentIdentity(payload)) + ) { + // Why: paired host hooks carry the stamp while the sibling OSC coordinator sees only the matching all-clear. + options.dispatchHookLifecycle?.(payload) + return + } if ( turnCompletedAt !== undefined && (turnCompletedAtAlreadyHandled(turnCompletedAt) || diff --git a/src/renderer/src/components/terminal-pane/agent-completion-snapshot-staleness.ts b/src/renderer/src/components/terminal-pane/agent-completion-snapshot-staleness.ts index f93542cf196..7b973c4a0be 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-snapshot-staleness.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-snapshot-staleness.ts @@ -8,16 +8,20 @@ export function isSupersededAgentCompletionSnapshot( if (!storedAgentStatus || !snapshot) { return false } - if (typeof snapshot.stateStartedAt !== 'number') { + const comparableStateStartedAt = snapshot.localStateStartedAt ?? snapshot.stateStartedAt + if (typeof comparableStateStartedAt !== 'number') { return storedAgentStatus.state !== snapshot.state } // Why: hook completion notifications are delayed by a quiet window; by the // time they fire, the same pane may already belong to a newer agent turn. - if (storedAgentStatus.stateStartedAt > snapshot.stateStartedAt) { + if (storedAgentStatus.stateStartedAt > comparableStateStartedAt) { return true } + const hasStampedTurn = + typeof snapshot.turnCompletedAt === 'number' && Number.isFinite(snapshot.turnCompletedAt) return ( - storedAgentStatus.stateStartedAt === snapshot.stateStartedAt && - storedAgentStatus.state !== snapshot.state + storedAgentStatus.stateStartedAt === comparableStateStartedAt && + storedAgentStatus.state !== snapshot.state && + !hasStampedTurn ) } diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts index 0a862e380ac..1267b986827 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts @@ -225,6 +225,64 @@ describe('dispatchTerminalNotification', () => { ) }) + it.each([ + { clientStateStartedAt: 5_000, hostTurnCompletedAt: 2_000 }, + { clientStateStartedAt: 2_000, hostTurnCompletedAt: 5_000 } + ])( + 'accepts a host-stamped completion across client/host clock skew %#', + ({ clientStateStartedAt, hostTurnCompletedAt }) => { + mockState.agentStatusByPaneKey[paneKey] = makeAgentStatus(paneKey, { + state: 'working', + stateStartedAt: clientStateStartedAt, + agentType: 'claude', + terminalTitle: 'claude' + }) + + dispatchTerminalNotification('wt-primary', { + source: 'agent-task-complete', + terminalTitle: 'claude', + paneKey, + agentStatusSnapshot: { + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: hostTurnCompletedAt, + localStateStartedAt: clientStateStartedAt, + turnCompletedAt: hostTurnCompletedAt + } + }) + + expect(window.api.notifications.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + } + ) + + it('drops a host-stamped completion after a newer client turn starts', () => { + mockState.agentStatusByPaneKey[paneKey] = makeAgentStatus(paneKey, { + state: 'working', + stateStartedAt: 6_000, + agentType: 'claude', + terminalTitle: 'claude' + }) + + dispatchTerminalNotification('wt-primary', { + source: 'agent-task-complete', + terminalTitle: 'claude', + paneKey, + agentStatusSnapshot: { + state: 'done', + prompt: 'previous turn', + agentType: 'claude', + stateStartedAt: 2_000, + localStateStartedAt: 5_000, + turnCompletedAt: 2_000 + } + }) + + expect(window.api.notifications.dispatch).not.toHaveBeenCalled() + }) + it('uses a live pane key when inactive worktree tab membership is not hydrated', () => { mockState.tabsByWorktree = {} 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 c185e0f7635..4b0bfe14842 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 @@ -177,6 +177,15 @@ describe('paired session-tab agent completion notifications', () => { expect(useAppStore.getState().agentStatusByPaneKey[paneKey]?.state).toBe('working') expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(3) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + payload: expect.objectContaining({ + localStateStartedAt: NOW, + turnCompletedAt + }) + }) + ) expect(dispatchCompletion).toHaveBeenCalledTimes(1) expect(dispatchCompletion).toHaveBeenCalledWith( 'claude', diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 76fbb379848..e8203ee90b5 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -3426,7 +3426,10 @@ export function applyWebSessionTabsStorePatch( const acceptedNotificationStatuses: { paneKey: string worktreeId: string - payload: ReturnType & { stateStartedAt: number } + payload: ReturnType & { + stateStartedAt: number + localStateStartedAt?: number + } }[] = [] useAppStore.setState((state) => { const patch = buildPatch(state) @@ -3476,7 +3479,13 @@ export function applyWebSessionTabsStorePatch( ...notificationStatus, ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) }), - stateStartedAt: notificationStatus.stateStartedAt + stateStartedAt: notificationStatus.stateStartedAt, + ...(clientOwnedNotification + ? { + localStateStartedAt: + state.agentStatusByPaneKey[notificationStatus.paneKey]?.stateStartedAt + } + : {}) } }) }