test(notifications): harden remote replay proof

This commit is contained in:
Jinwoo-H
2026-08-23 20:07:02 -04:00
parent ead459a0f9
commit 23d7503c8a
11 changed files with 442 additions and 72 deletions
+9 -1
View File
@@ -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": [
@@ -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()
@@ -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({
@@ -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
@@ -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)
@@ -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)
@@ -3734,7 +3734,9 @@ export function applyWebSessionTabsStorePatch(
buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial<WebSessionTabsSyncState>,
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<string, VisibilityResumeOmission>())
const visibilityResumeNotificationBaselinesRef = useRef(new Map<string, ReadonlySet<string>>())
const mirroredSessionTabsOwnerRevisionByEnvironmentRef = useRef(
new Map<string, number | undefined>()
)
@@ -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<string, VisibilityResumeEnvironment>()
const notificationBaselines = new Map<string, ReadonlySet<string>>()
const trackedWorktreeIds = new Set<string>()
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<unknown>,
isCurrent: () => boolean,
receivedFrame: number
receivedFrame: number,
allowCompletionNotification: boolean
): Promise<HostSessionMirrorSettle | null> => {
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(
@@ -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<RuntimeRpcResponse<unknown>> => ({
id: 'list-all',
ok: true as const,
result: { snapshots: [] },
_meta: { runtimeId: RUNTIME_ID }
})
)
const runtimeSubscribe = vi.fn<RuntimeSubscribe>(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<void> {
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 () => {
+15
View File
@@ -0,0 +1,15 @@
import { rmSync } from 'node:fs'
export async function removeE2EProfile(userDataDir: string): Promise<void> {
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)))
}
}
}
+10 -22
View File
@@ -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<void>
}
type BeforeRendererReady = (app: ElectronApplication) => Promise<void>
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<void> {
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<RuntimeDesktopPairingOffer> {
@@ -142,7 +130,7 @@ export async function launchPairedElectronClient(
offer: RuntimeDesktopPairingOffer,
testInfo: TestInfo,
name: string,
options: { extraEnv?: Record<string, string> } = {}
options: { beforeRendererReady?: BeforeRendererReady; extraEnv?: Record<string, string> } = {}
): Promise<PairedElectronClient> {
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
}
}
@@ -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<RuntimeMobileSessionTabsResult['tabs'][number], { type: 'terminal' }>
type ClaudeSettings = {
hooks?: Record<string, { hooks?: { command?: unknown }[] }[]>
}
async function callEnvironment<TResult>(
page: Page,
@@ -45,15 +56,18 @@ async function callEnvironment<TResult>(
}
async function installNotificationDispatchSpy(app: ElectronApplication): Promise<void> {
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<NotificationDispatch[]> {
@@ -63,6 +77,35 @@ async function notificationDispatches(app: ElectronApplication): Promise<Notific
})
}
async function flushNotificationDispatches(page: Page): Promise<void> {
await page.evaluate(async (source) => {
const dispatch = window.api.notifications.dispatch as (input: unknown) => Promise<unknown>
await dispatch({ source })
}, NOTIFICATION_BARRIER_SOURCE)
}
async function hasManagedClaudeHooks(host: HeadlessPairedRuntimeHost): Promise<boolean> {
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<string>([
...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<AgentStatusSum
return page.evaluate((key) => {
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<void> {
async function reconnectAndAwaitReplay(
client: PairedElectronClient,
paneKey: string,
expectedState: string
): Promise<void> {
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<void> {
{ 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<void> {
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<void> {
@@ -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')
}
})