diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0d577b2bb5f..f1a92f59391 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -4845,7 +4845,7 @@ "pnpm exec playwright test tests/e2e/paired-remote-agent-visibility-notifications.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx src/renderer/src/runtime/web-session-tabs-notification-receipt-order.test.tsx src/renderer/src/runtime/web-session-tabs-notification-reconciler.test.ts src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts src/main/ipc/notifications-delivery-gating.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx src/renderer/src/runtime/web-session-tabs-notification-receipt-order.test.tsx src/renderer/src/runtime/web-session-tabs-notification-reconciler.test.ts src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts src/renderer/src/hooks/useAutoAckViewedAgent.attention.test.tsx src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts src/main/ipc/notifications-delivery-gating.test.ts", "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts" ], "testFiles": [ @@ -4861,6 +4861,7 @@ "src/renderer/src/runtime/web-session-tabs-notification-receipt-order.test.tsx", "src/renderer/src/runtime/web-session-tabs-notification-reconciler.test.ts", "src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts", + "src/renderer/src/hooks/useAutoAckViewedAgent.attention.test.tsx", "src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts", "src/main/ipc/notifications-delivery-gating.test.ts", "src/renderer/src/runtime/runtime-terminal-inspection.test.ts", diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.attention.test.tsx b/src/renderer/src/hooks/useAutoAckViewedAgent.attention.test.tsx new file mode 100644 index 00000000000..2b52273d4dc --- /dev/null +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.attention.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { useAppStore } from '../store' +import { makeTab } from '../store/slices/store-test-helpers' +import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' + +const WORKTREE_ID = 'wt-attention' +const TAB_ID = 'tab-attention' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) + +describe('useAutoAckViewedAgent recovered attention', () => { + const acknowledgeAgents = vi.fn() + const clearWorktreeUnread = vi.fn() + + beforeEach(() => { + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + useAppStore.setState({ + activeView: 'terminal', + activeTabId: TAB_ID, + activeWorktreeId: WORKTREE_ID, + tabsByWorktree: { [WORKTREE_ID]: [makeTab({ id: TAB_ID, worktreeId: WORKTREE_ID })] }, + terminalLayoutsByTabId: { + [TAB_ID]: { root: null, activeLeafId: LEAF_ID, expandedLeafId: null } + }, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + acknowledgedAgentsByPaneKey: {}, + unreadAgentCompletionPanes: {}, + unreadTerminalTabs: {}, + acknowledgeAgents, + clearWorktreeUnread + }) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + it('acks the visible row without clearing unread that has no completion-source marker', () => { + renderHook(() => useAutoAckViewedAgent(false)) + + useAppStore.getState().setAgentStatus(PANE_KEY, { + state: 'waiting', + prompt: 'Approve?', + agentType: 'claude' + }) + + expect(acknowledgeAgents).toHaveBeenCalledWith([PANE_KEY]) + expect(clearWorktreeUnread).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.ts index 67f7be66742..5d35c2153f9 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.ts @@ -258,13 +258,16 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { } const worktreeId = target.worktreeId acknowledgeViewedAgentAttention(current, { - activeWorktreeId: shouldClearViewedAgentWorktreeUnread(current, { - activeWorktreeId: worktreeId, - activeTabId: tabId, - paneKeysToClear - }) - ? worktreeId - : null, + // Why: row acknowledgement does not own coarse unread; only the completion-source marker may auto-clear it. + activeWorktreeId: + activePaneKey && + shouldClearViewedAgentWorktreeUnread(current, { + activeWorktreeId: worktreeId, + activeTabId: tabId, + paneKeysToClear + }) + ? worktreeId + : null, activeTabId: tabId, paneKeys: toAck, activePaneKey diff --git a/src/renderer/src/runtime/web-session-tabs-notification-reconciler.ts b/src/renderer/src/runtime/web-session-tabs-notification-reconciler.ts index d1204f89323..41e37c13ec2 100644 --- a/src/renderer/src/runtime/web-session-tabs-notification-reconciler.ts +++ b/src/renderer/src/runtime/web-session-tabs-notification-reconciler.ts @@ -23,7 +23,7 @@ export type WebSessionTabsNotificationReconciler = { observeSnapshot: ( snapshot: RuntimeMobileSessionTabsResult, options?: { attentionRequired?: boolean } - ) => void + ) => boolean observeInventory: ( snapshots: readonly RuntimeMobileSessionTabsResult[], options: { armPublished: boolean; attentionRequired?: boolean } @@ -62,14 +62,14 @@ export function createWebSessionTabsNotificationReconciler(args: { const observeAcceptedSnapshot = ( snapshot: RuntimeMobileSessionTabsResult, attentionRequired: boolean - ): void => { + ): boolean => { const current = worktrees.get(snapshot.worktree) if (isRemoval(snapshot)) { worktrees.delete(snapshot.worktree) - return + return current !== undefined } if (current && !advancesFreshness(snapshot, current)) { - return + return false } const eligible = current?.eligible === true worktrees.set(snapshot.worktree, { @@ -81,15 +81,17 @@ export function createWebSessionTabsNotificationReconciler(args: { seedOnly: !eligible, attentionRequired: eligible && attentionRequired }) + return true } return { observeSnapshot: (snapshot, options) => { - observeAcceptedSnapshot(snapshot, options?.attentionRequired === true) + const accepted = observeAcceptedSnapshot(snapshot, options?.attentionRequired === true) const state = worktrees.get(snapshot.worktree) if (state) { state.eligible = true } + return accepted }, observeInventory: (snapshots, options) => { const publishedWorktrees = new Set() diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index aaa5c5f1232..8192b607362 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -361,13 +361,21 @@ function untrackWebSessionTabsWorktree(environmentId: string, worktreeId: string export function recordReceivedWebSessionTabsSnapshot( environmentId: string, snapshot: RuntimeMobileSessionTabsResult, - options: { observeNotification?: boolean; attentionRequired?: boolean } = {} + options: { + observeNotification?: boolean + attentionRequired?: boolean + onNotificationAccepted?: () => void + } = {} ): number { const receivedFrame = (receivedSessionTabsFrameSequence += 1) if (options.observeNotification !== false) { - getWebSessionTabsNotificationLifecycle(environmentId).observeSnapshot(snapshot, { - attentionRequired: options.attentionRequired - }) + const accepted = getWebSessionTabsNotificationLifecycle(environmentId).observeSnapshot( + snapshot, + { attentionRequired: options.attentionRequired } + ) + if (accepted) { + options.onNotificationAccepted?.() + } } const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) latestReceivedSessionTabsSnapshotByWorktree.set(key, { @@ -5046,9 +5054,11 @@ export function useWebSessionTabsSync(): void { return } const attentionRequired = awaitingVisibilityResumeSnapshot - awaitingVisibilityResumeSnapshot = false const receivedFrame = recordReceivedWebSessionTabsSnapshot(environmentId, event, { - attentionRequired + attentionRequired, + onNotificationAccepted: () => { + awaitingVisibilityResumeSnapshot = false + } }) recordVisibilityResumeSnapshotReceiptRef.current( environmentId, diff --git a/tests/e2e/paired-remote-agent-visibility-notifications.spec.ts b/tests/e2e/paired-remote-agent-visibility-notifications.spec.ts index 59785aa89cc..b03d916a107 100644 --- a/tests/e2e/paired-remote-agent-visibility-notifications.spec.ts +++ b/tests/e2e/paired-remote-agent-visibility-notifications.spec.ts @@ -7,6 +7,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-ty import { makePaneKey } from '../../src/shared/stable-pane-id' import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS } from '../../src/renderer/src/runtime/window-visibility-subscription-parking' +import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT } from '../../src/renderer/src/runtime/window-visibility-subscription-parking' import { expect, test } from './helpers/orca-app' import { readHookEndpoint } from './helpers/agent-hook-endpoint' import { @@ -21,6 +22,7 @@ import { revealPairedClientWindow } from './helpers/paired-client-window-reveal' const REQUIRED_CLAUDE_EVENTS = ['UserPromptSubmit', 'Stop', 'PermissionRequest'] as const const NOTIFICATION_BARRIER_SOURCE = 'sta-5244-barrier' +let completedHideCycles = 0 type NotificationDispatch = { source?: string agentState?: string @@ -200,7 +202,11 @@ async function hideUntilSubscriptionsPark(client: PairedElectronClient): Promise }) document.dispatchEvent(new Event('visibilitychange')) }) - await client.page.waitForTimeout(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS + 100) + const parkDelay = + WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS * + Math.min(2 ** completedHideCycles, WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT) + await client.page.waitForTimeout(parkDelay + 100) + completedHideCycles += 1 } async function revealAfterSubscriptionsPark(client: PairedElectronClient): Promise { @@ -320,6 +326,7 @@ test('recovers hidden remote completion and permission alerts exactly once', asy let coldClient: PairedElectronClient | null = null let terminal: string | null = null let testError: unknown + completedHideCycles = 0 try { await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })