diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 08735018f8d..17eac4a743f 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -352,7 +352,7 @@ describe('renderer startup runtime routing', () => { expect(gateBlock).toContain('terminalStartupRestorationReady') expect(gateIndex).toBeGreaterThanOrEqual(0) expect(gateIndex).toBeLessThan(createIndex) - expect(gateBlock.slice(gateIndex, createIndex)).toContain("outcome === 'blocked'") + expect(gateBlock.slice(gateIndex, createIndex)).toContain("outcome !== 'empty'") }) it('does not load the terminal workbench on the no-workspace landing path', () => { diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 73ce0b5ac70..8fd212741f0 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1496,7 +1496,7 @@ function Terminal(): React.JSX.Element | null { void gateWorktreeAgentActivation(activeWorktreeId).then((outcome) => { if ( cancelled || - outcome === 'blocked' || + outcome !== 'empty' || useAppStore.getState().activeWorktreeId !== activeWorktreeId ) { return diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 62af258ae6b..5b2a63fa6eb 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -207,6 +207,14 @@ function ensureFolderWorkspaceInitialTerminal( return primaryTabId } +function canInspectAgentActivationInventory(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.api?.runtime?.call === 'function' && + typeof window.api?.pty?.listSessions === 'function' + ) +} + export function activateAndRevealFolderWorkspace( folderWorkspaceId: string, opts?: { @@ -263,16 +271,21 @@ export function activateAndRevealFolderWorkspace( if (!state.isNavigatingHistory) { state.recordWorktreeVisit(workspaceKey) } - const shouldGateAutoResume = - !opts?.startup && workspaceHasSleepingAgentSessions(state, workspaceKey) - if (shouldGateAutoResume) { - void gateWorktreeAgentActivation(workspaceKey).then(() => { - if (useAppStore.getState().activeWorktreeId === workspaceKey) { + const shouldGateAgentActivation = + !opts?.startup && + (workspaceHasSleepingAgentSessions(state, workspaceKey) || + (canInspectAgentActivationInventory() && + shouldAutoCreateInitialTerminal( + state.reconcileWorktreeTabModel(workspaceKey).renderableTabCount + ))) + if (shouldGateAgentActivation) { + void gateWorktreeAgentActivation(workspaceKey).then((outcome) => { + if (outcome === 'empty' && useAppStore.getState().activeWorktreeId === workspaceKey) { ensureFolderWorkspaceInitialTerminal(folderWorkspace) } }) } - const primaryTabId = shouldGateAutoResume + const primaryTabId = shouldGateAgentActivation ? null : ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup) @@ -348,20 +361,25 @@ export function activateAndRevealWorktree( state.recordWorktreeVisit(worktreeId) } - // Why: sleeping destroys the local PTY but preserves the provider session id, so waking should restore those CLI sessions automatically. - const shouldGateAutoResume = - !hasActivationWork && workspaceHasSleepingAgentSessions(postActivationState, worktreeId) - if (shouldGateAutoResume) { - void gateWorktreeAgentActivation(worktreeId).then(() => { + // Why: live and structured agent inventory can hydrate after the tab model; reconcile it before an empty model authorizes a fallback terminal. + const shouldGateAgentActivation = + !hasActivationWork && + (workspaceHasSleepingAgentSessions(postActivationState, worktreeId) || + (canInspectAgentActivationInventory() && + shouldAutoCreateInitialTerminal( + postActivationState.reconcileWorktreeTabModel(worktreeId).renderableTabCount + ))) + if (shouldGateAgentActivation) { + void gateWorktreeAgentActivation(worktreeId).then((outcome) => { const currentState = useAppStore.getState() - if (currentState.activeWorktreeId === worktreeId) { + if (outcome === 'empty' && currentState.activeWorktreeId === worktreeId) { ensureWorktreeHasInitialTerminal(currentState, worktreeId) } }) } // 4. Ensure a focusable surface exists for externally-created worktrees - const primaryTabId = shouldGateAutoResume + const primaryTabId = shouldGateAgentActivation ? null : ensureWorktreeHasInitialTerminal( useAppStore.getState(), diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts index 49350a67549..3728386142c 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts @@ -35,7 +35,12 @@ type ActivationGateDeps = { resume: (worktreeId: string, options?: ResumeSleepingAgentSessionsOptions) => number } -export type WorktreeAgentActivationOutcome = 'adopted' | 'structured' | 'resumed' | 'blocked' +export type WorktreeAgentActivationOutcome = + | 'adopted' + | 'structured' + | 'resumed' + | 'empty' + | 'blocked' const inFlightByWorktreeId = new Map>() const WORKSPACE_SESSION_READY_TIMEOUT_MS = 30_000 @@ -243,7 +248,7 @@ export async function runWorktreeAgentActivationGate( ? 'adopted' : structured ? 'structured' - : 'resumed' + : 'empty' } export function gateWorktreeAgentActivation( diff --git a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts new file mode 100644 index 00000000000..5100484c0ac --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts @@ -0,0 +1,203 @@ +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import { useAppStore } from '@/store' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { activateAndRevealWorktree } from './worktree-activation' +import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' +import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent-test-state' + +const initialState = useAppStore.getState() + +function baseState(): Partial { + const worktree = makeWorktree() + return { + repos: [ + { + id: worktree.repoId, + path: path.join(path.sep, 'workspace', 'repo'), + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { [worktree.repoId]: [worktree] }, + activeRepoId: worktree.repoId, + activeView: 'terminal', + workspaceSessionReady: true, + terminalStartupRestorationReady: true, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + activeTabTypeByWorktree: {}, + activeTabIdByWorktree: {}, + tabBarOrderByWorktree: {}, + pendingStartupByTabId: {}, + automaticAgentResumeClaimsByTabId: {}, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {}, + settings: { + agentCmdOverrides: {}, + defaultTuiAgent: 'codex', + setupScriptLaunchMode: 'new-tab' + } as AppState['settings'], + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } +} + +function structuredSnapshot(worktreeId: string): RuntimeMobileSessionTabsResult { + return { + worktree: worktreeId, + publicationEpoch: 'activation-test', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: 'structured-agent-session-chat-1', + title: 'Codex Chat', + sessionId: 'chat-1', + agent: 'codex', + isActive: false + } + ] + } +} + +function stubInventory(args?: { structured?: boolean; livePtyId?: string }): { + runtimeCall: ReturnType + listSessions: ReturnType +} { + const worktree = makeWorktree() + const runtimeCall = vi.fn(async ({ method }: { method: string }) => { + if (method === 'session.tabs.listAll') { + return { + ok: true, + result: { snapshots: args?.structured ? [structuredSnapshot(worktree.id)] : [] } + } + } + if (method === 'agentSession.handoffStatus') { + return { ok: true, result: { owner: 'native' } } + } + throw new Error(`Unexpected runtime method: ${method}`) + }) + const listSessions = vi.fn(async () => + args?.livePtyId + ? [ + { + id: args.livePtyId, + cwd: worktree.path, + title: 'Codex', + agentOwnership: 'present' as const + } + ] + : [] + ) + vi.stubGlobal('window', { api: { runtime: { call: runtimeCall }, pty: { listSessions } } }) + return { runtimeCall, listSessions } +} + +afterEach(() => { + vi.unstubAllGlobals() + useAppStore.setState(initialState, true) +}) + +describe('worktree agent activation seam', () => { + it('keeps a projected chat-only workspace terminal-free', async () => { + const worktree = makeWorktree() + const groupId = 'chat-group' + useAppStore.setState({ + ...baseState(), + unifiedTabsByWorktree: { + [worktree.id]: [ + { + id: 'structured-agent-session-chat-1', + entityId: 'chat-1', + groupId, + worktreeId: worktree.id, + contentType: 'agent-session', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + [worktree.id]: [ + { + id: groupId, + worktreeId: worktree.id, + activeTabId: 'structured-agent-session-chat-1', + tabOrder: ['structured-agent-session-chat-1'] + } + ] + }, + activeGroupIdByWorktree: { [worktree.id]: groupId } + }) + const { runtimeCall, listSessions } = stubInventory() + + expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) + + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) + expect(runtimeCall).not.toHaveBeenCalled() + expect(listSessions).not.toHaveBeenCalled() + }) + + it('adopts a live terminal without spawning a fallback', async () => { + const worktree = makeWorktree() + const livePtyId = `${worktree.id}@@live-codex` + useAppStore.setState(baseState()) + stubInventory({ livePtyId }) + + expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) + + const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] + expect(tabs).toHaveLength(1) + expect(tabs[0]?.ptyId).toBe(livePtyId) + }) + + it('spawns a fallback when the workspace has no agent', async () => { + const worktree = makeWorktree() + useAppStore.setState(baseState()) + stubInventory() + + expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) + + const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] + expect(tabs).toHaveLength(1) + expect(tabs[0]?.ptyId).toBeNull() + }) + + it('does not spawn before a structured chat tab hydrates', async () => { + const worktree = makeWorktree() + useAppStore.setState(baseState()) + const { runtimeCall } = stubInventory({ structured: true }) + + expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) + + expect(useAppStore.getState().unifiedTabsByWorktree[worktree.id] ?? []).toHaveLength(0) + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) + expect(runtimeCall).toHaveBeenCalledWith({ + method: 'agentSession.handoffStatus', + params: { sessionId: 'chat-1' } + }) + }) +}) diff --git a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts index 78095a915b3..ff6731cb810 100644 --- a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts @@ -63,7 +63,9 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => { workspaceSessionReady: false, terminalStartupRestorationReady: false }) - const paneKey = 'packaged-restart-pane:0' + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = `packaged-restart-pane:${leafId}` + const livePtyId = `${worktree.id}@@daemon-live` useAppStore.setState({ sleepingAgentSessionsByPaneKey: { [paneKey]: { @@ -79,14 +81,25 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => { updatedAt: 1000, terminalTitle: 'Codex' } + }, + terminalLayoutsByTabId: { + 'packaged-restart-pane': { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: livePtyId } + } } }) vi.stubGlobal('window', { api: { + runtime: { + call: vi.fn(async () => ({ ok: true, result: { snapshots: [] } })) + }, pty: { listSessions: vi.fn(async () => [ { - id: `${worktree.id}@@daemon-live`, + id: livePtyId, cwd: worktree.path, title: 'Codex', agentOwnership: 'present' as const @@ -108,6 +121,7 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => { await gate const restored = useAppStore.getState() expect(restored.tabsByWorktree[worktree.id]).toHaveLength(1) + expect(restored.tabsByWorktree[worktree.id]?.[0]?.ptyId).toBe(livePtyId) expect(restored.automaticAgentResumeClaimsByTabId).toEqual({}) expect(restored.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() }) @@ -115,6 +129,14 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => { it('re-captured sleeping codex session resumes once, not once per reopen', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } useAppStore.setState(baseState(worktree)) + vi.stubGlobal('window', { + api: { + runtime: { + call: vi.fn(async () => ({ ok: true, result: { snapshots: [] } })) + }, + pty: { listSessions: vi.fn(async () => []) } + } + }) const providerSession = { key: 'session_id' as const, id: 'codex-session-1' } let resumedTabId: string | undefined