diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 18923839175..0ec7b674926 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", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx src/renderer/src/hooks/agent-hook-completion-notifications-inventory.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": [ @@ -4858,6 +4858,7 @@ "tests/e2e/remote-agent-completion-authority.unit.test.ts", "tests/e2e/paired-remote-agent-visibility-notifications.spec.ts", "src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx", + "src/renderer/src/hooks/agent-hook-completion-notifications-inventory.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", @@ -4932,6 +4933,13 @@ "the active scoped stream may win resume ordering without losing or duplicating attention" ] }, + { + "file": "src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts", + "assertions": [ + "cold done and blocked inventory seeds notification identity without dispatching", + "later done, blocked, and stamped Claude completion identities dispatch once" + ] + }, { "file": "src/renderer/src/runtime/runtime-terminal-inspection.test.ts", "assertions": [ diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts index deff744fbd8..1b4219167b0 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts @@ -108,6 +108,36 @@ describe('agent completion coordinator', () => { expect(dispatchCompletion).not.toHaveBeenCalled() }) + it('does not let a suppressed cold seed silence a later real permission prompt', () => { + const dispatchAttention = vi.fn() + let suppress = true + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion: vi.fn(), + dispatchAttention, + isLive: () => true, + shouldSuppressHookCompletion: (payload) => suppress && payload.state === 'waiting' + }) + const working = { state: 'working' as const, prompt: 'ship it', agentType: 'codex' as const } + const waiting = { + state: 'waiting' as const, + prompt: 'ship it', + agentType: 'codex' as const, + stateStartedAt: 100 + } + + coordinator.observeHookStatus(working) + coordinator.seedHookStatus(waiting) + suppress = false + coordinator.observeHookStatus(waiting) + vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) + + expect(dispatchAttention).toHaveBeenCalledTimes(1) + }) + it('does not dispatch completion when a blocked state arrives mid-turn', () => { const dispatchCompletion = vi.fn() const dispatchAttention = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts index 75613bd4eeb..4babc3ca578 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts @@ -8,6 +8,29 @@ import { describe('agent completion coordinator', () => { useAgentCompletionCoordinatorLifecycle() + it('does not seed a completion identity from an idle session boundary', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.seedHookStatus({ + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true, + stateStartedAt: 1_700_000_000_000 + }) + coordinator.observeClassifiedTitleCompletion('Claude done') + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + it('allows later done-only hook completions from the same long-lived process', () => { 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 dba0a394448..edb2d052b16 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -1159,6 +1159,11 @@ export function createAgentCompletionCoordinator( return } recordPaneActivity() + if (options.shouldSuppressHookCompletion?.(payload)) { + clearPendingHookDone() + clearPendingCodexAttention() + return + } if (isRecognizedAgentType(payload.agentType)) { establishAgentEvidence() } @@ -1168,6 +1173,9 @@ export function createAgentCompletionCoordinator( lastAttentionToken = hookAttentionToken(payload) return } + if (payload.state === 'done' && payload.sessionBoundary === true) { + return + } const identity = hookCompletionIdentity(payload) if (!identity) { return diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts index 60afa0c0956..dadf9894fce 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts @@ -60,8 +60,7 @@ describe('agent hook inventory notification baselines', () => { observe({ paneKey: PANE_KEY, worktreeId: 'wt-1', - payload: status('done', 200), - seedOnly: true + payload: status('done', 200) }) expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) }) @@ -79,8 +78,7 @@ describe('agent hook inventory notification baselines', () => { observe({ paneKey: PANE_KEY, worktreeId: 'wt-1', - payload: status('blocked', 200), - seedOnly: true + payload: status('blocked', 200) }) vi.advanceTimersByTime(1_500) expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) @@ -99,8 +97,7 @@ describe('agent hook inventory notification baselines', () => { observe({ paneKey: PANE_KEY, worktreeId: 'wt-1', - payload: { ...status('working', 100, 'claude'), turnCompletedAt: 200 }, - seedOnly: true + payload: { ...status('working', 100, 'claude'), turnCompletedAt: 200 } }) expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index ae68545876d..7d226331eed 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -310,7 +310,6 @@ export function observeAgentHookCompletionForNotification({ } let entry = coordinatorsByPaneKey.get(paneKey) - const shouldSeed = seedOnly === true && (!entry || entry.worktreeId !== worktreeId) if (!entry || entry.worktreeId !== worktreeId) { entry?.coordinator.dispose() entry = { @@ -327,7 +326,7 @@ export function observeAgentHookCompletionForNotification({ if (payload.state === 'working' && payload.turnCompletedAt === undefined && trackingEnabled) { paneKeysRequiringFreshWorking.delete(paneKey) } - if (shouldSeed) { + if (seedOnly === true) { entry.coordinator.seedHookStatus(payload) } else { entry.coordinator.observeHookStatus(payload) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 3b766d54d08..48e2eac28c2 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -3734,7 +3734,9 @@ export function applyWebSessionTabsStorePatch( buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial, hostMirrorVerdict: HostSessionMirrorPatchVerdict, agentStatusSnapshots?: RuntimeMobileSessionTabsResult | readonly RuntimeMobileSessionTabsResult[], - allowCompletionNotification = false + allowCompletionNotification: + | boolean + | ((snapshot: RuntimeMobileSessionTabsResult) => boolean) = false ): HostSessionMirrorSettle { let mirroredAgentStatusChanged = false // Why: zustand commits before notifying subscribers, so a producer that ran @@ -3760,6 +3762,10 @@ export function applyWebSessionTabsStorePatch( ? agentStatusSnapshots : [agentStatusSnapshots] for (const snapshot of snapshots) { + const notificationAllowed = + typeof allowCompletionNotification === 'function' + ? allowCompletionNotification(snapshot) + : allowCompletionNotification for (const surface of snapshot.tabs) { if (surface.type !== 'terminal') { continue @@ -3840,7 +3846,7 @@ export function applyWebSessionTabsStorePatch( acceptedNotificationStatuses.push({ paneKey: notificationStatus.paneKey, worktreeId: notificationStatus.worktreeId ?? snapshot.worktree, - ...(!allowCompletionNotification ? { seedOnly: true as const } : {}), + ...(!notificationAllowed ? { seedOnly: true as const } : {}), payload: { ...pickParsedAgentStatusPayload({ ...notificationStatus, @@ -4021,6 +4027,7 @@ export function useWebSessionTabsSync(): void { ) => boolean >(() => true) const visibilityResumeOmissionsByKeyRef = useRef(new Map()) + const visibilityResumeNotificationBaselinesRef = useRef(new Map>()) const mirroredSessionTabsOwnerRevisionByEnvironmentRef = useRef( new Map() ) @@ -4069,6 +4076,7 @@ export function useWebSessionTabsSync(): void { } mirroredSessionTabsOwnerRevisionByEnvironmentRef.current.clear() visibilityResumeOmissionsByKeyRef.current.clear() + visibilityResumeNotificationBaselinesRef.current.clear() }, [] ) @@ -4119,6 +4127,12 @@ export function useWebSessionTabsSync(): void { if (!workspaceSessionReady || environments.length === 0) { return } + const notificationBaselinesByEnvironment = new Map( + environments.map(({ environmentId }) => [ + environmentId, + new Set(getTrackedWebSessionTabsWorktrees(environmentId).map(({ worktree }) => worktree)) + ]) + ) type VisibilityResumeEnvironment = { trackedWorktrees: readonly TrackedWebSessionTabsWorktree[] @@ -4457,6 +4471,7 @@ export function useWebSessionTabsSync(): void { } } const resumedEnvironments = new Map() + const notificationBaselines = new Map>() const trackedWorktreeIds = new Set() for (const index of restartingSpecIndexes) { const environmentId = environmentIdBySubscriptionSpec[index] @@ -4468,6 +4483,10 @@ export function useWebSessionTabsSync(): void { for (const { worktree } of trackedWorktrees) { trackedWorktreeIds.add(worktree) } + notificationBaselines.set( + environmentId, + new Set(trackedWorktrees.map(({ worktree }) => worktree)) + ) resumedEnvironments.set(environmentId, { trackedWorktrees, inventoryReceived: false, @@ -4476,6 +4495,7 @@ export function useWebSessionTabsSync(): void { }) } } + visibilityResumeNotificationBaselinesRef.current = notificationBaselines visibilityResumeBatch = resumedEnvironments.size > 0 ? { @@ -4640,8 +4660,17 @@ export function useWebSessionTabsSync(): void { ).length } }, - freshSnapshots + freshSnapshots, + (snapshot) => + notificationBaselinesByEnvironment + .get(environmentId) + ?.has(snapshot.worktree) === true ) + const notificationBaselines = + notificationBaselinesByEnvironment.get(environmentId) + for (const snapshot of freshSnapshots) { + notificationBaselines?.add(snapshot.worktree) + } const freshSnapshotSet = new Set(freshSnapshots) for (const { index, snapshot } of applicable) { if (unchangedVisibilityResumeSnapshots[index]) { @@ -4726,6 +4755,9 @@ export function useWebSessionTabsSync(): void { recovered, event.type === 'updated' && !replayed ) + notificationBaselinesByEnvironment + .get(environmentId) + ?.add(recovered.worktree) recordVisibilityResumeSnapshot(environmentId, recovered, receivedFrame) } else { settleHydration = hostSessionMirrorSettleForPatchlessFrame( @@ -4812,13 +4844,17 @@ export function useWebSessionTabsSync(): void { let requestedInitialTerminal = false let requestedRespawnAfterWake = false + let notificationBaselineEstablished = getTrackedWebSessionTabsWorktrees(environmentId).some( + ({ worktree }) => worktree === activeWorktreeId + ) /** Resolves the settle receipt of the evidence this frame put (or already * had) in the store, or null when the frame was discarded undecided. */ const applyActiveSnapshot = async ( event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }, response: RuntimeRpcResponse, isCurrent: () => boolean, - receivedFrame: number + receivedFrame: number, + allowCompletionNotification: boolean ): Promise => { const recovered = await recoverWebSessionTerminalOrphansBeforeApply( useAppStore.getState(), @@ -4873,8 +4909,9 @@ export function useWebSessionTabsSync(): void { (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), { frames: [{ environmentId, worktreeId: recovered.worktree, decision }] }, recovered, - event.type === 'updated' && !replayed + allowCompletionNotification || (event.type === 'updated' && !replayed) ) + notificationBaselineEstablished = true recordVisibilityResumeSnapshotRef.current(environmentId, recovered, receivedFrame) } if (isCurrent() && shouldBootstrapInitialTerminal) { @@ -4950,7 +4987,16 @@ export function useWebSessionTabsSync(): void { event.worktree, receivedFrame ) - void applyActiveSnapshot(event, response, isCurrent, receivedFrame) + void applyActiveSnapshot( + event, + response, + isCurrent, + receivedFrame, + notificationBaselineEstablished || + visibilityResumeNotificationBaselinesRef.current + .get(environmentId) + ?.has(activeWorktreeId) === true + ) .catch((error) => { if (isCurrent()) { console.warn( diff --git a/src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx b/src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx index 963362eb83b..7450ad70a08 100644 --- a/src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Mock } from 'vitest' import { tagRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { makePaneKey } from '../../../shared/stable-pane-id' import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' @@ -58,8 +59,11 @@ import { import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS } from './window-visibility-subscription-parking' const ENVIRONMENT_ID = 'env-a' +const ENVIRONMENT_B = 'env-b' const RUNTIME_ID = 'runtime-a' +const RUNTIME_B = 'runtime-b' const REVISION = 101 +const REVISION_B = 202 const REPO_ID = 'repo-a' const WORKTREE_ID = `${REPO_ID}::/worktree-a` const OTHER_WORKTREE_ID = `${REPO_ID}::/other` @@ -68,6 +72,8 @@ const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID) const NOW = 1_700_000_000_000 const MIRROR_KEY = `${ENVIRONMENT_ID}\u0001${RUNTIME_ID}\u00011\u0001${REVISION}` +const RECONNECTED_MIRROR_KEY = `${ENVIRONMENT_ID}\u0001${RUNTIME_ID}\u00012\u0001${REVISION}` +const TWO_ENVIRONMENT_MIRROR_KEY = `${MIRROR_KEY}\u0000${ENVIRONMENT_B}\u0001${RUNTIME_B}\u00011\u0001${REVISION_B}` const initialState = useAppStore.getInitialState() type RuntimeSubscribe = typeof window.api.runtimeEnvironments.subscribe @@ -79,12 +85,14 @@ type RuntimeSubscription = { type TerminalState = 'working' | 'done' | 'blocked' | 'waiting' const subscriptions: RuntimeSubscription[] = [] -const runtimeCall = vi.fn(async () => ({ - id: 'list-all', - ok: true as const, - result: { snapshots: [] }, - _meta: { runtimeId: RUNTIME_ID } -})) +const runtimeCall = vi.fn( + async (): Promise> => ({ + id: 'list-all', + ok: true as const, + result: { snapshots: [] }, + _meta: { runtimeId: RUNTIME_ID } + }) +) const runtimeSubscribe = vi.fn(async (request, callbacks) => { const unsubscribe = vi.fn() subscriptions.push({ request, callbacks, unsubscribe }) @@ -158,6 +166,20 @@ function findSubscription( return subscription } +function findEnvironmentSubscription( + method: 'session.tabs.subscribeAll' | 'session.tabs.subscribe', + environmentId: string, + occurrence = 0 +): RuntimeSubscription { + const subscription = subscriptions.filter( + ({ request }) => request.method === method && request.selector === environmentId + )[occurrence] + if (!subscription) { + throw new Error(`Missing ${environmentId} ${method} subscription ${occurrence}`) + } + return subscription +} + async function publish( subscription: RuntimeSubscription, result: unknown, @@ -229,7 +251,6 @@ async function parkAndReveal(): Promise { describe('paired session-tab visibility-resume notifications', () => { let notificationDispatch: Mock<(kind: 'done' | 'attention') => void> let disposeCoordinator: () => void - let hasNotificationBaseline: boolean beforeEach(() => { vi.useFakeTimers() @@ -249,8 +270,6 @@ describe('paired session-tab visibility-resume notifications', () => { resetWebSessionTabsSnapshotFreshnessForTests() resetRendererOwnedAgentStatusPanesForTests() seedRemoteMirrorState() - hasNotificationBaseline = false - notificationDispatch = vi.fn() const coordinator = createAgentCompletionCoordinator({ paneKey: PANE_KEY, @@ -270,12 +289,11 @@ describe('paired session-tab visibility-resume notifications', () => { }) disposeCoordinator = () => coordinator.dispose() mocks.observeAgentHookCompletionForNotification.mockImplementation(({ payload, seedOnly }) => { - if (seedOnly && !hasNotificationBaseline) { + if (seedOnly) { coordinator.seedHookStatus(payload) } else { coordinator.observeHookStatus(payload) } - hasNotificationBaseline = true }) }) @@ -330,6 +348,26 @@ describe('paired session-tab visibility-resume notifications', () => { hook.unmount() }) + it('keeps ordered listAll and subscription cold inventories silent', async () => { + runtimeCall.mockResolvedValueOnce({ + id: 'list-all', + ok: true, + result: { snapshots: [snapshot(1, 'working', NOW)] }, + _meta: { runtimeId: RUNTIME_ID } + }) + const hook = renderHook(() => useWebSessionTabsSync()) + await act(settle) + await publish(findSubscription('session.tabs.subscribeAll'), { + type: 'snapshots', + snapshots: [snapshot(2, 'done', NOW + 1_000)] + }) + vi.advanceTimersByTime(1_500) + + expect(notificationDispatch).not.toHaveBeenCalled() + expect(badgeCount()).toBe(0) + hook.unmount() + }) + it.each(['done', 'blocked'] as const)( 'keeps cold %s inventory silent when first mounted hidden', async (state) => { @@ -351,6 +389,65 @@ describe('paired session-tab visibility-resume notifications', () => { } ) + it.each(['done', 'blocked'] as const)( + 'keeps a cold active-worktree %s snapshot silent after first hidden mount', + async (state) => { + seedRemoteMirrorState(true) + mocks.getExplicitRuntimeEnvironmentIdForWorktree.mockReturnValue(ENVIRONMENT_ID) + setDocumentVisibility('hidden') + const hook = renderHook(() => useWebSessionTabsSync()) + await act(settle) + + act(() => setDocumentVisibility('visible')) + await act(settle) + await publish(findSubscription('session.tabs.subscribe'), { + type: 'snapshot', + ...snapshot(1, state, NOW) + }) + vi.advanceTimersByTime(1_500) + + expect(notificationDispatch).not.toHaveBeenCalled() + expect(badgeCount()).toBe(0) + hook.unmount() + } + ) + + it('does not borrow another environment same-id worktree notification baseline', async () => { + const hook = renderHook(() => useWebSessionTabsSync()) + await act(settle) + await publish(findEnvironmentSubscription('session.tabs.subscribeAll', ENVIRONMENT_ID), { + type: 'updated', + ...snapshot(1, 'working', NOW) + }) + notificationDispatch.mockClear() + + const runtimeEnvironments = [ + { id: ENVIRONMENT_ID, createdAt: 100, pairingRevision: REVISION }, + { id: ENVIRONMENT_B, createdAt: 200, pairingRevision: REVISION_B } + ] as PublicKnownRuntimeEnvironment[] + replaceRuntimeEnvironmentRevisions(runtimeEnvironments) + useAppStore.setState({ + runtimeEnvironments, + runtimeStatusByEnvironmentId: new Map([ + [ENVIRONMENT_ID, { status: { runtimeId: RUNTIME_ID }, connectionGeneration: 1 }], + [ENVIRONMENT_B, { status: { runtimeId: RUNTIME_B }, connectionGeneration: 1 }] + ]) as AppState['runtimeStatusByEnvironmentId'] + }) + mocks.runtimeSessionMirrorEnvironmentKey.mockReturnValue(TWO_ENVIRONMENT_MIRROR_KEY) + hook.rerender() + await act(settle) + + await publish(findEnvironmentSubscription('session.tabs.subscribeAll', ENVIRONMENT_B), { + type: 'snapshots', + snapshots: [snapshot(1, 'done', NOW + 1_000)] + }) + vi.advanceTimersByTime(1_500) + + expect(notificationDispatch).not.toHaveBeenCalled() + expect(badgeCount()).toBe(0) + hook.unmount() + }) + it.each(['done', 'blocked'] as const)( 'keeps a metadata-only %s inventory refresh silent', async (state) => { @@ -420,15 +517,14 @@ describe('paired session-tab visibility-resume notifications', () => { }) it('alerts after a transport resubscription preserves the prior baseline', async () => { - const firstHook = renderHook(() => useWebSessionTabsSync()) + const hook = renderHook(() => useWebSessionTabsSync()) await act(settle) await publish(findSubscription('session.tabs.subscribeAll'), { type: 'updated', ...snapshot(1, 'working', NOW) }) - firstHook.unmount() - - const reconnectedHook = renderHook(() => useWebSessionTabsSync()) + mocks.runtimeSessionMirrorEnvironmentKey.mockReturnValue(RECONNECTED_MIRROR_KEY) + hook.rerender() await act(settle) await publish(findSubscription('session.tabs.subscribeAll', 1), { type: 'snapshots', @@ -438,7 +534,7 @@ describe('paired session-tab visibility-resume notifications', () => { expect(notificationDispatch).toHaveBeenCalledTimes(1) expect(badgeCount()).toBe(1) - reconnectedHook.unmount() + hook.unmount() }) it('alerts for a Claude stamped completion while the host row stays working', async () => { diff --git a/tests/e2e/helpers/e2e-profile-removal.ts b/tests/e2e/helpers/e2e-profile-removal.ts new file mode 100644 index 00000000000..e8af4f0b719 --- /dev/null +++ b/tests/e2e/helpers/e2e-profile-removal.ts @@ -0,0 +1,15 @@ +import { rmSync } from 'node:fs' + +export async function removeE2EProfile(userDataDir: string): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + rmSync(userDataDir, { recursive: true, force: true }) + return + } catch (error) { + if (attempt === 4) { + throw error + } + await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1))) + } + } +} diff --git a/tests/e2e/helpers/paired-electron-client.ts b/tests/e2e/helpers/paired-electron-client.ts index 75bb8dce5d0..745ad587d25 100644 --- a/tests/e2e/helpers/paired-electron-client.ts +++ b/tests/e2e/helpers/paired-electron-client.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { randomUUID } from 'node:crypto' import os from 'node:os' import path from 'node:path' @@ -10,6 +10,7 @@ import { } from '@stablyai/playwright-test' import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile' +import { removeE2EProfile } from './e2e-profile-removal' import { getOrcaElectronLaunchArgs } from './electron-launch-args' import { cleanupE2EDaemons, closeElectronAppForE2E } from './electron-process-shutdown' import { @@ -46,6 +47,7 @@ export type PairedWebClient = { dispose: () => Promise } +type BeforeRendererReady = (app: ElectronApplication) => Promise const DIRECT_SSH_PROBE_CANARY_TARGET_ID = '__orca_e2e_direct_ssh_probe_canary__' function readDirectSshAttemptTargetIds(probePath: string): string[] { @@ -59,20 +61,6 @@ function readDirectSshAttemptTargetIds(probePath: string): string[] { } } -async function removeProfile(userDataDir: string): Promise { - for (let attempt = 0; attempt < 5; attempt += 1) { - try { - rmSync(userDataDir, { recursive: true, force: true }) - return - } catch (error) { - if (attempt === 4) { - throw error - } - await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1))) - } - } -} - export async function createRuntimeDesktopPairingOffer( hubPage: Page ): Promise { @@ -142,7 +130,7 @@ export async function launchPairedElectronClient( offer: RuntimeDesktopPairingOffer, testInfo: TestInfo, name: string, - options: { extraEnv?: Record } = {} + options: { beforeRendererReady?: BeforeRendererReady; extraEnv?: Record } = {} ): Promise { const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-paired-desktop-')) const directSshProbePath = path.join(userDataDir, 'forbidden-local-ssh-connects.jsonl') @@ -170,6 +158,7 @@ export async function launchPairedElectronClient( }) try { + await options.beforeRendererReady?.(app) assertElectronResolvedIsolatedHome( await app.evaluate(({ app: electronApp }) => electronApp.getPath('home')), homeIsolation @@ -241,20 +230,19 @@ export async function launchPairedElectronClient( dispose: async () => { await closeElectronAppForE2E(app) await cleanupE2EDaemons(userDataDir) - await removeProfile(userDataDir) + await removeE2EProfile(userDataDir) }, - getDirectSshAttemptTargetIds: async () => { - return readDirectSshAttemptTargetIds(directSshProbePath).filter( + getDirectSshAttemptTargetIds: async () => + readDirectSshAttemptTargetIds(directSshProbePath).filter( (targetId) => targetId !== DIRECT_SSH_PROBE_CANARY_TARGET_ID - ) - }, + ), installDirectSshAttemptProbe: async () => {}, replacePairingInPlace } } catch (error) { await closeElectronAppForE2E(app) await cleanupE2EDaemons(userDataDir) - await removeProfile(userDataDir) + await removeE2EProfile(userDataDir) throw error } } diff --git a/tests/e2e/paired-remote-agent-visibility-notifications.spec.ts b/tests/e2e/paired-remote-agent-visibility-notifications.spec.ts index e5d698e9380..5a3bd0d64c1 100644 --- a/tests/e2e/paired-remote-agent-visibility-notifications.spec.ts +++ b/tests/e2e/paired-remote-agent-visibility-notifications.spec.ts @@ -1,6 +1,7 @@ -import { mkdirSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync } from 'node:fs' import path from 'node:path' import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { CLAUDE_EVENTS } from '../../src/main/claude/hook-settings' import type { AgentHookEndpoint } from '../../src/shared/agent-hook-endpoint-file' import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' import { makePaneKey } from '../../src/shared/stable-pane-id' @@ -18,9 +19,19 @@ import { } from './helpers/paired-electron-client' import { revealPairedClientWindow } from './helpers/paired-client-window-reveal' +const REQUIRED_CLAUDE_EVENTS = ['UserPromptSubmit', 'Stop', 'PermissionRequest'] as const +const NOTIFICATION_BARRIER_SOURCE = 'sta-5244-barrier' type NotificationDispatch = { source?: string; agentState?: string; worktreeId?: string } -type AgentStatusSummary = { state: string; prompt: string; agentType?: string } +type AgentStatusSummary = { + state: string + prompt: string + agentType?: string + toolInput?: unknown +} type TerminalSurface = Extract +type ClaudeSettings = { + hooks?: Record +} async function callEnvironment( page: Page, @@ -45,15 +56,18 @@ async function callEnvironment( } async function installNotificationDispatchSpy(app: ElectronApplication): Promise { - await app.evaluate(({ ipcMain }) => { + await app.evaluate(({ ipcMain }, barrierSource) => { const state = globalThis as unknown as { __sta5244Dispatches?: NotificationDispatch[] } state.__sta5244Dispatches = [] ipcMain.removeHandler('notifications:dispatch') ipcMain.handle('notifications:dispatch', (_event: unknown, input: NotificationDispatch) => { + if (input.source === barrierSource) { + return { delivered: true } + } state.__sta5244Dispatches!.push(input) return { delivered: true } }) - }) + }, NOTIFICATION_BARRIER_SOURCE) } async function notificationDispatches(app: ElectronApplication): Promise { @@ -63,6 +77,35 @@ async function notificationDispatches(app: ElectronApplication): Promise { + await page.evaluate(async (source) => { + const dispatch = window.api.notifications.dispatch as (input: unknown) => Promise + await dispatch({ source }) + }, NOTIFICATION_BARRIER_SOURCE) +} + +async function hasManagedClaudeHooks(host: HeadlessPairedRuntimeHost): Promise { + const home = await host.app.evaluate(({ app }) => app.getPath('home')) + const scriptName = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' + const scriptPath = path.join(home, '.orca', 'agent-hooks', scriptName) + const settingsPath = path.join(home, '.claude', 'settings.json') + if (!existsSync(scriptPath) || !existsSync(settingsPath)) { + return false + } + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as ClaudeSettings + const requiredEvents = new Set([ + ...REQUIRED_CLAUDE_EVENTS, + ...CLAUDE_EVENTS.map(({ eventName }) => eventName) + ]) + return [...requiredEvents].every((eventName) => + settings.hooks?.[eventName]?.some((definition) => + definition.hooks?.some( + ({ command }) => typeof command === 'string' && command.includes('claude-hook') + ) + ) + ) +} + async function postClaudeHook( endpoint: AgentHookEndpoint, paneKey: string, @@ -109,7 +152,12 @@ async function clientStatus(page: Page, paneKey: string): Promise { const status = window.__store?.getState().agentStatusByPaneKey[key] return status - ? { state: status.state, prompt: status.prompt, agentType: status.agentType } + ? { + state: status.state, + prompt: status.prompt, + agentType: status.agentType, + toolInput: status.toolInput + } : null }, paneKey) } @@ -165,10 +213,34 @@ async function revealAfterSubscriptionsPark(client: PairedElectronClient): Promi await revealPairedClientWindow(client) } -async function reconnect(client: PairedElectronClient): Promise { +async function reconnectAndAwaitReplay( + client: PairedElectronClient, + paneKey: string, + expectedState: string +): Promise { + const previousGeneration = await client.page.evaluate( + (environmentId) => + window.__store?.getState().runtimeStatusByEnvironmentId.get(environmentId) + ?.connectionGeneration ?? 0, + client.environmentId + ) await client.page.evaluate(async (selector) => { await window.api.runtimeEnvironments.disconnect({ selector }) }, client.environmentId) + await expect + .poll(async () => { + await client.page.evaluate( + async (environmentId) => + window.__store?.getState().refreshRuntimeEnvironmentStatus(environmentId, 1_000), + client.environmentId + ) + return client.page.evaluate((environmentId) => { + const entry = window.__store?.getState().runtimeStatusByEnvironmentId.get(environmentId) + return entry === undefined || entry.status === null + }, client.environmentId) + }) + .toBe(true) + await clearClientStatus(client.page, paneKey) await expect .poll( () => @@ -179,6 +251,41 @@ async function reconnect(client: PairedElectronClient): Promise { { timeout: 60_000, message: 'paired client did not reconnect' } ) .toBe(true) + await expect + .poll(async () => { + await client.page.evaluate( + async (environmentId) => + window.__store?.getState().refreshRuntimeEnvironmentStatus(environmentId, 1_000), + client.environmentId + ) + return client.page.evaluate( + ({ environmentId, previousGeneration }) => { + const status = window.__store?.getState().runtimeStatusByEnvironmentId.get(environmentId) + return status?.status && (status.connectionGeneration ?? 0) > previousGeneration + }, + { environmentId: client.environmentId, previousGeneration } + ) + }) + .toBeTruthy() + await expect + .poll(() => clientStatus(client.page, paneKey), { + timeout: 30_000, + message: 'paired client did not apply the reconnect replay' + }) + .toMatchObject({ state: expectedState }) + await flushNotificationDispatches(client.page) +} + +async function clearClientStatus(page: Page, paneKey: string): Promise { + await page.evaluate((key) => { + const store = window.__store + if (!store) { + return + } + const next = { ...store.getState().agentStatusByPaneKey } + delete next[key] + store.setState({ agentStatusByPaneKey: next }) + }, paneKey) } async function clearUnread(page: Page, worktreeId: string): Promise { @@ -197,6 +304,7 @@ test('recovers hidden remote completion and permission alerts exactly once', asy let client: PairedElectronClient | null = null let coldClient: PairedElectronClient | null = null let terminal: string | null = null + let testError: unknown try { await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) @@ -241,6 +349,18 @@ test('recovers hidden remote completion and permission alerts exactly once', asy ) const endpoint = await readHookEndpoint(host.app) expect(Number(endpoint.port)).toBeGreaterThan(0) + await expect + .poll(() => hasManagedClaudeHooks(host), { + timeout: 30_000, + message: 'isolated host did not install every managed Claude hook' + }) + .toBe(true) + await revealPairedClientWindow(client) + await expect + .poll(() => + client!.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isVisible()) + ) + .toBe(true) const completionPrompt = `STA-5244 completion ${Date.now()}` await postClaudeHook(endpoint, hostPaneKey, worktreeId, { @@ -364,10 +484,26 @@ test('recovers hidden remote completion and permission alerts exactly once', asy await client.page.screenshot({ path: path.join(screenshots, '03-hidden-permission-pass.png') }) await hideUntilSubscriptionsPark(client) + await postClaudeHook(endpoint, hostPaneKey, worktreeId, { + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + tool_input: { command: 'git status --short' } + }) + await expect + .poll(() => hostSurface(host, worktreeId, surface.parentTabId)) + .toMatchObject({ + agentStatus: { state: 'waiting', toolInput: 'git status --short' } + }) await revealAfterSubscriptionsPark(client) - await expect.poll(() => notificationDispatches(client!.app)).toHaveLength(2) - await reconnect(client) - await expect.poll(() => notificationDispatches(client!.app)).toHaveLength(2) + await expect + .poll(() => clientStatus(client!.page, clientPaneKey), { + message: 'same-turn reveal replay did not settle' + }) + .toMatchObject({ state: 'waiting', toolInput: 'git status --short' }) + await flushNotificationDispatches(client.page) + expect(await notificationDispatches(client.app)).toHaveLength(2) + await reconnectAndAwaitReplay(client, clientPaneKey, 'waiting') + expect(await notificationDispatches(client.app)).toHaveLength(2) await clearUnread(client.page, worktreeId) await expect @@ -377,30 +513,54 @@ test('recovers hidden remote completion and permission alerts exactly once', asy unreadCount: 0, dock: process.platform === 'darwin' ? '' : null }) - coldClient = await launchPairedElectronClient(host.offer, testInfo, 'STA-5244 cold replay') + coldClient = await launchPairedElectronClient(host.offer, testInfo, 'STA-5244 cold replay', { + beforeRendererReady: installNotificationDispatchSpy + }) await expect .poll(() => clientStatus(coldClient!.page, clientPaneKey), { timeout: 30_000, message: 'cold client did not hydrate terminal permission state' }) .toMatchObject({ state: 'waiting' }) + await flushNotificationDispatches(coldClient.page) expect(await unreadBadgeState(coldClient, worktreeId)).toMatchObject({ unread: false, unreadCount: 0, dock: process.platform === 'darwin' ? '' : null }) - await installNotificationDispatchSpy(coldClient.app) - await reconnect(coldClient) - await expect.poll(() => notificationDispatches(coldClient!.app)).toHaveLength(0) + expect(await notificationDispatches(coldClient.app)).toHaveLength(0) + await reconnectAndAwaitReplay(coldClient, clientPaneKey, 'waiting') + expect(await notificationDispatches(coldClient.app)).toHaveLength(0) await coldClient.page.screenshot({ path: path.join(screenshots, '02-cold-permission-silent.png') }) - } finally { - await coldClient?.dispose() - if (terminal) { - await host.client.call('terminal.closeTab', { terminal }).catch(() => undefined) + } catch (error) { + testError = error + } + const cleanupErrors: unknown[] = [] + const cleanupStages = [ + () => coldClient?.dispose() ?? Promise.resolve(), + () => + terminal + ? host.client.call('terminal.closeTab', { terminal }).then(() => undefined) + : Promise.resolve(), + () => client?.dispose() ?? Promise.resolve(), + () => host.dispose() + ] + for (const cleanup of cleanupStages) { + try { + await cleanup() + } catch (error) { + cleanupErrors.push(error) } - await client?.dispose() - await host.dispose() + } + if (testError !== undefined && cleanupErrors.length > 0) { + throw new AggregateError([testError, ...cleanupErrors], 'STA-5244 test and cleanup failed') + } + if (testError !== undefined) { + throw testError + } + if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, 'STA-5244 fixture cleanup failed') } })