mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix: gate chat-only workspace activation
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user