diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-empty-headless-snapshot.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-empty-headless-snapshot.spec.ts new file mode 100644 index 00000000000..f8a380b53b6 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-empty-headless-snapshot.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { + HEADLESS_LEAF_ID, + TEST_WORKTREE_ID, + makeHeadlessTerminalLayout, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} from '../orca-runtime-test-fixtures.spec' +import { getRuntimeBrowserPageRegistry } from '../runtime-browser-page-registry' + +// #25900: a windowless host listed no tabs for a worktree whose saved session still had live +// agent terminals, so a client waiting to reattach heard "nothing here" and stayed on Reconnecting. + +const AGENT_PTY_ID = 'daemon-agent-pty' +const PLACEMENT = { + kind: 'client' as const, + browserHostClientId: 'host-a', + browserHostGeneration: 3, + pageHostGeneration: 9 +} + +function makeHost() { + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeTabIdByWorktree: {}, + tabsByWorktree: { [TEST_WORKTREE_ID]: [] }, + terminalLayoutsByTabId: {} + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'unexpected-pty' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + const pages = getRuntimeBrowserPageRegistry(runtime) + return { + runtime, + openPage: (browserPageId: string) => + pages.publishClientPage({ + browserPageId, + workspaceId: TEST_WORKTREE_ID, + browserProfileId: 'default', + executionHostKey: 'native:runtime-a:7', + placement: PLACEMENT, + url: 'https://client.test/', + title: 'Client page', + loading: false, + active: true + }), + closePage: (browserPageId: string) => pages.retirePage(browserPageId, PLACEMENT), + saveAgentTerminal: () => { + const session = getSession() + setSession({ + ...session, + activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'agent-tab' }, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'agent-tab', + ptyId: AGENT_PTY_ID, + worktreeId: TEST_WORKTREE_ID, + title: 'codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'agent-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: AGENT_PTY_ID }) + } + }) + } + } +} + +async function listedTabTypes(runtime: OrcaRuntimeService): Promise { + const snapshots = await runtime.listAllMobileSessionTabs() + return (snapshots.find((snapshot) => snapshot.worktree === TEST_WORKTREE_ID)?.tabs ?? []) + .map((tab) => tab.type) + .sort() +} + +describe('OrcaRuntimeService empty headless session snapshot', () => { + it('rebuilds the terminals when the last browser closes instead of listing nothing', async () => { + const host = makeHost() + host.openPage('page-1') + expect(await listedTabTypes(host.runtime)).toEqual(['browser']) + + host.saveAgentTerminal() + host.closePage('page-1') + + expect(await listedTabTypes(host.runtime)).toEqual(['terminal']) + }) + + it('keeps the saved terminals listed when a browser opens over an emptied snapshot', async () => { + const host = makeHost() + host.openPage('page-1') + await listedTabTypes(host.runtime) + host.closePage('page-1') + // The emptied snapshot this host built stays stored while the session gains its terminal. + expect(await listedTabTypes(host.runtime)).toEqual([]) + + host.saveAgentTerminal() + host.openPage('page-2') + + expect(await listedTabTypes(host.runtime)).toEqual(['browser', 'terminal']) + const listed = await host.runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(listed.tabs.map((tab) => tab.type).sort()).toEqual(['browser', 'terminal']) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index bc903f2c9e3..496c4ad2e2f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -84,6 +84,7 @@ await import('./orca-runtime-tests/mobile-session-tabs-part-12.spec') await import('./orca-runtime-tests/mobile-session-tabs-part-13.spec') await import('./orca-runtime-tests/mobile-session-tabs-part-14.spec') await import('./orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec') +await import('./orca-runtime-tests/mobile-session-tabs-empty-headless-snapshot.spec') await import('./orca-runtime-tests/exit-retirement-activation.spec') await import('./orca-runtime-tests/mobile-creation-and-orchestration.spec') await import('./orca-runtime-tests/mobile-creation-and-orchestration-part-02.spec') diff --git a/src/main/runtime/session-tabs/mobile-session-hydration.ts b/src/main/runtime/session-tabs/mobile-session-hydration.ts index a64b0057ec6..0b9e21c9df3 100644 --- a/src/main/runtime/session-tabs/mobile-session-hydration.ts +++ b/src/main/runtime/session-tabs/mobile-session-hydration.ts @@ -94,6 +94,20 @@ export abstract class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspa protected abstract getAvailableAuthoritativeWindow(): BrowserWindow | null + /** A stored snapshot, or undefined when it is one this host built and left empty. */ + private readRebuildableMobileSessionSnapshot( + worktreeId: string + ): RuntimeMobileSessionTabsSnapshot | undefined { + const stored = this.mobileSessionTabsByWorktree.get(worktreeId) + // Why: only the persisted session feeds a headless-built snapshot, so an empty one is never an + // answer — as a base it made the next pass seed browser rows alone and hide every terminal. + return stored && + stored.tabs.length === 0 && + this.isHeadlessBuiltMobileSessionPublicationBase(stored.publicationEpoch) + ? undefined + : stored + } + hydrateHeadlessMobileSessionTabsFromWorkspaceSession( worktreeId?: string, options: { @@ -169,10 +183,10 @@ export abstract class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspa continue } } - const existing = this.mobileSessionTabsByWorktree.get(entryWorktreeId) + const stored = this.readRebuildableMobileSessionSnapshot(entryWorktreeId) if ( - existing && - existing.tabs.length > 0 && + stored && + stored.tabs.length > 0 && options.force !== true && options.onlyRuntimeOwnedTerminals !== true ) { @@ -180,10 +194,15 @@ export abstract class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspa // offscreen browser tabs are live and may have been created/closed since. // Reconcile just the browser tabs against the live bridge instead of // leaving a stale snapshot that omits a freshly-opened browser tab. - this.reconcileHeadlessMobileSessionBrowserTabs(entryWorktreeId, existing) + this.reconcileHeadlessMobileSessionBrowserTabs(entryWorktreeId, stored) reconciledWorktreeIds.add(entryWorktreeId) - continue + // Why: closing the last browser row empties a browser-only snapshot; rebuild it now + // rather than serve that empty answer once. + if (this.readRebuildableMobileSessionSnapshot(entryWorktreeId)) { + continue + } } + const existing = this.readRebuildableMobileSessionSnapshot(entryWorktreeId) // Why: windowless, nothing publishes the other tabs, and a filtered seed would make the full pass skip this worktree. const runtimeOwnedOnly = options.onlyRuntimeOwnedTerminals === true &&