fix: gate chat-only workspace activation

This commit is contained in:
Brennan Benson
2026-08-14 18:22:21 -07:00
parent 6bb062827e
commit deea33fcb7
6 changed files with 267 additions and 19 deletions
+1 -1
View File
@@ -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', () => {
+1 -1
View File
@@ -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
+31 -13
View File
@@ -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(),
@@ -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<string, Promise<WorktreeAgentActivationOutcome>>()
const WORKSPACE_SESSION_READY_TIMEOUT_MS = 30_000
@@ -243,7 +248,7 @@ export async function runWorktreeAgentActivationGate(
? 'adopted'
: structured
? 'structured'
: 'resumed'
: 'empty'
}
export function gateWorktreeAgentActivation(
@@ -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<AppState> {
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<typeof vi.fn>
listSessions: ReturnType<typeof vi.fn>
} {
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' }
})
})
})
@@ -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