mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(notifications): recover parked remote agent alerts
This commit is contained in:
@@ -4809,6 +4809,7 @@
|
||||
"paired viewer-local structured agent focus",
|
||||
"headed desktop remote-server pairing",
|
||||
"headless remote-server parity",
|
||||
"visibility-resume completion and permission attention",
|
||||
"daemon and relay reconnect",
|
||||
"remote completion classification across disconnect and reconnect",
|
||||
"terminal exit retirement and restart restore",
|
||||
@@ -4825,9 +4826,10 @@
|
||||
"https://github.com/stablyai/orca/issues/9352",
|
||||
"https://github.com/stablyai/orca/pull/9687",
|
||||
"https://github.com/stablyai/orca/issues/10192",
|
||||
"https://github.com/stablyai/orca/pull/10193"
|
||||
"https://github.com/stablyai/orca/pull/10193",
|
||||
"https://github.com/stablyai/orca/issues/16102"
|
||||
],
|
||||
"invariant": "For every claim-capable execution route, one provider-session identity has at most one live PTY owner and one canonical host surface across concurrent clients, retries, reconnects, and stale publications. For paired structured fresh and resume requests, the authenticated owning runtime creates in background without a renderer window; activate=true focuses the exact requested leaf only on the requesting viewer, while activate=false changes no viewer focus. A live orphan may be adopted only when the controller proves its exact handle and incarnation, its worktree and host owner match, no competing visual owner exists, and a host topology CAS wins. A viewer may classify completion only from successful host/provider inspection or explicit lifecycle evidence; transport, handle, or provider unavailability remains unknown and breaks any consecutive-idle proof. A physical exit retires that exact incarnation durably so stale client state and host restart cannot recreate it. Mixed-version routes select the unchanged legacy request before any authority side effect or execution-owner-local filesystem access.",
|
||||
"invariant": "For every claim-capable execution route, one provider-session identity has at most one live PTY owner and one canonical host surface across concurrent clients, retries, reconnects, and stale publications. For paired structured fresh and resume requests, the authenticated owning runtime creates in background without a renderer window; activate=true focuses the exact requested leaf only on the requesting viewer, while activate=false changes no viewer focus. A live orphan may be adopted only when the controller proves its exact handle and incarnation, its worktree and host owner match, no competing visual owner exists, and a host topology CAS wins. A viewer may classify completion only from successful host/provider inspection or explicit lifecycle evidence; transport, handle, or provider unavailability remains unknown and breaks any consecutive-idle proof. A visibility resubscription announces each newly accepted remote done/blocked/waiting transition once, while cold inventory and unchanged reconnect replay remain silent. A physical exit retires that exact incarnation durably so stale client state and host restart cannot recreate it. Mixed-version routes select the unchanged legacy request before any authority side effect or execution-owner-local filesystem access.",
|
||||
"oracle": "Race independent clients and repeated operation IDs, then assert one physical spawn and one canonical PTY/surface; inject exit-before-reply, provider disconnect, conflicting claim scope, old daemon/relay capabilities, reused handles, stale incarnations, owner mismatch, and topology revision conflict; assert safe adoption or explicit failure without a second spawn or wrong-process attachment. Run fresh/resume with activate true/false against an isolated headed desktop host and a separate paired client, then against isolated headless serve: assert host presentation stays background, only the requesting viewer focuses the exact leaf, inactive calls preserve client/DOM focus, a same-version publication replay cannot lose focus intent, and sibling-first split publication cannot consume exact-leaf intent. Restore legacy split panes and groups beside a newer host-owned tab, preserving exact predecessor/new/successor order, output, input, resize, titles, tab/leaf identity, active group, and multi-client convergence. For completion, drive a known running agent through outer transport loss, authoritative provider rejection, reconnect, explicit stop, real exit status, and successful hook completion; assert unavailable evidence never dispatches completion and two fresh authoritative idle samples are required after the gap. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, exact tokened fixture PIDs are dead, and unrelated tabs/processes survive until scoped cleanup.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/agent-session.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/web-session-intent-owner.test.ts src/renderer/src/runtime/remote-server-parity.test.ts",
|
||||
@@ -4842,6 +4844,7 @@
|
||||
"Manual headed paired-server journey: isolated Orca desktop host + separate paired web client + real Codex process + 20-second WebSocket fault + reconnect + explicit stop",
|
||||
"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 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": [
|
||||
@@ -4852,6 +4855,7 @@
|
||||
"src/relay/pty-handler-spawn-admission.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"tests/e2e/remote-agent-completion-authority.unit.test.ts",
|
||||
"src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx",
|
||||
"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",
|
||||
@@ -4918,6 +4922,14 @@
|
||||
"explicit stop, real exit status, and genuine successful completion remain distinct"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx",
|
||||
"assertions": [
|
||||
"a hidden paired mirror recovers done, blocked, and waiting exactly once through notification dispatch and unread badge state",
|
||||
"cold inventory, same-turn reconnect replay, and duplicate frames remain silent",
|
||||
"the active scoped stream may win resume ordering without losing or duplicating attention"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/runtime/runtime-terminal-inspection.test.ts",
|
||||
"assertions": [
|
||||
|
||||
@@ -1154,7 +1154,33 @@ export function createAgentCompletionCoordinator(
|
||||
if (isFiniteTurnCompletedAt(turnCompletedAt)) {
|
||||
rememberHandledTurnCompletedAt(turnCompletedAt)
|
||||
}
|
||||
observeHookStatus(unstampedPayload)
|
||||
if (payload.state === 'working') {
|
||||
observeHookStatus(unstampedPayload)
|
||||
return
|
||||
}
|
||||
recordPaneActivity()
|
||||
if (isRecognizedAgentType(payload.agentType)) {
|
||||
establishAgentEvidence()
|
||||
}
|
||||
clearPendingHookDone()
|
||||
clearPendingCodexAttention()
|
||||
if (isAttentionHookState(payload.state)) {
|
||||
lastAttentionToken = hookAttentionToken(payload)
|
||||
return
|
||||
}
|
||||
const identity = hookCompletionIdentity(payload)
|
||||
if (!identity) {
|
||||
return
|
||||
}
|
||||
lastCompletionIdentity = {
|
||||
source: 'hook',
|
||||
identity,
|
||||
agentIdentity: hookCompletionAgentIdentity(payload),
|
||||
...(isFiniteTurnCompletedAt(turnCompletedAt)
|
||||
? { lastTurnCompletedAtNotified: turnCompletedAt }
|
||||
: {})
|
||||
}
|
||||
lastCompletionIdentityByPaneKey.set(options.paneKey, lastCompletionIdentity)
|
||||
}
|
||||
|
||||
function markTitleCompletionNotified(title: string): void {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentCompletionStatusSnapshot } from '@/components/terminal-pane/agent-completion-coordinator-types'
|
||||
|
||||
const dispatchTerminalNotification = vi.fn()
|
||||
const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
const mockStoreState = {
|
||||
settings: {
|
||||
experimentalTerminalAttention: false,
|
||||
notifications: { enabled: true, agentTaskComplete: true }
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
|
||||
suppressedPtyExitIds: {},
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' }] },
|
||||
terminalLayoutsByTabId: {},
|
||||
agentLaunchConfigByPaneKey: {},
|
||||
agentStatusByPaneKey: {},
|
||||
getAgentLaunchConfigForStatusEntry: () => undefined,
|
||||
getAgentLaunchConfigForStatusMetadata: () => undefined
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => mockStoreState } }))
|
||||
vi.mock('@/components/terminal-pane/use-notification-dispatch', () => ({
|
||||
dispatchTerminalNotification
|
||||
}))
|
||||
vi.mock('@/components/terminal-pane/agent-hook-terminal-lifecycle', () => ({
|
||||
dispatchAgentHookTerminalLifecycle: vi.fn()
|
||||
}))
|
||||
|
||||
function status(
|
||||
state: AgentCompletionStatusSnapshot['state'],
|
||||
stateStartedAt: number,
|
||||
agentType = 'codex'
|
||||
): AgentCompletionStatusSnapshot {
|
||||
return { state, stateStartedAt, prompt: 'implement notifications', agentType }
|
||||
}
|
||||
|
||||
describe('agent hook inventory notification baselines', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
dispatchTerminalNotification.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('seeds cold terminal identity and observes only a later transition', async () => {
|
||||
const { observeAgentHookCompletionForNotification: observe } =
|
||||
await import('./agent-hook-completion-notifications')
|
||||
const coldDone = status('done', 100)
|
||||
|
||||
observe({ paneKey: PANE_KEY, worktreeId: 'wt-1', payload: coldDone, seedOnly: true })
|
||||
observe({
|
||||
paneKey: PANE_KEY,
|
||||
worktreeId: 'wt-1',
|
||||
payload: { ...coldDone, lastAssistantMessage: 'metadata refresh' },
|
||||
seedOnly: true
|
||||
})
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
observe({
|
||||
paneKey: PANE_KEY,
|
||||
worktreeId: 'wt-1',
|
||||
payload: status('done', 200),
|
||||
seedOnly: true
|
||||
})
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('seeds cold attention identity and observes only a later permission prompt', async () => {
|
||||
const { observeAgentHookCompletionForNotification: observe } =
|
||||
await import('./agent-hook-completion-notifications')
|
||||
const coldBlocked = status('blocked', 100)
|
||||
|
||||
observe({ paneKey: PANE_KEY, worktreeId: 'wt-1', payload: coldBlocked, seedOnly: true })
|
||||
observe({ paneKey: PANE_KEY, worktreeId: 'wt-1', payload: coldBlocked, seedOnly: true })
|
||||
vi.advanceTimersByTime(1_500)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
observe({
|
||||
paneKey: PANE_KEY,
|
||||
worktreeId: 'wt-1',
|
||||
payload: status('blocked', 200),
|
||||
seedOnly: true
|
||||
})
|
||||
vi.advanceTimersByTime(1_500)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('observes a stamped Claude completion after a cold working seed', async () => {
|
||||
const { observeAgentHookCompletionForNotification: observe } =
|
||||
await import('./agent-hook-completion-notifications')
|
||||
|
||||
observe({
|
||||
paneKey: PANE_KEY,
|
||||
worktreeId: 'wt-1',
|
||||
payload: status('working', 100, 'claude'),
|
||||
seedOnly: true
|
||||
})
|
||||
observe({
|
||||
paneKey: PANE_KEY,
|
||||
worktreeId: 'wt-1',
|
||||
payload: { ...status('working', 100, 'claude'), turnCompletedAt: 200 },
|
||||
seedOnly: true
|
||||
})
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -310,6 +310,7 @@ 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 = {
|
||||
@@ -326,7 +327,7 @@ export function observeAgentHookCompletionForNotification({
|
||||
if (payload.state === 'working' && payload.turnCompletedAt === undefined && trackingEnabled) {
|
||||
paneKeysRequiringFreshWorking.delete(paneKey)
|
||||
}
|
||||
if (seedOnly === true) {
|
||||
if (shouldSeed) {
|
||||
entry.coordinator.seedHookStatus(payload)
|
||||
} else {
|
||||
entry.coordinator.observeHookStatus(payload)
|
||||
|
||||
@@ -128,6 +128,17 @@ describe('paired session-tab agent completion notifications', () => {
|
||||
expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it.each(['done', 'blocked'] as const)('forwards cold %s inventory as a silent seed', (state) => {
|
||||
applySnapshot(makeAgentSnapshot(1, NOW, undefined, state), false)
|
||||
|
||||
expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledWith({
|
||||
paneKey: makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID),
|
||||
worktreeId: WORKTREE_ID,
|
||||
seedOnly: true,
|
||||
payload: expect.objectContaining({ state, stateStartedAt: NOW })
|
||||
})
|
||||
})
|
||||
|
||||
it('announces one live stamped completion after a late-pair working seed', () => {
|
||||
const dispatchCompletion = vi.fn()
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
|
||||
@@ -3837,9 +3837,6 @@ export function applyWebSessionTabsStorePatch(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!allowCompletionNotification && notificationStatus.state !== 'working') {
|
||||
continue
|
||||
}
|
||||
acceptedNotificationStatuses.push({
|
||||
paneKey: notificationStatus.paneKey,
|
||||
worktreeId: notificationStatus.worktreeId ?? snapshot.worktree,
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
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 { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments'
|
||||
import { getUnreadBadgeCount } from '@/lib/unread-badge-count'
|
||||
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type * as WorktreeRuntimeOwnerModule from '@/lib/worktree-runtime-owner'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getExplicitRuntimeEnvironmentIdForWorktree: vi.fn(),
|
||||
observeAgentHookCompletionForNotification: vi.fn(),
|
||||
recoverSnapshot: vi.fn(),
|
||||
runtimeSessionMirrorEnvironmentKey: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./use-runtime-session-mirror-environment-key', () => ({
|
||||
useRuntimeSessionMirrorEnvironmentKey: mocks.runtimeSessionMirrorEnvironmentKey
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-runtime-owner', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WorktreeRuntimeOwnerModule>()
|
||||
return {
|
||||
...actual,
|
||||
getExplicitRuntimeEnvironmentIdForWorktree: mocks.getExplicitRuntimeEnvironmentIdForWorktree
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./web-session-terminal-orphan-recovery', () => ({
|
||||
recoverWebSessionTerminalOrphansBeforeApply: mocks.recoverSnapshot
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/agent-hook-completion-notifications', () => ({
|
||||
observeAgentHookCompletionForNotification: mocks.observeAgentHookCompletionForNotification
|
||||
}))
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
createAgentCompletionCoordinator,
|
||||
resetAgentCompletionCoordinatorIdentitiesForTest
|
||||
} from '@/components/terminal-pane/agent-completion-coordinator'
|
||||
import {
|
||||
markRendererOwnedAgentStatusWrite,
|
||||
registerRendererOwnedAgentStatusPane,
|
||||
resetRendererOwnedAgentStatusPanesForTests
|
||||
} from '@/components/terminal-pane/renderer-owned-agent-status-registry'
|
||||
import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision'
|
||||
import {
|
||||
resetWebSessionTabsSnapshotFreshnessForTests,
|
||||
useWebSessionTabsSync
|
||||
} from './web-session-tabs-sync'
|
||||
import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS } from './window-visibility-subscription-parking'
|
||||
|
||||
const ENVIRONMENT_ID = 'env-a'
|
||||
const RUNTIME_ID = 'runtime-a'
|
||||
const REVISION = 101
|
||||
const REPO_ID = 'repo-a'
|
||||
const WORKTREE_ID = `${REPO_ID}::/worktree-a`
|
||||
const OTHER_WORKTREE_ID = `${REPO_ID}::/other`
|
||||
const HOST_TAB_ID = 'host-tab-1'
|
||||
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 initialState = useAppStore.getInitialState()
|
||||
|
||||
type RuntimeSubscribe = typeof window.api.runtimeEnvironments.subscribe
|
||||
type RuntimeSubscription = {
|
||||
request: Parameters<RuntimeSubscribe>[0]
|
||||
callbacks: Parameters<RuntimeSubscribe>[1]
|
||||
unsubscribe: ReturnType<typeof vi.fn>
|
||||
}
|
||||
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 runtimeSubscribe = vi.fn<RuntimeSubscribe>(async (request, callbacks) => {
|
||||
const unsubscribe = vi.fn()
|
||||
subscriptions.push({ request, callbacks, unsubscribe })
|
||||
return { unsubscribe, sendBinary: vi.fn() }
|
||||
})
|
||||
|
||||
function snapshot(
|
||||
snapshotVersion: number,
|
||||
state: TerminalState,
|
||||
stateStartedAt: number,
|
||||
updatedAt = stateStartedAt,
|
||||
turnCompletedAt?: number,
|
||||
publicationEpoch = 'epoch-1'
|
||||
): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch,
|
||||
snapshotVersion,
|
||||
activeGroupId: 'host-group-1',
|
||||
activeTabId: HOST_TAB_ID,
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `${HOST_TAB_ID}::${LEAF_ID}`,
|
||||
title: 'Claude',
|
||||
parentTabId: HOST_TAB_ID,
|
||||
leafId: LEAF_ID,
|
||||
isActive: true,
|
||||
status: 'ready',
|
||||
terminal: 'terminal-1',
|
||||
...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}),
|
||||
agentStatus: {
|
||||
state,
|
||||
prompt: 'review the PR',
|
||||
updatedAt,
|
||||
stateStartedAt,
|
||||
agentType: 'claude',
|
||||
paneKey: makePaneKey(HOST_TAB_ID, LEAF_ID),
|
||||
tabId: HOST_TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
]
|
||||
} as RuntimeMobileSessionTabsResult
|
||||
}
|
||||
|
||||
function setDocumentVisibility(state: 'visible' | 'hidden'): void {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => state
|
||||
})
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function findSubscription(
|
||||
method: 'session.tabs.subscribeAll' | 'session.tabs.subscribe',
|
||||
occurrence = 0
|
||||
): RuntimeSubscription {
|
||||
const subscription = subscriptions.filter(({ request }) => request.method === method)[occurrence]
|
||||
if (!subscription) {
|
||||
throw new Error(`Missing ${method} subscription ${occurrence}`)
|
||||
}
|
||||
return subscription
|
||||
}
|
||||
|
||||
async function publish(
|
||||
subscription: RuntimeSubscription,
|
||||
result: unknown,
|
||||
replayed = false
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
const response = {
|
||||
id: 'subscription-event',
|
||||
ok: true as const,
|
||||
result,
|
||||
_meta: { runtimeId: RUNTIME_ID }
|
||||
}
|
||||
subscription.callbacks.onResponse(
|
||||
replayed ? tagRuntimeSubscriptionReplayResponse(response) : response
|
||||
)
|
||||
await settle()
|
||||
})
|
||||
}
|
||||
|
||||
function seedRemoteMirrorState(activeRemoteWorktree = false): void {
|
||||
const runtimeEnvironments = [
|
||||
{ id: ENVIRONMENT_ID, createdAt: 100, pairingRevision: REVISION }
|
||||
] as PublicKnownRuntimeEnvironment[]
|
||||
replaceRuntimeEnvironmentRevisions(runtimeEnvironments)
|
||||
useAppStore.setState(
|
||||
{
|
||||
...initialState,
|
||||
activeWorktreeId: activeRemoteWorktree ? WORKTREE_ID : OTHER_WORKTREE_ID,
|
||||
workspaceSessionReady: true,
|
||||
runtimeEnvironments,
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
[ENVIRONMENT_ID, { status: { runtimeId: RUNTIME_ID }, connectionGeneration: 1 }]
|
||||
]) as AppState['runtimeStatusByEnvironmentId'],
|
||||
worktreesByRepo: {
|
||||
[REPO_ID]: [
|
||||
makeWorktree({ id: WORKTREE_ID, repoId: REPO_ID, path: '/worktree-a' }),
|
||||
makeWorktree({ id: OTHER_WORKTREE_ID, repoId: REPO_ID, path: '/other' })
|
||||
]
|
||||
}
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
function markUnread(): void {
|
||||
useAppStore.setState((state) => ({
|
||||
worktreesByRepo: {
|
||||
...state.worktreesByRepo,
|
||||
[REPO_ID]: state.worktreesByRepo[REPO_ID].map((worktree) =>
|
||||
worktree.id === WORKTREE_ID ? { ...worktree, isUnread: true } : worktree
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function badgeCount(): number {
|
||||
return getUnreadBadgeCount(useAppStore.getState())
|
||||
}
|
||||
|
||||
async function parkAndReveal(): Promise<void> {
|
||||
act(() => {
|
||||
setDocumentVisibility('hidden')
|
||||
vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS)
|
||||
setDocumentVisibility('visible')
|
||||
})
|
||||
await act(settle)
|
||||
}
|
||||
|
||||
describe('paired session-tab visibility-resume notifications', () => {
|
||||
let notificationDispatch: Mock<(kind: 'done' | 'attention') => void>
|
||||
let disposeCoordinator: () => void
|
||||
let hasNotificationBaseline: boolean
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
subscriptions.length = 0
|
||||
runtimeCall.mockClear()
|
||||
runtimeSubscribe.mockClear()
|
||||
mocks.getExplicitRuntimeEnvironmentIdForWorktree.mockReset().mockReturnValue(null)
|
||||
mocks.recoverSnapshot.mockReset().mockImplementation(async (_state, value) => value)
|
||||
mocks.runtimeSessionMirrorEnvironmentKey.mockReset().mockReturnValue(MIRROR_KEY)
|
||||
mocks.observeAgentHookCompletionForNotification.mockReset()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } }
|
||||
})
|
||||
setDocumentVisibility('visible')
|
||||
resetAgentCompletionCoordinatorIdentitiesForTest()
|
||||
resetWebSessionTabsSnapshotFreshnessForTests()
|
||||
resetRendererOwnedAgentStatusPanesForTests()
|
||||
seedRemoteMirrorState()
|
||||
hasNotificationBaseline = false
|
||||
|
||||
notificationDispatch = vi.fn()
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: PANE_KEY,
|
||||
statusLane: 'hook',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion: () => {
|
||||
notificationDispatch('done')
|
||||
markUnread()
|
||||
},
|
||||
dispatchAttention: () => {
|
||||
notificationDispatch('attention')
|
||||
markUnread()
|
||||
},
|
||||
isLive: () => true
|
||||
})
|
||||
disposeCoordinator = () => coordinator.dispose()
|
||||
mocks.observeAgentHookCompletionForNotification.mockImplementation(({ payload, seedOnly }) => {
|
||||
if (seedOnly && !hasNotificationBaseline) {
|
||||
coordinator.seedHookStatus(payload)
|
||||
} else {
|
||||
coordinator.observeHookStatus(payload)
|
||||
}
|
||||
hasNotificationBaseline = true
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
disposeCoordinator()
|
||||
cleanup()
|
||||
useAppStore.setState(initialState, true)
|
||||
replaceRuntimeEnvironmentRevisions([])
|
||||
resetAgentCompletionCoordinatorIdentitiesForTest()
|
||||
resetWebSessionTabsSnapshotFreshnessForTests()
|
||||
resetRendererOwnedAgentStatusPanesForTests()
|
||||
setDocumentVisibility('visible')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.each(['done', 'blocked', 'waiting'] as const)(
|
||||
'alerts once for %s reached while the hidden mirror is parked',
|
||||
async (state) => {
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll'), {
|
||||
type: 'updated',
|
||||
...snapshot(1, 'working', NOW)
|
||||
})
|
||||
notificationDispatch.mockClear()
|
||||
|
||||
await parkAndReveal()
|
||||
await publish(findSubscription('session.tabs.subscribeAll', 1), {
|
||||
type: 'snapshots',
|
||||
snapshots: [snapshot(2, state, NOW + 1_000)]
|
||||
})
|
||||
vi.advanceTimersByTime(1_500)
|
||||
|
||||
expect(notificationDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(badgeCount()).toBe(1)
|
||||
hook.unmount()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps cold inventory silent', async () => {
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
|
||||
await publish(findSubscription('session.tabs.subscribeAll'), {
|
||||
type: 'snapshots',
|
||||
snapshots: [snapshot(1, 'done', NOW)]
|
||||
})
|
||||
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) => {
|
||||
setDocumentVisibility('hidden')
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
|
||||
act(() => setDocumentVisibility('visible'))
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll'), {
|
||||
type: 'snapshots',
|
||||
snapshots: [snapshot(1, state, NOW)]
|
||||
})
|
||||
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) => {
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
const global = findSubscription('session.tabs.subscribeAll')
|
||||
await publish(global, {
|
||||
type: 'snapshots',
|
||||
snapshots: [snapshot(1, state, NOW)]
|
||||
})
|
||||
await publish(global, {
|
||||
type: 'snapshots',
|
||||
snapshots: [snapshot(2, state, NOW, NOW + 5_000)]
|
||||
})
|
||||
vi.advanceTimersByTime(1_500)
|
||||
|
||||
expect(notificationDispatch).not.toHaveBeenCalled()
|
||||
expect(badgeCount()).toBe(0)
|
||||
hook.unmount()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps same-turn reconnect replay and duplicate frames silent', async () => {
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
const global = findSubscription('session.tabs.subscribeAll')
|
||||
await publish(global, { type: 'updated', ...snapshot(1, 'working', NOW) })
|
||||
await publish(global, { type: 'updated', ...snapshot(2, 'done', NOW + 1_000) })
|
||||
vi.advanceTimersByTime(1_500)
|
||||
expect(notificationDispatch).toHaveBeenCalledTimes(1)
|
||||
|
||||
await publish(
|
||||
global,
|
||||
{ type: 'snapshots', snapshots: [snapshot(2, 'done', NOW + 1_000)] },
|
||||
true
|
||||
)
|
||||
await publish(global, {
|
||||
type: 'snapshots',
|
||||
snapshots: [snapshot(3, 'done', NOW + 1_000)]
|
||||
})
|
||||
vi.advanceTimersByTime(1_500)
|
||||
|
||||
expect(notificationDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(badgeCount()).toBe(1)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('alerts for a new transition even when the first resume frame is replay-tagged', async () => {
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll'), {
|
||||
type: 'updated',
|
||||
...snapshot(1, 'working', NOW)
|
||||
})
|
||||
notificationDispatch.mockClear()
|
||||
|
||||
await parkAndReveal()
|
||||
await publish(
|
||||
findSubscription('session.tabs.subscribeAll', 1),
|
||||
{ type: 'snapshots', snapshots: [snapshot(2, 'blocked', NOW + 1_000)] },
|
||||
true
|
||||
)
|
||||
|
||||
expect(notificationDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(badgeCount()).toBe(1)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('alerts after a transport resubscription preserves the prior baseline', async () => {
|
||||
const firstHook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll'), {
|
||||
type: 'updated',
|
||||
...snapshot(1, 'working', NOW)
|
||||
})
|
||||
firstHook.unmount()
|
||||
|
||||
const reconnectedHook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll', 1), {
|
||||
type: 'snapshots',
|
||||
snapshots: [snapshot(2, 'done', NOW + 1_000)]
|
||||
})
|
||||
vi.advanceTimersByTime(1_500)
|
||||
|
||||
expect(notificationDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(badgeCount()).toBe(1)
|
||||
reconnectedHook.unmount()
|
||||
})
|
||||
|
||||
it('alerts for a Claude stamped completion while the host row stays working', async () => {
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll'), {
|
||||
type: 'updated',
|
||||
...snapshot(1, 'working', NOW)
|
||||
})
|
||||
notificationDispatch.mockClear()
|
||||
|
||||
await parkAndReveal()
|
||||
await publish(findSubscription('session.tabs.subscribeAll', 1), {
|
||||
type: 'snapshots',
|
||||
snapshots: [snapshot(2, 'working', NOW, NOW + 1_000, NOW + 1_000)]
|
||||
})
|
||||
|
||||
expect(notificationDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(badgeCount()).toBe(1)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('alerts across a restarted host publication epoch without comparing clocks', async () => {
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll'), {
|
||||
type: 'updated',
|
||||
...snapshot(10, 'working', NOW, NOW, undefined, 'epoch-1')
|
||||
})
|
||||
notificationDispatch.mockClear()
|
||||
|
||||
await parkAndReveal()
|
||||
await publish(findSubscription('session.tabs.subscribeAll', 1), {
|
||||
type: 'snapshots',
|
||||
snapshots: [snapshot(1, 'blocked', NOW - 100_000, NOW, undefined, 'epoch-2')]
|
||||
})
|
||||
|
||||
expect(notificationDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(badgeCount()).toBe(1)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('recovers a blocked prompt when the client and host clocks disagree', async () => {
|
||||
registerRendererOwnedAgentStatusPane(PANE_KEY, ENVIRONMENT_ID)
|
||||
markRendererOwnedAgentStatusWrite(PANE_KEY)
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: {
|
||||
[PANE_KEY]: {
|
||||
state: 'working',
|
||||
prompt: 'review the PR',
|
||||
updatedAt: NOW + 100_000,
|
||||
stateStartedAt: NOW + 100_000,
|
||||
agentType: 'claude',
|
||||
paneKey: PANE_KEY,
|
||||
tabId: toWebTerminalSurfaceTabId(HOST_TAB_ID),
|
||||
worktreeId: WORKTREE_ID,
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
})
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll'), {
|
||||
type: 'updated',
|
||||
...snapshot(1, 'working', NOW)
|
||||
})
|
||||
notificationDispatch.mockClear()
|
||||
|
||||
await parkAndReveal()
|
||||
await publish(findSubscription('session.tabs.subscribeAll', 1), {
|
||||
type: 'snapshots',
|
||||
snapshots: [snapshot(2, 'blocked', NOW + 1_000, NOW + 200_000)]
|
||||
})
|
||||
|
||||
expect(notificationDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(badgeCount()).toBe(1)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('alerts once when the resumed active stream wins the inventory race', async () => {
|
||||
seedRemoteMirrorState(true)
|
||||
mocks.getExplicitRuntimeEnvironmentIdForWorktree.mockReturnValue(ENVIRONMENT_ID)
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll'), {
|
||||
type: 'updated',
|
||||
...snapshot(1, 'working', NOW)
|
||||
})
|
||||
notificationDispatch.mockClear()
|
||||
|
||||
await parkAndReveal()
|
||||
await publish(findSubscription('session.tabs.subscribe', 1), {
|
||||
type: 'snapshot',
|
||||
...snapshot(2, 'done', NOW + 1_000)
|
||||
})
|
||||
vi.advanceTimersByTime(1_500)
|
||||
await publish(findSubscription('session.tabs.subscribeAll', 1), {
|
||||
type: 'snapshots',
|
||||
snapshots: [snapshot(2, 'done', NOW + 1_000)]
|
||||
})
|
||||
|
||||
expect(notificationDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(badgeCount()).toBe(1)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('alerts once when the resumed inventory wins the active-stream race', async () => {
|
||||
seedRemoteMirrorState(true)
|
||||
mocks.getExplicitRuntimeEnvironmentIdForWorktree.mockReturnValue(ENVIRONMENT_ID)
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll'), {
|
||||
type: 'updated',
|
||||
...snapshot(1, 'working', NOW)
|
||||
})
|
||||
notificationDispatch.mockClear()
|
||||
|
||||
await parkAndReveal()
|
||||
await publish(findSubscription('session.tabs.subscribeAll', 1), {
|
||||
type: 'snapshots',
|
||||
snapshots: [snapshot(2, 'done', NOW + 1_000)]
|
||||
})
|
||||
vi.advanceTimersByTime(1_500)
|
||||
await publish(findSubscription('session.tabs.subscribe', 1), {
|
||||
type: 'snapshot',
|
||||
...snapshot(2, 'done', NOW + 1_000)
|
||||
})
|
||||
|
||||
expect(notificationDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(badgeCount()).toBe(1)
|
||||
hook.unmount()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user