diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 61ef053cb10..c5e4175acc0 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -2882,7 +2882,8 @@ export default function SessionScreen() { worktree: `id:${worktreeId}`, tabId: matchingTab.id, notifyClients: false, - navigation: 'caller' + navigation: 'caller', + intent: 'user' }).catch(() => {}) } } @@ -2922,7 +2923,8 @@ export default function SessionScreen() { worktree: `id:${worktreeId}`, tabId: tab.id, notifyClients: false, - navigation: 'caller' + navigation: 'caller', + intent: 'user' }).catch(() => {}) } return @@ -2946,7 +2948,8 @@ export default function SessionScreen() { worktree: `id:${worktreeId}`, tabId: tab.id, notifyClients: false, - navigation: 'caller' + navigation: 'caller', + intent: 'user' }).catch(() => {}) } if (tab.type === 'browser') { @@ -4197,7 +4200,10 @@ export default function SessionScreen() { tabId: activePendingTerminalTab.id, leafId: activePendingTerminalTab.leafId, notifyClients: false, - navigation: 'caller' + navigation: 'caller', + // Why: this only ever runs for the tab the user is looking at, so it is the + // tail of their tap — the gesture that materializes a parked pane. + intent: 'user' }) .then((response) => { if (!response.ok) { diff --git a/mobile/src/session/mobile-session-tab-activation.test.ts b/mobile/src/session/mobile-session-tab-activation.test.ts index 5e7503f24d4..8c490dffca5 100644 --- a/mobile/src/session/mobile-session-tab-activation.test.ts +++ b/mobile/src/session/mobile-session-tab-activation.test.ts @@ -45,7 +45,8 @@ describe('mobile session tab activation', () => { tabId: 'tab-1', leafId: 'leaf-1', notifyClients: false as const, - navigation: 'caller' as const + navigation: 'caller' as const, + intent: 'user' as const } await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({ diff --git a/mobile/src/session/mobile-session-tab-activation.ts b/mobile/src/session/mobile-session-tab-activation.ts index 8a8bd80fc96..ff8353459b4 100644 --- a/mobile/src/session/mobile-session-tab-activation.ts +++ b/mobile/src/session/mobile-session-tab-activation.ts @@ -1,3 +1,4 @@ +import type { TabActivationIntent } from '../../../src/shared/tab-activation-intent' import type { RpcClient } from '../transport/rpc-client' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import type { RpcResponse } from '../transport/types' @@ -15,6 +16,8 @@ type MobileSessionTabActivationParams = { leafId?: string notifyClients: false navigation: 'caller' + /** Required so each call site declares whether a user asked for this. */ + intent: TabActivationIntent } async function retryIdempotentActivationAfterCutover( diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index c481a0a4239..58cf61c533a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -79,6 +79,7 @@ import { } from '../../shared/runtime-types' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' +import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, TERMINAL_INPUT_MAX_BYTES, @@ -1413,9 +1414,14 @@ function makeHeadlessTerminalLayout( } } -function makeRuntimeStoreWithWorkspaceSession(initialSession: WorkspaceSessionState): { +function makeRuntimeStoreWithWorkspaceSession( + initialSession: WorkspaceSessionState, + // Why: sessions are partitioned by execution host, so the stub must answer for + // one partition only — a loose stub lets a hardcoded host id pass unnoticed. + ownerHostId = 'local' +): { runtimeStore: typeof store & { - getWorkspaceSession: () => WorkspaceSessionState + getWorkspaceSession: (hostId?: string) => WorkspaceSessionState setWorkspaceSession: ReturnType persistPtyBinding: ReturnType } @@ -1428,7 +1434,8 @@ function makeRuntimeStoreWithWorkspaceSession(initialSession: WorkspaceSessionSt } const runtimeStore = { ...store, - getWorkspaceSession: () => session, + getWorkspaceSession: (hostId?: string) => + hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(), setWorkspaceSession: vi.fn(setSession), persistPtyBinding: vi.fn( (args: { worktreeId: string; tabId: string; leafId: string; ptyId: string }) => { @@ -29145,6 +29152,293 @@ describe('OrcaRuntimeService', () => { }) }) + describe('deliberately parked pane activation (STA-3465)', () => { + function makeParkedSessionStore( + origin: SleepingAgentSessionRecord['origin'] | undefined, + overrides: Partial = {}, + ownerHostId = 'local' + ) { + return makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + sleepingAgentSessionsByPaneKey: { + [`host-tab:${HEADLESS_LEAF_ID}`]: { + paneKey: `host-tab:${HEADLESS_LEAF_ID}`, + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + agent: 'claude', + providerSession: { key: 'session_id', id: 'provider-session-1' }, + prompt: 'do the thing', + state: 'done', + capturedAt: 1, + updatedAt: 1, + ...(origin ? { origin } : {}), + ...overrides + } as SleepingAgentSessionRecord + } + }), + ownerHostId + ) + } + + function makeParkedRuntime(runtimeStore: unknown): { + runtime: OrcaRuntimeService + spawn: ReturnType + } { + const spawn = vi.fn().mockResolvedValue({ id: 'persisted-pty' }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + return { runtime, spawn } + } + + function setParkedRuntimeNotifier( + runtime: OrcaRuntimeService, + resumeSleepingAgents: (worktreeId: string) => void + ): void { + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + resumeSleepingAgents, + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + } + + const userActivate = ( + runtime: OrcaRuntimeService, + leafId?: string + ): Promise => + runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', leafId, { + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + + const automaticActivate = ( + runtime: OrcaRuntimeService, + leafId?: string + ): Promise => + runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', leafId, { + notifyClients: false, + navigation: 'caller', + intent: 'automatic' + }) + + it('refuses an automatic reconnect probe for a deliberately slept pane', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await automaticActivate(runtime) + + expect(spawn).not.toHaveBeenCalled() + expect(activated.tabs[0]).toMatchObject({ + type: 'terminal', + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + status: 'pending-handle', + terminal: null + }) + // Negative safety: refusing to wake must not retire the surface either. + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs[0]).toMatchObject( + { parentTabId: 'host-tab', status: 'pending-handle' } + ) + }) + + it('refuses an automatic probe that carries the leafId the probe sends', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await automaticActivate(runtime, HEADLESS_LEAF_ID) + + expect(spawn).not.toHaveBeenCalled() + expect(activated.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null }) + }) + + // Why: opening the tab is the documented wake gesture for a slept pane + // (#11598). These four cover every topology, because three of them never + // clear the record — the pane's own activation is the only thing that wakes it. + it('materializes a slept pane for a user tap under headless serve, which never wakes', async () => { + const { runtimeStore, getSession } = makeParkedSessionStore('worktree-sleep') + const resumeSleepingAgents = vi.fn() + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + setParkedRuntimeNotifier(runtime, resumeSleepingAgents) + electronMocks.BrowserWindow.fromId.mockReturnValue(null as never) + + const worktreeActivation = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'mobile' + }) + + expect(worktreeActivation.sleepingAgentWake).toBe('unsupported-headless') + expect(resumeSleepingAgents).not.toHaveBeenCalled() + expect( + getSession().sleepingAgentSessionsByPaneKey?.[`host-tab:${HEADLESS_LEAF_ID}`]?.origin + ).toBe('worktree-sleep') + + const activated = await userActivate(runtime, HEADLESS_LEAF_ID) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + it('materializes a slept pane for a paired desktop client tab click, which asks for no wake', async () => { + const { runtimeStore, getSession } = makeParkedSessionStore('worktree-sleep') + const resumeSleepingAgents = vi.fn() + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + setParkedRuntimeNotifier(runtime, resumeSleepingAgents) + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + + await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'runtime' + }) + + expect(resumeSleepingAgents).not.toHaveBeenCalled() + expect( + getSession().sleepingAgentSessionsByPaneKey?.[`host-tab:${HEADLESS_LEAF_ID}`] + ).toBeDefined() + + const activated = await userActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + // Why: manual sleep of a finished agent stamps restoreOnTabOpenOnly, which the + // background wake skips and resume classifies pane-owned, so the record survives. + it('materializes a slept pane whose completed-agent record is restore-on-tab-open-only', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep', { + state: 'done', + restoreOnTabOpenOnly: true + }) + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await userActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + // Why: manual sleep of a running agent is the one topology whose wake relaunches + // and clears the record, so the pane must materialize with the record gone too. + it('materializes a slept running-agent pane after its wake cleared the record', async () => { + const { runtimeStore, getSession, setSession } = makeParkedSessionStore('worktree-sleep', { + state: 'working' + }) + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + const woken = structuredClone(getSession()) + delete woken.sleepingAgentSessionsByPaneKey?.[`host-tab:${HEADLESS_LEAF_ID}`] + setSession(woken) + + const activated = await userActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + // Why: the field is additive, so a client that predates it sends nothing and + // must keep its wake gesture rather than silently losing it. + it('treats an absent intent as a user activation', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await runtime.activateMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + 'host-tab', + undefined, + { notifyClients: false, navigation: 'caller' } + ) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + // Why: #11542's reconnect fix depends on an automatic activate materializing a + // genuinely awaiting pane. These four prove the park guard did not break it. + it('still materializes a pane awaiting reconnect with no sleeping record', async () => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await automaticActivate(runtime) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + sessionId: 'persisted-pty' + }) + ) + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + it('still materializes a pane whose record was captured while it was live', async () => { + const { runtimeStore } = makeParkedSessionStore('live') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await automaticActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + it('still materializes a pane whose record was captured at app quit', async () => { + const { runtimeStore } = makeParkedSessionStore('quit') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await automaticActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + it('ignores a park record that belongs to a different worktree', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep', { + worktreeId: 'other-repo::/other' + }) + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await automaticActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + // Why: sleeping records live in the owning execution host's session partition, + // so reading a fixed partition would miss the record on an SSH-host worktree. + it('reads the park record from the worktree own execution-host partition', async () => { + const sshRepo = { ...store.getRepos()[0]!, executionHostId: 'ssh:ssh-1' as const } + const { runtimeStore } = makeParkedSessionStore('worktree-sleep', {}, 'ssh:ssh-1') + const { runtime, spawn } = makeParkedRuntime({ + ...runtimeStore, + getRepos: () => [sshRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? sshRepo : undefined) + }) + + const activated = await automaticActivate(runtime) + + expect(spawn).not.toHaveBeenCalled() + expect(activated.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null }) + }) + }) + it('reattaches hydrated SSH headless terminals with the persisted relay identity', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal({ @@ -29167,7 +29461,8 @@ describe('OrcaRuntimeService', () => { [HEADLESS_LEAF_ID]: 'ssh:ssh-1@@relay-pty' }) } - }) + }), + 'ssh:ssh-1' ) const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } const remoteStore = { @@ -29220,7 +29515,8 @@ describe('OrcaRuntimeService', () => { terminalLayoutsByTabId: { 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: stalePtyId }) } - }) + }), + 'ssh:ssh-1' ) const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } const remoteStore = { @@ -29446,7 +29742,8 @@ describe('OrcaRuntimeService', () => { terminalLayoutsByTabId: { 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: undefined }) } - }) + }), + 'ssh:ssh-1' ) const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } const remoteStore = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 79abd93b6b2..7c1c092411e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -245,6 +245,10 @@ import { navigationTargetsHost, type RuntimeNavigationTarget } from '../../shared/runtime-navigation' +import { + isAutomaticTabActivation, + type TabActivationIntent +} from '../../shared/tab-activation-intent' import type { SshConnectionState } from '../../shared/ssh-types' import { getPublicSshState } from './public-ssh-state' import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' @@ -7321,6 +7325,7 @@ export class OrcaRuntimeService { notifyClients?: boolean clientNavigationId?: string navigation?: RuntimeNavigationTarget + intent?: TabActivationIntent } = {} ): Promise { const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all') @@ -7362,6 +7367,10 @@ export class OrcaRuntimeService { const shouldMaterializePendingTerminal = publicTab?.type === 'terminal' && publicTab.status !== 'ready' && + // Why: opening a tab is the documented wake gesture for a slept pane + // (#11598), so only a background probe may be refused for one. + (!isAutomaticTabActivation(opts.intent) || + !this.isDeliberatelyParkedPane(worktreeId, tab)) && (!targetsHost || !this.notifier?.focusTerminal || this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab)) @@ -7515,6 +7524,29 @@ export class OrcaRuntimeService { return snapshot } + /** + * Whether persistence proves this pane's PTY was deliberately taken down and parked + * (workspace sleep or completed-agent hibernation) rather than lost and awaiting reconnect. + * Why: `pending-handle` alone cannot tell those apart — a parked pane publishes it + * indefinitely — and respawning a parked pane re-launches its agent behind the user. + * Only an automatic activation consults this; a user opening the tab is the wake gesture. + */ + private isDeliberatelyParkedPane( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + const record = + this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey?.[ + makePaneKey(tab.parentTabId, tab.leafId) + ] + // Why: 'live'/'quit' captures describe a pane that was still running, so a reconnect + // must still mint its replacement PTY (#11542). Only a worktree-owned capture records + // a deliberate takedown the user did not ask to undo. + return ( + record?.origin === 'worktree-sleep' && runtimeWorktreeIdsEqual(record.worktreeId, worktreeId) + ) + } + private shouldMaterializeHeadlessMobileSessionTab( snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index c035099d083..fc7bdc2adc3 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -4,6 +4,7 @@ import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../shared/types' import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' +import { TAB_ACTIVATION_INTENTS } from '../../../../shared/tab-activation-intent' import { OptionalBoolean } from '../schemas' export const WorktreeTabSelector = z.object({ @@ -24,7 +25,10 @@ export const ActivateTab = WorktreeTabSelector.extend({ .pipe(z.string().min(1, 'Missing tab id')), leafId: z.string().max(128).optional(), notifyClients: OptionalBoolean, - navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional() + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), + // Why: absent means user intent, so clients that predate this field keep the + // tab-open wake gesture. Only 'automatic' may be refused for a slept pane. + intent: z.enum(TAB_ACTIVATION_INTENTS).optional() }) export const CloseTab = ActivateTab.extend({ diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 428fde012a3..d31f8606709 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -68,6 +68,58 @@ describe('session tab RPC methods', () => { }) }) + // Why: only this field separates a reconnect probe from a user opening the tab, + // and the tab-open gesture is what wakes a deliberately slept pane (STA-3465). + it('forwards an automatic activation intent to the runtime', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + activateMobileSessionTab: vi.fn().mockResolvedValue({ tabs: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.activate', { + worktree: 'id:wt-1', + tabId: 'tab-1', + notifyClients: false, + intent: 'automatic' + }) + ) + + expect(response.ok).toBe(true) + expect(runtime.activateMobileSessionTab).toHaveBeenCalledWith( + 'id:wt-1', + 'tab-1', + undefined, + expect.objectContaining({ intent: 'automatic' }) + ) + }) + + // Why: the field is additive, so a client that predates it sends nothing and + // must keep the permissive default rather than losing its wake gesture. + it('passes no intent for a client that omits the field', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + activateMobileSessionTab: vi.fn().mockResolvedValue({ tabs: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.activate', { + worktree: 'id:wt-1', + tabId: 'tab-1', + notifyClients: false + }) + ) + + expect(response.ok).toBe(true) + expect(runtime.activateMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', undefined, { + notifyClients: false, + clientNavigationId: undefined, + navigation: 'caller' + }) + }) + it('refuses a reasonless close without invoking destructive runtime logic', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 7c4230c27b1..14716e0bf17 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -34,6 +34,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ runtime.activateMobileSessionTab(params.worktree, params.tabId, params.leafId, { notifyClients: params.notifyClients !== false, clientNavigationId: pairedDeviceId, + ...(params.intent ? { intent: params.intent } : {}), navigation: resolveRuntimeNavigationTarget({ navigation: params.navigation, notifyClients: params.notifyClients, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts index 8cd8600dac2..b797a0f5877 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts @@ -362,6 +362,7 @@ describe('remote runtime outage: toast flood and stuck reconnect (issue3)', () = let hostActivated = false let activateCallsAfterOutage = 0 let listCallsAfterOutage = 0 + const activateIntentsAfterOutage: unknown[] = [] const hostSnapshot = () => ({ ok: true, result: { @@ -386,18 +387,21 @@ describe('remote runtime outage: toast flood and stuck reconnect (issue3)', () = ] } }) - runtimeCall.mockImplementation(async (request: { method: string }) => { - if (request.method === 'session.tabs.list') { - listCallsAfterOutage += 1 - return hostSnapshot() + runtimeCall.mockImplementation( + async (request: { method: string; params?: { intent?: unknown } }) => { + if (request.method === 'session.tabs.list') { + listCallsAfterOutage += 1 + return hostSnapshot() + } + if (request.method === 'session.tabs.activate') { + activateCallsAfterOutage += 1 + activateIntentsAfterOutage.push(request.params?.intent) + hostActivated = true + return hostSnapshot() + } + return { ok: true, result: {} } } - if (request.method === 'session.tabs.activate') { - activateCallsAfterOutage += 1 - hostActivated = true - return hostSnapshot() - } - return { ok: true, result: {} } - }) + ) // Stream lost → reconnect. The resubscribe path looks for a status:'ready' // handle and finds only the pending surface. @@ -426,6 +430,9 @@ describe('remote runtime outage: toast flood and stuck reconnect (issue3)', () = activateCallsAfterOutage, `reconnect ran ${listCallsAfterOutage} list-only inventory polls across online trigger + Reconnect click without ever activating the pending surface` ).toBeGreaterThan(0) + // Why: reconnect is machinery, not a user gesture, so the host must be able + // to tell it apart and leave a deliberately slept pane slept (STA-3465). + expect(activateIntentsAfterOutage.every((intent) => intent === 'automatic')).toBe(true) await vi.waitFor(() => expect(subscribedTerminalHandles()).toContain('terminal-2')) emitSnapshot(latestSubscribePayload().streamId, 'rematerialized') expect(transport.isConnected()).toBe(true) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 16b83b0057a..ca8643fee91 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -730,8 +730,13 @@ describe('createRemoteRuntimePtyTransport', () => { terminal: 'terminal-1', viewport: { cols: 100, rows: 30 } }) + // Why: opening the pane is the user's wake gesture for a slept pane, so it + // must not be labelled like the reconnect probe (STA-3465). expect(runtimeCall).toHaveBeenCalledWith( - expect.objectContaining({ method: 'session.tabs.activate' }) + expect.objectContaining({ + method: 'session.tabs.activate', + params: expect.objectContaining({ intent: 'user' }) + }) ) expect(runtimeCall).toHaveBeenCalledWith( expect.objectContaining({ @@ -4082,7 +4087,8 @@ describe('createRemoteRuntimePtyTransport', () => { tabId: 'host-tab-1', leafId: 'leaf-1', notifyClients: false, - navigation: 'caller' + navigation: 'caller', + intent: 'user' } }) ) @@ -4233,7 +4239,8 @@ describe('createRemoteRuntimePtyTransport', () => { tabId: 'host-tab-1', leafId: 'leaf-2', notifyClients: false, - navigation: 'caller' + navigation: 'caller', + intent: 'user' } }) ) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index fb6caea5713..75d4d2c72b1 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -10,6 +10,7 @@ import type { RuntimeEnsureAgentSessionResult } from '../../../../shared/agent-session-host-authority' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { TabActivationIntent } from '../../../../shared/tab-activation-intent' import type { RuntimeMobileSessionTerminalClientTab, RuntimeMobileSessionTabsResult, @@ -518,6 +519,7 @@ export function createRemoteRuntimePtyTransport( function activateHostSessionSurface( hostTabId: string, worktree: string, + intent: TabActivationIntent, timeoutMs?: number ): Promise { return callRuntime( @@ -527,7 +529,8 @@ export function createRemoteRuntimePtyTransport( tabId: hostTabId, ...(leafId ? { leafId } : {}), notifyClients: false, - navigation: 'caller' + navigation: 'caller', + intent }, timeoutMs ) @@ -548,7 +551,8 @@ export function createRemoteRuntimePtyTransport( const worktree = toRuntimeWorktreeSelector(worktreeId) let activated: RuntimeMobileSessionTabsResult try { - activated = await activateHostSessionSurface(hostTabId, worktree) + // Why: this runs when the pane itself is opened/attached — the user's wake gesture. + activated = await activateHostSessionSurface(hostTabId, worktree, 'user') } catch (error) { if (isMissingHostSessionSurfaceError(error)) { return null @@ -714,7 +718,9 @@ export function createRemoteRuntimePtyTransport( requestRemainingMs ) }) - : await activateHostSessionSurface(hostTabId, worktree, requestRemainingMs) + : // Why: reconnect recovery, not a user gesture — a pane the user slept + // must stay slept even though it publishes the same pending status. + await activateHostSessionSurface(hostTabId, worktree, 'automatic', requestRemainingMs) lastRequestError = null const nextHandle = findReadyHostSessionHandle(listed, hostTabId) if (nextHandle) { diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 7eee47842ba..09f6dee870b 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -1608,7 +1608,8 @@ describe('web runtime session tab actions', () => { worktree: `id:${WORKTREE_ID}`, tabId: 'host-browser-unified', notifyClients: false, - navigation: 'caller' + navigation: 'caller', + intent: 'user' }, timeoutMs: 15_000 }) diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 05ee84260fc..7178aa21b5f 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -907,9 +907,12 @@ async function callWebRuntimeSessionTabMethod( tabId: hostTabId, ...(method === 'session.tabs.activate' ? { - // Why: the additive intent protects new hosts while notifyClients:false protects old hosts. + // Why: the additive navigation target protects new hosts while notifyClients:false protects old hosts. notifyClients: false, - navigation: 'caller' as const + navigation: 'caller' as const, + // Why: every caller here is a tab click, shortcut, or palette pick — + // the gesture that is supposed to wake a slept pane. + intent: 'user' as const } : {}), ...(isLifecycleClose diff --git a/src/shared/tab-activation-intent.ts b/src/shared/tab-activation-intent.ts new file mode 100644 index 00000000000..3fca92b9ada --- /dev/null +++ b/src/shared/tab-activation-intent.ts @@ -0,0 +1,18 @@ +export const TAB_ACTIVATION_INTENTS = ['user', 'automatic'] as const + +/** + * Who asked for a tab activation: an explicit user gesture (opening the tab) or + * background machinery (a reconnect/recovery probe). Opening a tab is the + * documented way to wake a deliberately slept pane, so only an automatic + * activation may be refused for one. + */ +export type TabActivationIntent = (typeof TAB_ACTIVATION_INTENTS)[number] + +/** + * Why: the field is additive on an existing method, so a client that predates it + * sends nothing. Absent must keep today's permissive behavior or those clients + * silently lose their wake gesture. + */ +export function isAutomaticTabActivation(intent: TabActivationIntent | undefined): boolean { + return intent === 'automatic' +}