fix(session-tabs): rebuild an emptied headless session snapshot instead of hiding saved terminals (#29283)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-15 18:41:55 -07:00
committed by GitHub
co-authored by Claude
parent 69fcd672d8
commit 97469d64bd
3 changed files with 141 additions and 5 deletions
@@ -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<string[]> {
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'])
})
})
+1
View File
@@ -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')
@@ -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 &&