From da23e30e9044d53ff1c7b27d86461c8ea5fc593a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:41:49 -0700 Subject: [PATCH] perf(worktree): prepare agent checkouts before composer submission --- .../components/NewWorkspaceComposerModal.tsx | 4 +- .../composer-state/composer-submit-model.ts | 2 +- .../quick-creation-execution.ts | 4 +- .../composer-state/quick-submit-action.ts | 2 +- src/renderer/src/hooks/useComposerState.ts | 2 +- .../src/lib/create-requested-worktree.test.ts | 67 ++++++++++++++++++ .../src/lib/create-requested-worktree.ts | 8 ++- .../src/lib/retained-agent-activation.test.ts | 69 +++++++++++++++++++ .../lib/retained-worktree-creation.test.ts | 22 ++++++ .../src/lib/retained-worktree-creation.ts | 13 ++-- src/shared/worktree/types.ts | 4 +- 11 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/lib/create-requested-worktree.test.ts create mode 100644 src/renderer/src/lib/retained-agent-activation.test.ts diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 80f247bfd6b..1a258b42ea1 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -185,11 +185,11 @@ function QuickTabBody({ }, []) useEffect(() => { - if (!active || quickAgent !== null || createDisabled) { + if (!active || createDisabled) { return } let cancelled = false - void prepareQuickWorkspace(() => cancelled) + void prepareQuickWorkspace(quickAgent, () => cancelled) return () => { cancelled = true } diff --git a/src/renderer/src/hooks/composer-state/composer-submit-model.ts b/src/renderer/src/hooks/composer-state/composer-submit-model.ts index 7ed6f3c2ab7..c7eb19533a1 100644 --- a/src/renderer/src/hooks/composer-state/composer-submit-model.ts +++ b/src/renderer/src/hooks/composer-state/composer-submit-model.ts @@ -110,7 +110,7 @@ export type ComposerSubmitModel = { ) => QuickSubmitSource | null resetForNextCreate: () => void submit: () => Promise - prepareQuickWorkspace: (isCancelled: () => boolean) => Promise + prepareQuickWorkspace: (agent: TuiAgent | null, isCancelled: () => boolean) => Promise submitQuick: (agent: TuiAgent | null) => Promise submitFolderTarget: (requestedAgent: TuiAgent | null) => Promise } diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index 0e3f474f3f8..d638514d4b5 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -81,9 +81,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { if ( !isCancelled || (preparation && - (!selectedRepoIsGit || - requestedAgent !== null || - (ephemeralVmsEnabled && selectedEphemeralVmRecipeId))) + (!selectedRepoIsGit || (ephemeralVmsEnabled && selectedEphemeralVmRecipeId))) ) { return } diff --git a/src/renderer/src/hooks/composer-state/quick-submit-action.ts b/src/renderer/src/hooks/composer-state/quick-submit-action.ts index d1ac205c169..7b2c3967cf4 100644 --- a/src/renderer/src/hooks/composer-state/quick-submit-action.ts +++ b/src/renderer/src/hooks/composer-state/quick-submit-action.ts @@ -205,7 +205,7 @@ export function useQuickSubmitAction(input: QuickSubmitActionInput) { ) const prepareQuickWorkspace = useCallback( - (isCancelled: () => boolean) => submitQuick(null, { isCancelled }), + (agent: TuiAgent | null, isCancelled: () => boolean) => submitQuick(agent, { isCancelled }), [submitQuick] ) return { submitQuick, prepareQuickWorkspace } diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 1f8aa330cdf..c7f9c33a500 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -59,7 +59,7 @@ export type UseComposerStateResult = { promptTextareaRef: RefObject nameInputRef: RefObject submit: () => Promise - prepareQuickWorkspace: (isCancelled: () => boolean) => Promise + prepareQuickWorkspace: (agent: TuiAgent | null, isCancelled: () => boolean) => Promise submitQuick: (agent: TuiAgent | null) => Promise createDisabled: boolean selectAddedProjectRepo: (repoId: string) => void diff --git a/src/renderer/src/lib/create-requested-worktree.test.ts b/src/renderer/src/lib/create-requested-worktree.test.ts new file mode 100644 index 00000000000..42b4f6d7500 --- /dev/null +++ b/src/renderer/src/lib/create-requested-worktree.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createRequestedWorktree } from './create-requested-worktree' +import { makeRequest } from './worktree-creation-request.test-fixture' + +const { createWorktree } = vi.hoisted(() => ({ createWorktree: vi.fn() })) +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ createWorktree }) } +})) +vi.mock('@/lib/worktree-draft-startup-view-mode', () => ({ + resolveBackendDraftStartup: (request: { startup?: unknown }) => request.startup +})) + +beforeEach(() => vi.clearAllMocks()) + +describe('durable composer creation launch boundary', () => { + it.each(['command', 'draft'] as const)( + 'withholds agent %s execution until Create while retaining workspace metadata', + async (delivery) => { + const request = makeRequest({ + agent: 'codex', + startup: delivery === 'command' ? { command: 'codex', launchAgent: 'codex' } : undefined, + launchDraftPrompt: 'Investigate this task', + startupPlan: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + const snapshot = structuredClone(request) + await createRequestedWorktree('reservation', request, true) + const args = createWorktree.mock.calls[0] + expect(args[10]).toBe('codex') + expect(args[16]).toBeUndefined() + expect(args[25]).not.toHaveProperty('startupDraft') + expect(request).toEqual(snapshot) + } + ) + + it('preserves ordinary backend agent launch', async () => { + const startup = { command: 'codex', launchAgent: 'codex' as const } + await createRequestedWorktree('submit', makeRequest({ agent: 'codex', startup })) + expect(createWorktree.mock.calls[0][16]).toEqual(startup) + }) + + it('preserves ordinary host-owned draft launch', async () => { + await createRequestedWorktree( + 'submit', + makeRequest({ agent: 'codex', launchDraftPrompt: 'Unsent task' }) + ) + expect(createWorktree.mock.calls[0][25]).toMatchObject({ startupDraft: 'Unsent task' }) + }) + + it('continues warming blank shells without selecting them', async () => { + await createRequestedWorktree( + 'reservation', + makeRequest({ startup: { command: '', env: { PROJECT: 'fixture' } } }), + true + ) + expect(createWorktree.mock.calls[0][16]).toEqual({ + command: '', + env: { PROJECT: 'fixture' }, + activate: false + }) + }) +}) diff --git a/src/renderer/src/lib/create-requested-worktree.ts b/src/renderer/src/lib/create-requested-worktree.ts index cdb86ba883c..fecd1cec579 100644 --- a/src/renderer/src/lib/create-requested-worktree.ts +++ b/src/renderer/src/lib/create-requested-worktree.ts @@ -12,8 +12,11 @@ export function createRequestedWorktree( ): Promise { const provisionedRoot = getProvisionedRootCreateOptions(preparedRequest) const structuredLaunch = preparedRequest.agentLaunchRoute === 'structured-native-chat' + const deferAgentLaunch = background && preparedRequest.agent !== null const backendStartup = - provisionedRoot || structuredLaunch ? undefined : resolveBackendDraftStartup(preparedRequest) + provisionedRoot || structuredLaunch || deferAgentLaunch + ? undefined + : resolveBackendDraftStartup(preparedRequest) return useAppStore .getState() .createWorktree( @@ -54,7 +57,8 @@ export function createRequestedWorktree( ? { linkedTaskSourceContext: preparedRequest.linkedTaskSourceContext } : {}), // Why: the remote host must own task-draft startup so its initial terminal is the agent, not an idle fallback shell. - ...(!structuredLaunch && + ...(!deferAgentLaunch && + !structuredLaunch && !backendStartup && preparedRequest.agent && preparedRequest.launchDraftPrompt diff --git a/src/renderer/src/lib/retained-agent-activation.test.ts b/src/renderer/src/lib/retained-agent-activation.test.ts new file mode 100644 index 00000000000..f442adc47d9 --- /dev/null +++ b/src/renderer/src/lib/retained-agent-activation.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CreateWorktreeResult } from '../../../shared/worktree/create-types' +import { makeRequest } from './worktree-creation-request.test-fixture' +import { executeWorktreeCreation } from './worktree-creation-flow-execute' +import { activateAndRevealWorktree } from './worktree-activation' + +const { store } = vi.hoisted(() => ({ + store: { + createWorktree: vi.fn(), + pendingWorktreeCreations: { 'creation-1': {} }, + activeView: 'terminal', + activePendingCreationId: 'creation-1' + } +})) +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: () => 'creation-1' })) +vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() })) +vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({ + ensureWorktreeHasInitialTerminal: vi.fn() +})) +vi.mock('@/lib/agent-trust-preflight', () => ({ preflightAgentTrust: vi.fn() })) +vi.mock('@/lib/ephemeral-vm-worktree-creation', () => ({ + attachEphemeralVmRuntimeToWorkspace: vi.fn(), + cleanupEphemeralVmRuntimeForFailedCreate: vi.fn(), + prepareRequestForCreate: vi.fn() +})) +vi.mock('@/lib/worktree-creation-structured-session', () => ({ + launchStructuredWorktreeSession: vi.fn() +})) +vi.mock('@/lib/worktree-creation-structured-recovery', () => ({ + markStructuredWorktreeLaunchUnconfirmed: vi.fn() +})) +vi.mock('@/lib/worktree-creation-completion', () => ({ completeWorktreeCreation: vi.fn() })) + +describe('retained agent activation', () => { + it('launches the selected agent from a retained checkout without creating again', async () => { + vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'agent-tab' }) + const launchConfig = { agentArgs: '--model selected', agentEnv: { PROJECT: 'fixture' } } + const request = makeRequest({ + agent: 'codex', + launchDraftPrompt: 'Unsent task', + startupPlan: { + agent: 'codex', + launchCommand: 'codex --model selected', + expectedProcess: 'codex', + followupPrompt: null, + draftPrompt: 'Unsent task', + env: { PROJECT: 'fixture' }, + launchConfig + } + }) + await executeWorktreeCreation('creation-1', request, { + worktree: { id: 'wt-agent', repoId: 'repo-1' } + } as CreateWorktreeResult) + expect(store.createWorktree).not.toHaveBeenCalled() + expect(activateAndRevealWorktree).toHaveBeenCalledExactlyOnceWith('wt-agent', { + sidebarRevealBehavior: 'auto', + startup: { + command: 'codex --model selected', + launchAgent: 'codex', + env: { PROJECT: 'fixture' }, + launchConfig, + launchToken: 'creation-1', + draftPrompt: 'Unsent task', + launchDraftText: 'Unsent task' + } + }) + }) +}) diff --git a/src/renderer/src/lib/retained-worktree-creation.test.ts b/src/renderer/src/lib/retained-worktree-creation.test.ts index faefa0ad134..3036944aa77 100644 --- a/src/renderer/src/lib/retained-worktree-creation.test.ts +++ b/src/renderer/src/lib/retained-worktree-creation.test.ts @@ -26,6 +26,28 @@ const result = { } as CreateWorktreeResult describe('retained composer worktree creation', () => { + it('retains an agent checkout and preserves the exact launch plan for Create', async () => { + const checkout = { worktree: result.worktree } + const create = vi.fn(async () => checkout) + const controller = createRetainedWorktreeCreation(create) + const agentRequest = request({ + agent: 'codex', + startup: { command: 'codex', launchAgent: 'codex' }, + startupPlan: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} } + }, + launchDraftPrompt: 'Keep this unsent' + }) + expect(controller.start(agentRequest, 'owner')).toBe(true) + expect(await controller.take(agentRequest, 'owner')).toBe(checkout) + expect(create).toHaveBeenCalledExactlyOnceWith(agentRequest) + expect(controller.take(agentRequest, 'owner')).toBeNull() + }) + it('retains a remote blank checkout whose terminal starts on activation', async () => { const create = vi.fn(async () => ({ worktree: result.worktree })) const controller = createRetainedWorktreeCreation(create) diff --git a/src/renderer/src/lib/retained-worktree-creation.ts b/src/renderer/src/lib/retained-worktree-creation.ts index 444f25f68ff..fb989cd4f41 100644 --- a/src/renderer/src/lib/retained-worktree-creation.ts +++ b/src/renderer/src/lib/retained-worktree-creation.ts @@ -3,12 +3,13 @@ import type { WorktreeCreationRequest } from './pending-worktree-creation' export function canRetainComposerWorktree(request: WorktreeCreationRequest): boolean { return ( - request.agent === null && - (request.startup === undefined || request.startup.command === '') && - !request.startup?.launchAgent && - !request.startup?.launchConfig && - !request.startupPlan && - !request.launchDraftPrompt && + (request.agent !== null + ? Boolean(request.startupPlan) + : (request.startup === undefined || request.startup.command === '') && + !request.startup?.launchAgent && + !request.startup?.launchConfig && + !request.startupPlan && + !request.launchDraftPrompt) && !request.issueCommand && !request.ephemeralVmRecipe && !request.ephemeralVmRuntimeId && diff --git a/src/shared/worktree/types.ts b/src/shared/worktree/types.ts index e368716dc01..e787620df9e 100644 --- a/src/shared/worktree/types.ts +++ b/src/shared/worktree/types.ts @@ -114,9 +114,7 @@ export type Worktree = { * grant newly-created worktrees a short grace window at the top of Recent, * immune to ambient PTY-bump reordering in other worktrees. */ createdAt?: number - /** Agent selected when Orca originally created the worktree. Used only to - * seed a replacement terminal if the user later reopens the worktree after - * closing every visible surface. */ + /** Agent selected at creation; metadata alone never requests an agent launch. */ createdWithAgent?: TuiAgent /** True while an auto-named workspace is waiting for the first agent message * to drive the branch/title rename. */