diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts index 45a5c1588de..dd5b39d3ff5 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts @@ -6,7 +6,7 @@ import type { AgentSessionHandleProvider } from '../../../../shared/agent-sessio import { hasRuntimeRpcErrorCode } from '../../../../shared/runtime-rpc-error-code' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { prepareAiVaultSessionForResume } from '@/lib/ai-vault-session-resume-preparation' -import { settleStructuredAgentLaunch } from '@/lib/structured-agent-launch-settlement' +import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' import { activateAndRevealFolderWorkspace, activateAndRevealWorktree @@ -21,9 +21,10 @@ export function activateAiVaultResumeWorkspace(workspaceId: string): void { activateAndRevealWorktree(workspaceId) } -/** Adopt a vault conversation into a new structured chat. No legacy fallback: resume has no - * terminal equivalent short of the resume command, and switching surface silently would hide - * the refusal the user needs to see. */ +/** Adopt a vault conversation into a new structured chat. The route was decided by the + * eligibility gate that showed this action, so it re-enters as a verdict. No legacy fallback: + * resume has no terminal equivalent short of the resume command, and switching surface silently + * would hide the refusal the user needs to see. */ export async function resumeAiVaultSessionInNewChat( session: AiVaultSession, agent: AgentSessionHandleProvider, @@ -33,18 +34,18 @@ export async function resumeAiVaultSessionInNewChat( // Codex rows can live under a shared legacy home; the same preparation the terminal resume // runs re-pins them, and its result is what names the conversation the host will look for. const preparedSession = await prepareAiVaultSessionForResume(session) - const settlement = await settleStructuredAgentLaunch( - worktreeId, + const settlement = await adoptAgentSessionLaunchVerdict({ + route: 'structured-native-chat', agent, - { resumeFrom: { providerSessionId: preparedSession.sessionId } }, - {} - ) - if (settlement.kind === 'failed') { + worktreeId, + resumeFrom: { providerSessionId: preparedSession.sessionId } + }).launch({}) + if (settlement?.kind === 'failed') { notifyAiVaultSessionResumeInChatFailure(settlement.error) return } // Why: an unknown outcome is not a failure; the launch layer reconciles it on the next attempt. - if (settlement.kind !== 'structured') { + if (settlement?.kind !== 'structured') { return } if (useAppStore.getState().activeWorktreeId !== worktreeId) { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts index 8eaca8449bc..d0b2bc786be 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts @@ -1,7 +1,5 @@ -import { - structuredAgentLaunchSupportedForWorkspace, - workspaceKindForWorktreeId -} from '@/lib/agent-launch-route-input' +import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' +import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' import { useAppStore } from '@/store' import type { AiVaultSession } from '../../../../shared/ai-vault-types' @@ -36,13 +34,14 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: { structuredRouteAvailable: isAgentSessionHandleProvider(args.session.agent) && targetWorkspaceId !== null && - structuredAgentLaunchSupportedForWorkspace(useAppStore.getState(), { + planAgentSessionLaunch(useAppStore.getState(), { agent: args.session.agent, workspace: { kind: workspaceKindForWorktreeId(targetWorkspaceId), worktreeId: targetWorkspaceId - } - }) && + }, + explicitStructured: true + }).route === 'structured-native-chat' && readLocalRuntimeCapabilities().includes( STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY ) diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 964e514d7c9..024cab700f2 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -10,8 +10,10 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { useAppStore } from '@/store' -import { revealOnboardingFolderWithAgentLaunch } from '@/lib/onboarding-folder-agent-launch' -import { resolveDismissedOnboardingFolderAgentLaunch } from '@/lib/onboarding-folder-agent-startup' +import { + resolveDismissedOnboardingFolderAgentLaunch, + revealOnboardingFolderWithAgentLaunch +} from '@/lib/onboarding-folder-agent-launch' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 3d439a82346..34eab00e730 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -18,10 +18,8 @@ import { getLinkedItemDisplayName, toFolderWorkspaceLinkedTask } from './folder-workspace-composer-helpers' -import { resolveAgentLaunchRouteForWorkspace } from '@/lib/agent-launch-route-input' +import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' import { getNewWorkspaceProjectGroupHostId } from '@/lib/new-workspace-project-options' -import { settleStructuredAgentLaunch } from '@/lib/structured-agent-launch-settlement' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { useAppStore } from '@/store' import { buildFolderWorkspaceLinkedStartupPlan, @@ -132,23 +130,21 @@ export async function submitFolderWorkspaceCreate({ // `startupPlan.draftPrompt` alone can't tell whether this launch has one. const launchDraftPrompt = quickAgent && linkedWorkItem ? resolveFolderWorkspaceLaunchDraft(linkedWorkItem, note) : null - const launchPrompt = launchDraftPrompt ?? note - const promptDelivery = launchDraftPrompt ? 'draft' : 'auto-submit' - const agentLaunchRoute = quickAgent - ? resolveAgentLaunchRouteForWorkspace(useAppStore.getState(), { + const plan = quickAgent + ? planAgentSessionLaunch(useAppStore.getState(), { agent: quickAgent, workspace: { kind: 'folder', runtimeEnvironmentId, executionHostId: getNewWorkspaceProjectGroupHostId(projectGroup) }, - prompt: launchPrompt, - promptDelivery, + prompt: launchDraftPrompt ?? note, + promptDelivery: launchDraftPrompt ? 'draft' : 'auto-submit', tuiCustomization: { agentArgs }, initialSessionOptions: startupPlan?.sessionOptions }) - : 'terminal-tui' - const structuredLaunch = agentLaunchRoute === 'structured-native-chat' + : null + const structuredLaunch = plan?.route === 'structured-native-chat' // Why: the pending badge should only appear when the submitted prompt can // actually produce the first agent message that names the workspace. const pendingFirstAgentMessageRename = @@ -216,35 +212,37 @@ export async function submitFolderWorkspaceCreate({ runtimeEnvironmentId }) let structuredLaunchAccepted = structuredLaunch - if (structuredLaunch && isAgentSessionHandleProvider(quickAgent)) { - const settlement = await settleStructuredAgentLaunch( - folderWorkspaceKey(workspace.id), - quickAgent, - { prompt: launchPrompt, promptDelivery }, - { - legacyFallback: async () => { - if (pendingFirstAgentMessageRename) { - await useAppStore - .getState() - .updateFolderWorkspace(workspace.id, { pendingFirstAgentMessageRename: true }) - .catch(() => undefined) - } - await preflightAgentTrust({ - agent: quickAgent, - workspacePath: workspace.folderPath, - connectionId: workspace.connectionId ?? projectGroup.connectionId - }) - const fallbackActivation = activateAndRevealFolderWorkspace(workspace.id, { - ...(startup ? { startup } : {}), - runtimeEnvironmentId - }) - return { - activation: fallbackActivation, - primaryTabId: fallbackActivation === false ? null : fallbackActivation.primaryTabId - } - } - } - ) + const settlement = + plan?.route === 'structured-native-chat' + ? await plan.launch( + { + legacyFallback: async () => { + if (pendingFirstAgentMessageRename) { + await useAppStore + .getState() + .updateFolderWorkspace(workspace.id, { pendingFirstAgentMessageRename: true }) + .catch(() => undefined) + } + await preflightAgentTrust({ + agent: quickAgent, + workspacePath: workspace.folderPath, + connectionId: workspace.connectionId ?? projectGroup.connectionId + }) + const fallbackActivation = activateAndRevealFolderWorkspace(workspace.id, { + ...(startup ? { startup } : {}), + runtimeEnvironmentId + }) + return { + activation: fallbackActivation, + primaryTabId: + fallbackActivation === false ? null : fallbackActivation.primaryTabId + } + } + }, + { worktreeId: folderWorkspaceKey(workspace.id) } + ) + : null + if (settlement) { // Why: the workspace exists either way. Unknown keeps reporting false and failed true, as // the boolean did before the loop was shared; the launch layer owns the failure toast. if (settlement.kind === 'visibility-unknown') { diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.ts index b1cb54fd246..28961e355c6 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-execution.ts @@ -37,7 +37,7 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus' import { useAppStore } from '@/store' -import { resolveAgentLaunchRouteForWorkspace } from '@/lib/agent-launch-route-input' +import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' import { settleFullCreationStructuredLaunch } from './full-creation-structured-launch' import { finalizeFullCreation } from './full-creation-finalization' import { buildFullCreationIssueCommand } from './full-creation-issue-command' @@ -129,20 +129,18 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { return } - const launchPrompt = startupPlan?.draftPrompt ?? submitStartupPrompt - const promptDelivery = startupPlan?.draftPrompt ? 'draft' : 'auto-submit' - const agentLaunchRoute = resolveAgentLaunchRouteForWorkspace(useAppStore.getState(), { + const launchPlan = planAgentSessionLaunch(useAppStore.getState(), { agent: tuiAgent, workspace: { kind: selectedRepoIsGit ? 'git-worktree' : 'folder', repoId, executionHostId: selectedRepoExecutionHostId ?? undefined }, - prompt: launchPrompt, - promptDelivery, + prompt: startupPlan?.draftPrompt ?? submitStartupPrompt, + promptDelivery: startupPlan?.draftPrompt ? 'draft' : 'auto-submit', initialSessionOptions: startupPlan?.sessionOptions }) - const structuredLaunch = agentLaunchRoute === 'structured-native-chat' + const structuredLaunch = launchPlan.route === 'structured-native-chat' const effectiveBackendStartup = structuredLaunch ? undefined : backendStartup const result = await createWorktree( @@ -230,11 +228,8 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { }) const settlement = await settleFullCreationStructuredLaunch({ - structuredLaunch, - agent: tuiAgent, + plan: launchPlan, worktreeId: worktree.id, - prompt: launchPrompt, - promptDelivery, startup, pendingFirstAgentMessageRename, applyWorktreeMeta diff --git a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts index af9ccff38a4..4a7b569bf79 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts @@ -18,14 +18,25 @@ vi.mock('@/lib/structured-agent-session-tab-activation', () => ({ activateStructuredAgentSessionById: mocks.activateStructuredAgentSessionById })) +import { + adoptAgentSessionLaunchVerdict, + type AgentSessionLaunchVerdict +} from '@/lib/agent-session-launch-plan' import { settleFullCreationStructuredLaunch } from './full-creation-structured-launch' +/** Planned before the worktree existed, so the verdict names no workspace. */ +const plan = (overrides: Partial = {}) => + adoptAgentSessionLaunchVerdict({ + route: 'structured-native-chat', + agent: 'codex', + prompt: 'Fix the route', + promptDelivery: 'auto-submit', + ...overrides + }) + const baseArgs = { - structuredLaunch: true, - agent: 'codex' as const, + plan: plan(), worktreeId: 'worktree-1', - prompt: 'Fix the route', - promptDelivery: 'auto-submit' as const, startup: { command: 'codex' } as never, pendingFirstAgentMessageRename: true, applyWorktreeMeta: vi.fn().mockResolvedValue(undefined) @@ -36,7 +47,7 @@ describe('settleFullCreationStructuredLaunch', () => { it('skips the loop when the route is not structured', async () => { await expect( - settleFullCreationStructuredLaunch({ ...baseArgs, structuredLaunch: false }) + settleFullCreationStructuredLaunch({ ...baseArgs, plan: plan({ route: 'terminal-tui' }) }) ).resolves.toBeNull() expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled() }) @@ -50,7 +61,7 @@ describe('settleFullCreationStructuredLaunch', () => { ) await expect( - settleFullCreationStructuredLaunch({ ...baseArgs, promptDelivery: 'draft' }) + settleFullCreationStructuredLaunch({ ...baseArgs, plan: plan({ promptDelivery: 'draft' }) }) ).resolves.toEqual({ kind: 'structured', sessionId: 'session-1' }) expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith( 'worktree-1', diff --git a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts index 434e09dd20a..c7e19026ba7 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts @@ -1,21 +1,15 @@ -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' -import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { - settleStructuredAgentLaunch, - type StructuredAgentLaunchSettlement -} from '@/lib/structured-agent-launch-settlement' +import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation' /** Full-create dialog: the structured launch plus what this flow did before structured chat - * existed. Returns null when the route is not structured. */ + * existed. Returns null when the plan's route is not structured. */ export async function settleFullCreationStructuredLaunch(args: { - structuredLaunch: boolean - agent: TuiAgent + /** Planned before the worktree existed; `worktreeId` names the one that was created. */ + plan: AgentSessionLaunchPlan worktreeId: string - prompt: string - promptDelivery: 'draft' | 'auto-submit' startup: WorktreeStartupPayload | undefined pendingFirstAgentMessageRename: boolean applyWorktreeMeta: ( @@ -23,13 +17,7 @@ export async function settleFullCreationStructuredLaunch(args: { meta: { pendingFirstAgentMessageRename: boolean } ) => Promise }): Promise { - if (!args.structuredLaunch || !isAgentSessionHandleProvider(args.agent)) { - return null - } - return settleStructuredAgentLaunch( - args.worktreeId, - args.agent, - { prompt: args.prompt, promptDelivery: args.promptDelivery }, + return args.plan.launch( { legacyFallback: async () => { if (args.pendingFirstAgentMessageRename) { @@ -46,6 +34,7 @@ export async function settleFullCreationStructuredLaunch(args: { }, onStructuredReady: (sessionId) => activateStructuredAgentSessionById({ worktreeId: args.worktreeId, sessionId }) - } + }, + { worktreeId: args.worktreeId } ) } 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 30703af0dd7..324bf694025 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -46,7 +46,7 @@ import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-c import { buildQuickComposerStartup } from './quick-startup-plan' import { buildQuickCreationRequest } from './quick-creation-request' import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions' -import { resolveAgentLaunchRouteForWorkspace } from '@/lib/agent-launch-route-input' +import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' export function useQuickCreationExecution(input: QuickCreationExecutionInput) { const { @@ -195,8 +195,9 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { } const promptDelivery = quickDraftPrompt ? 'draft' : 'auto-submit' + // Why: the verdict is persisted on the request as data and re-entered once the worktree exists. const agentLaunchRoute = agent - ? resolveAgentLaunchRouteForWorkspace(useAppStore.getState(), { + ? planAgentSessionLaunch(useAppStore.getState(), { agent, workspace: { kind: selectedRepoIsGit ? 'git-worktree' : 'folder', @@ -208,7 +209,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { prompt: quickDraftPrompt ?? quickPrompt, promptDelivery, initialSessionOptions: startupPlan?.sessionOptions - }) + }).route : 'terminal-tui' const structuredLaunch = agentLaunchRoute === 'structured-native-chat' diff --git a/src/renderer/src/lib/agent-launch-route-input.test.ts b/src/renderer/src/lib/agent-launch-route-input.test.ts index 0f5463d0e44..6d079b093ee 100644 --- a/src/renderer/src/lib/agent-launch-route-input.test.ts +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -24,14 +24,24 @@ vi.mock('@/lib/local-preflight-context', () => ({ vi.mock('@/runtime/local-runtime-capabilities', () => ({ readLocalRuntimeCapabilitiesOrUnknown: mocks.readLocalRuntimeCapabilitiesOrUnknown })) +// Why: the planner is the only route consumer; its settle loop is out of scope here. +vi.mock('@/lib/structured-agent-launch-settlement', () => ({ + settleStructuredAgentLaunch: vi.fn() +})) import { buildAgentLaunchRouteInput, - resolveAgentLaunchRouteForWorkspace, - structuredAgentLaunchSupportedForWorkspace, workspaceKindForWorktreeId, + type AgentLaunchRouteArgs, type AgentLaunchRouteStore } from './agent-launch-route-input' +import { planAgentSessionLaunch } from './agent-session-launch-plan' + +const routeFor = (appStore: AgentLaunchRouteStore, args: AgentLaunchRouteArgs) => + planAgentSessionLaunch(appStore, args).route +const explicitStructuredFor = (appStore: AgentLaunchRouteStore, args: AgentLaunchRouteArgs) => + planAgentSessionLaunch(appStore, { ...args, explicitStructured: true }).route === + 'structured-native-chat' const STRUCTURED_SETTINGS = { experimentalNativeChat: true, @@ -97,7 +107,7 @@ describe('buildAgentLaunchRouteInput', () => { expect(mocks.getLocalProjectExecutionRuntimeContext).toHaveBeenCalledWith(appStore, 'wt-1') expect(mocks.getLocalRepoProjectExecutionRuntimeContext).not.toHaveBeenCalled() expect( - resolveAgentLaunchRouteForWorkspace(appStore, { + routeFor(appStore, { agent: 'codex', workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } }) @@ -117,7 +127,7 @@ describe('buildAgentLaunchRouteInput', () => { expect(mocks.getLocalProjectExecutionRuntimeContext).not.toHaveBeenCalled() expect(mocks.getLocalRepoProjectExecutionRuntimeContext).not.toHaveBeenCalled() expect( - structuredAgentLaunchSupportedForWorkspace(store(), { + explicitStructuredFor(store(), { agent: 'claude', workspace: { kind: 'git-worktree', worktreeId: 'wt-remote' } }) @@ -142,7 +152,7 @@ describe('buildAgentLaunchRouteInput', () => { expect(mocks.getExecutionHostIdForWorktree).not.toHaveBeenCalled() expect(mocks.getLocalProjectExecutionRuntimeContext).not.toHaveBeenCalled() expect( - resolveAgentLaunchRouteForWorkspace(appStore, { + routeFor(appStore, { agent: 'codex', workspace: { kind: 'git-worktree', repoId: 'repo-1' }, prompt: 'issue body', @@ -203,7 +213,7 @@ describe('buildAgentLaunchRouteInput', () => { expect(input.projectRuntime).toBeUndefined() expect(mocks.getLocalProjectExecutionRuntimeContext).not.toHaveBeenCalled() expect( - structuredAgentLaunchSupportedForWorkspace(store(), { + explicitStructuredFor(store(), { agent: 'codex', workspace: { kind: 'floating', worktreeId: FLOATING_TERMINAL_WORKTREE_ID } }) @@ -218,8 +228,8 @@ describe('buildAgentLaunchRouteInput', () => { promptDelivery: 'draft' as const } expect(buildAgentLaunchRouteInput(store(), args).promptDelivery).toBe('draft') - expect(resolveAgentLaunchRouteForWorkspace(store(), args)).toBe('structured-native-chat') - expect(structuredAgentLaunchSupportedForWorkspace(store(), args)).toBe(true) + expect(routeFor(store(), args)).toBe('structured-native-chat') + expect(explicitStructuredFor(store(), args)).toBe(true) }) it.each([ diff --git a/src/renderer/src/lib/agent-launch-route-input.ts b/src/renderer/src/lib/agent-launch-route-input.ts index c1632adb70c..6987298ab37 100644 --- a/src/renderer/src/lib/agent-launch-route-input.ts +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -9,13 +9,10 @@ import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { hasExplicitTuiAgentArgs, hasExplicitTuiLaunchCustomization, - resolveAgentLaunchRoute, - structuredAgentLaunchSupported, - type AgentLaunchRoute, type AgentLaunchRoutingInput } from '@/lib/agent-launch-routing' -// Why: the store root imports the onboarding launch path, which imports this module; the -// resolver's own module keeps that graph acyclic where the `connection-context` facade would not. +// Why: the `connection-context` facade imports the store root; the resolver's own module keeps +// this input builder importable from anywhere in the launch graph without a cycle. import { getConnectionIdFromState } from '@/lib/connection-owner-resolution' import { getLocalProjectExecutionRuntimeContext, @@ -104,7 +101,7 @@ function resolveTranscriptIsLocalReadable( return host?.kind === 'ssh' ? isNativeChatTranscriptLocalReadable(host.targetId) : true } -/** The one place that gathers what a launch route decision needs; no caller assembles it by hand. */ +/** The one place that gathers what a launch route decision needs; only the planner resolves on it. */ export function buildAgentLaunchRouteInput( store: AgentLaunchRouteStore, args: AgentLaunchRouteArgs @@ -132,18 +129,3 @@ export function buildAgentLaunchRouteInput( initialSessionOptions: args.initialSessionOptions } } - -export function resolveAgentLaunchRouteForWorkspace( - store: AgentLaunchRouteStore, - args: AgentLaunchRouteArgs -): AgentLaunchRoute { - return resolveAgentLaunchRoute(buildAgentLaunchRouteInput(store, args)) -} - -/** An explicit chat request: structured feasibility without the default-view-mode gate. */ -export function structuredAgentLaunchSupportedForWorkspace( - store: AgentLaunchRouteStore, - args: AgentLaunchRouteArgs -): boolean { - return structuredAgentLaunchSupported(buildAgentLaunchRouteInput(store, args)) -} diff --git a/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts b/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts index c4b9e4a01c1..a552cbcd834 100644 --- a/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts +++ b/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts @@ -20,12 +20,10 @@ const LAUNCH_AGENT_IN_NEW_TAB_CALLERS = [ 'src/renderer/src/lib/run-quick-command-in-new-tab.ts' ] -// Why: every route decision must gather its inputs through the one builder. A direct call to -// the resolver is how the seven launch sites drifted apart before it existed. -const ROUTE_RESOLVER_OWNERS = [ - 'src/renderer/src/lib/agent-launch-route-input.ts', - 'src/renderer/src/lib/agent-launch-routing.ts' -] +// Why: the planner is the one production module that decides a route. A second resolver call +// site is how the seven launch sites drifted apart before it existed. +const ROUTE_RESOLVER_DEFINITION = 'src/renderer/src/lib/agent-launch-routing.ts' +const ROUTE_PLANNER = 'src/renderer/src/lib/agent-session-launch-plan.ts' const DIRECT_ROUTE_RESOLVER_CALL = /\b(?:resolveAgentLaunchRoute|structuredAgentLaunchSupported)\(/ async function productionFiles(): Promise { @@ -46,14 +44,14 @@ describe('agent launch routing caller census', () => { expect(callers).toEqual([...LAUNCH_AGENT_IN_NEW_TAB_CALLERS].sort()) }) - it('routes every launch decision through the one route-input builder', async () => { + it('lets only the planner decide a launch route', async () => { const directCallers = (await productionFiles()) - .filter((file) => !ROUTE_RESOLVER_OWNERS.includes(file)) + .filter((file) => file !== ROUTE_RESOLVER_DEFINITION) .filter((file) => DIRECT_ROUTE_RESOLVER_CALL.test(readFileSync(join(REPO_ROOT, file), 'utf8')) ) .sort() - expect(directCallers).toEqual([]) + expect(directCallers).toEqual([ROUTE_PLANNER]) }) it('keeps non-visible, resume, and floating launchers intentionally outside the route', () => { diff --git a/src/renderer/src/lib/agent-session-launch-plan.test.ts b/src/renderer/src/lib/agent-session-launch-plan.test.ts new file mode 100644 index 00000000000..73b26670ee4 --- /dev/null +++ b/src/renderer/src/lib/agent-session-launch-plan.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentLaunchRouteStore } from './agent-launch-route-input' + +const mocks = vi.hoisted(() => ({ + buildAgentLaunchRouteInput: vi.fn(), + resolveAgentLaunchRoute: vi.fn(), + structuredAgentLaunchSupported: vi.fn(), + settleStructuredAgentLaunch: vi.fn() +})) + +vi.mock('@/lib/agent-launch-route-input', () => ({ + buildAgentLaunchRouteInput: mocks.buildAgentLaunchRouteInput +})) +vi.mock('@/lib/agent-launch-routing', () => ({ + resolveAgentLaunchRoute: mocks.resolveAgentLaunchRoute, + structuredAgentLaunchSupported: mocks.structuredAgentLaunchSupported +})) +vi.mock('@/lib/structured-agent-launch-settlement', () => ({ + settleStructuredAgentLaunch: mocks.settleStructuredAgentLaunch +})) + +import { adoptAgentSessionLaunchVerdict, planAgentSessionLaunch } from './agent-session-launch-plan' + +const store = { settings: {} } as unknown as AgentLaunchRouteStore +const ROUTE_INPUT = { agent: 'codex', executionHostId: 'local' } +const STRUCTURED = { kind: 'structured', sessionId: 'session-1' } +const hooks = { onStructuredReady: vi.fn() } + +describe('planAgentSessionLaunch', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.buildAgentLaunchRouteInput.mockReturnValue(ROUTE_INPUT) + mocks.resolveAgentLaunchRoute.mockReturnValue('structured-native-chat') + mocks.structuredAgentLaunchSupported.mockReturnValue(true) + mocks.settleStructuredAgentLaunch.mockResolvedValue(STRUCTURED) + }) + + it('decides the route once, from the builder input, and never again on launch', async () => { + const plan = planAgentSessionLaunch(store, { + agent: 'codex', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' }, + prompt: 'Fix it', + promptDelivery: 'draft' + }) + + expect(plan.route).toBe('structured-native-chat') + expect(mocks.buildAgentLaunchRouteInput).toHaveBeenCalledWith( + store, + expect.objectContaining({ agent: 'codex', prompt: 'Fix it', promptDelivery: 'draft' }) + ) + expect(mocks.resolveAgentLaunchRoute).toHaveBeenCalledWith(ROUTE_INPUT) + expect(mocks.structuredAgentLaunchSupported).not.toHaveBeenCalled() + + await plan.launch(hooks) + await plan.launch(hooks) + expect(mocks.buildAgentLaunchRouteInput).toHaveBeenCalledOnce() + expect(mocks.resolveAgentLaunchRoute).toHaveBeenCalledOnce() + }) + + it('hands the settle loop exactly the prompt, mode, resume source, and delivery hook it planned on', async () => { + const onPromptDelivered = vi.fn() + const resumeFrom = { providerSessionId: 'provider-1' } + const plan = planAgentSessionLaunch(store, { + agent: 'claude', + workspace: { kind: 'folder', worktreeId: 'folder:ws-1' }, + prompt: 'Review this', + promptDelivery: 'submit-after-ready', + resumeFrom, + onPromptDelivered + }) + + await expect(plan.launch(hooks)).resolves.toBe(STRUCTURED) + expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith( + 'folder:ws-1', + 'claude', + { + prompt: 'Review this', + promptDelivery: 'submit-after-ready', + resumeFrom, + onPromptDelivered + }, + hooks + ) + }) + + it('sends no delivery fields the request did not carry', async () => { + const plan = planAgentSessionLaunch(store, { + agent: 'codex', + workspace: { kind: 'folder', worktreeId: 'folder:ws-1' } + }) + + await plan.launch(hooks) + expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith( + 'folder:ws-1', + 'codex', + {}, + hooks + ) + }) + + it.each(['legacy-native-chat', 'terminal-tui'] as const)( + 'returns null from launch on the %s route without touching the loop', + async (route) => { + mocks.resolveAgentLaunchRoute.mockReturnValue(route) + const plan = planAgentSessionLaunch(store, { + agent: 'codex', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } + }) + + expect(plan.route).toBe(route) + await expect(plan.launch(hooks)).resolves.toBeNull() + expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled() + } + ) + + it('returns null for an agent that cannot hold a structured session even on the structured route', async () => { + const plan = planAgentSessionLaunch(store, { + agent: 'gemini', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } + }) + + await expect(plan.launch(hooks)).resolves.toBeNull() + expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled() + }) + + it.each([ + [true, 'structured-native-chat'], + [false, 'legacy-native-chat'] + ])( + 'answers an explicit chat request from structured feasibility alone (supported=%s)', + (supported, route) => { + mocks.structuredAgentLaunchSupported.mockReturnValue(supported) + const plan = planAgentSessionLaunch(store, { + agent: 'codex', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' }, + explicitStructured: true + }) + + expect(plan.route).toBe(route) + expect(mocks.structuredAgentLaunchSupported).toHaveBeenCalledWith(ROUTE_INPUT) + expect(mocks.resolveAgentLaunchRoute).not.toHaveBeenCalled() + } + ) + + it('launches into the workspace created after planning when the target names one', async () => { + const plan = planAgentSessionLaunch(store, { + agent: 'codex', + workspace: { kind: 'git-worktree', repoId: 'repo-1' }, + prompt: 'Fix it', + promptDelivery: 'auto-submit' + }) + + await plan.launch(hooks, { worktreeId: 'wt-created' }) + expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith( + 'wt-created', + 'codex', + { prompt: 'Fix it', promptDelivery: 'auto-submit' }, + hooks + ) + }) + + it('refuses to launch a prospective workspace that was never created', async () => { + const plan = planAgentSessionLaunch(store, { + agent: 'codex', + workspace: { kind: 'git-worktree', repoId: 'repo-1' } + }) + + await expect(plan.launch(hooks)).rejects.toThrow(/workspace/) + expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled() + }) +}) + +describe('adoptAgentSessionLaunchVerdict', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.settleStructuredAgentLaunch.mockResolvedValue(STRUCTURED) + }) + + it('re-enters a persisted verdict without resolving the route again', async () => { + const plan = adoptAgentSessionLaunchVerdict({ + route: 'structured-native-chat', + agent: 'codex', + prompt: 'Fix it', + promptDelivery: 'draft' + }) + + expect(plan.route).toBe('structured-native-chat') + await expect(plan.launch(hooks, { worktreeId: 'wt-recovered' })).resolves.toBe(STRUCTURED) + expect(mocks.buildAgentLaunchRouteInput).not.toHaveBeenCalled() + expect(mocks.resolveAgentLaunchRoute).not.toHaveBeenCalled() + expect(mocks.structuredAgentLaunchSupported).not.toHaveBeenCalled() + expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith( + 'wt-recovered', + 'codex', + { prompt: 'Fix it', promptDelivery: 'draft' }, + hooks + ) + }) + + it('keeps a non-structured verdict out of the loop', async () => { + const plan = adoptAgentSessionLaunchVerdict({ + route: 'terminal-tui', + agent: 'codex', + worktreeId: 'wt-1' + }) + + await expect(plan.launch(hooks)).resolves.toBeNull() + expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts new file mode 100644 index 00000000000..037075b8740 --- /dev/null +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -0,0 +1,114 @@ +import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' +import type { TuiAgent } from '../../../shared/tui-agent' +import { + buildAgentLaunchRouteInput, + type AgentLaunchRouteArgs, + type AgentLaunchRouteStore +} from '@/lib/agent-launch-route-input' +import { + resolveAgentLaunchRoute, + structuredAgentLaunchSupported, + type AgentLaunchRoute +} from '@/lib/agent-launch-routing' +import type { NativeChatLaunchPromptDelivery } from '@/lib/native-chat-initial-view-mode' +import { + settleStructuredAgentLaunch, + type StructuredAgentLaunchHooks, + type StructuredAgentLaunchSettlement +} from '@/lib/structured-agent-launch-settlement' +import type { StructuredAgentLaunchOptions } from '@/lib/structured-agent-session-launch' + +export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & { + /** An explicit chat request (vault resume): structured feasibility without the default-view-mode gate. */ + explicitStructured?: boolean + resumeFrom?: StructuredAgentSessionResumeSource + onPromptDelivered?: () => void +} + +/** + * A route decided once plus exactly what its structured launch delivers. The persisted quick-create + * request carries the data fields, so a launch that happens after the workspace exists (or a + * recovery replay) re-enters here without re-resolving. + */ +export type AgentSessionLaunchVerdict = { + route: AgentLaunchRoute + agent: TuiAgent + worktreeId?: string + prompt?: string + promptDelivery?: NativeChatLaunchPromptDelivery + resumeFrom?: StructuredAgentSessionResumeSource + onPromptDelivered?: () => void +} + +export type AgentSessionLaunchTarget = { + /** Overrides the verdict's workspace when it was created after planning. */ + worktreeId?: string +} + +export type AgentSessionLaunchPlan = Readonly & { + /** Runs the structured settle loop for this plan. Null when the route is not structured. */ + launch( + hooks: StructuredAgentLaunchHooks, + target?: AgentSessionLaunchTarget + ): Promise +} + +function structuredLaunchOptions(verdict: AgentSessionLaunchVerdict): StructuredAgentLaunchOptions { + return { + ...(verdict.prompt !== undefined ? { prompt: verdict.prompt } : {}), + ...(verdict.promptDelivery ? { promptDelivery: verdict.promptDelivery } : {}), + ...(verdict.resumeFrom ? { resumeFrom: verdict.resumeFrom } : {}), + ...(verdict.onPromptDelivered ? { onPromptDelivered: verdict.onPromptDelivered } : {}) + } +} + +/** Re-enter with a verdict decided earlier; the route is data here and is never re-resolved. */ +export function adoptAgentSessionLaunchVerdict( + verdict: AgentSessionLaunchVerdict +): AgentSessionLaunchPlan { + return { + ...verdict, + launch: async (hooks, target) => { + if ( + verdict.route !== 'structured-native-chat' || + !isAgentSessionHandleProvider(verdict.agent) + ) { + return null + } + const worktreeId = target?.worktreeId ?? verdict.worktreeId + if (!worktreeId) { + throw new Error('A structured agent launch needs the workspace it targets.') + } + return settleStructuredAgentLaunch( + worktreeId, + verdict.agent, + structuredLaunchOptions(verdict), + hooks + ) + } + } +} + +/** The one place a launch route is decided. Delivery mode is fixed here too, so the settle loop + * later receives exactly the prompt and mode the route was decided on. */ +export function planAgentSessionLaunch( + store: AgentLaunchRouteStore, + request: AgentSessionLaunchRequest +): AgentSessionLaunchPlan { + const input = buildAgentLaunchRouteInput(store, request) + const route: AgentLaunchRoute = request.explicitStructured + ? structuredAgentLaunchSupported(input) + ? 'structured-native-chat' + : 'legacy-native-chat' + : resolveAgentLaunchRoute(input) + return adoptAgentSessionLaunchVerdict({ + route, + agent: request.agent, + ...(request.workspace.worktreeId ? { worktreeId: request.workspace.worktreeId } : {}), + ...(request.prompt !== undefined ? { prompt: request.prompt } : {}), + ...(request.promptDelivery ? { promptDelivery: request.promptDelivery } : {}), + ...(request.resumeFrom ? { resumeFrom: request.resumeFrom } : {}), + ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}) + }) +} diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts index fe0c18ce03b..da95db63aca 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts @@ -12,8 +12,20 @@ vi.mock('@/lib/structured-agent-launch-settlement', () => ({ settleStructuredAgentLaunch: mocks.settleStructuredAgentLaunch })) +import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' import { launchAgentInStructuredNewTab } from './launch-agent-in-new-tab-structured' +type Delivery = 'auto-submit' | 'submit-after-ready' | 'draft' +const structuredPlan = (prompt: string, promptDelivery: Delivery, onPromptDelivered?: () => void) => + adoptAgentSessionLaunchVerdict({ + route: 'structured-native-chat', + agent: 'codex', + worktreeId: 'wt-1', + prompt, + promptDelivery, + ...(onPromptDelivered ? { onPromptDelivered } : {}) + }) + const delivered = { delivered: true, failureNotified: false } const undelivered = { delivered: false, failureNotified: true } @@ -56,11 +68,7 @@ describe('launchAgentInStructuredNewTab', () => { const onPromptDelivered = vi.fn() const result = launchAgentInStructuredNewTab({ - worktreeId: 'wt-1', - agent: 'codex', - prompt: 'Fix it', - promptDelivery: 'submit-after-ready', - onPromptDelivered, + plan: structuredPlan('Fix it', 'submit-after-ready', onPromptDelivered), legacyLaunch }) @@ -91,10 +99,7 @@ describe('launchAgentInStructuredNewTab', () => { })) const result = launchAgentInStructuredNewTab({ - worktreeId: 'wt-1', - agent: 'codex', - prompt: 'Fix it', - promptDelivery: 'submit-after-ready', + plan: structuredPlan('Fix it', 'submit-after-ready'), legacyLaunch }) @@ -116,10 +121,7 @@ describe('launchAgentInStructuredNewTab', () => { })) const result = launchAgentInStructuredNewTab({ - worktreeId: 'wt-1', - agent: 'codex', - prompt: 'Fix it', - promptDelivery: 'auto-submit', + plan: structuredPlan('Fix it', 'auto-submit'), legacyLaunch }) @@ -131,10 +133,7 @@ describe('launchAgentInStructuredNewTab', () => { settleWith('refusal') const result = launchAgentInStructuredNewTab({ - worktreeId: 'wt-1', - agent: 'codex', - prompt: 'Fix it', - promptDelivery: 'auto-submit', + plan: structuredPlan('Fix it', 'auto-submit'), legacyLaunch: () => null }) @@ -151,10 +150,7 @@ describe('launchAgentInStructuredNewTab', () => { const legacyLaunch = vi.fn() const result = launchAgentInStructuredNewTab({ - worktreeId: 'wt-1', - agent: 'codex', - prompt: 'Fix it', - promptDelivery: 'submit-after-ready', + plan: structuredPlan('Fix it', 'submit-after-ready'), legacyLaunch }) @@ -169,10 +165,7 @@ describe('launchAgentInStructuredNewTab', () => { mocks.settleStructuredAgentLaunch.mockRejectedValue(error) const result = launchAgentInStructuredNewTab({ - worktreeId: 'wt-1', - agent: 'codex', - prompt: 'Fix it', - promptDelivery: 'submit-after-ready', + plan: structuredPlan('Fix it', 'submit-after-ready'), legacyLaunch: vi.fn() }) @@ -186,10 +179,7 @@ describe('launchAgentInStructuredNewTab', () => { const legacyLaunch = vi.fn() const result = launchAgentInStructuredNewTab({ - worktreeId: 'wt-1', - agent: 'codex', - prompt: 'Fix it', - promptDelivery: 'submit-after-ready', + plan: structuredPlan('Fix it', 'submit-after-ready'), legacyLaunch }) @@ -203,15 +193,13 @@ describe('launchAgentInStructuredNewTab', () => { }) it.each([ - ['no prompt', { prompt: '', promptDelivery: 'auto-submit' as const }], - ['a draft prompt', { prompt: 'Fix it', promptDelivery: 'draft' as const }] - ])('exposes no delivery promise for %s', async (_label, options) => { + ['no prompt', '', 'auto-submit' as const], + ['a draft prompt', 'Fix it', 'draft' as const] + ])('exposes no delivery promise for %s', async (_label, prompt, promptDelivery) => { settleWith({ kind: 'structured', sessionId: 'session-1' }) const result = launchAgentInStructuredNewTab({ - worktreeId: 'wt-1', - agent: 'codex', - ...options, + plan: structuredPlan(prompt, promptDelivery), legacyLaunch: vi.fn() }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured.ts index f210081d6fc..3daa361ceb6 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-structured.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-structured.ts @@ -1,20 +1,11 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import type { LaunchAgentInNewTabResult } from '@/lib/launch-agent-in-new-tab' -import { - settleStructuredAgentLaunch, - type StructuredAgentLaunchSettlement -} from '@/lib/structured-agent-launch-settlement' -import type { StructuredAgentLaunchOptions } from '@/lib/structured-agent-session-launch' +import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' -export type StructuredNewTabLaunchArgs = Pick< - StructuredAgentLaunchOptions, - 'promptDelivery' | 'onPromptDelivered' -> & { - worktreeId: string - agent: AgentSessionHandleProvider - /** Already trimmed; empty means no prompt. */ - prompt: string +export type StructuredNewTabLaunchArgs = { + /** Planned on the structured route with an already-trimmed prompt; empty means no prompt. */ + plan: AgentSessionLaunchPlan /** The terminal-backed launch with the same arguments. Runs at most once, on definitive refusal. */ legacyLaunch: () => LaunchAgentInNewTabResult } @@ -43,16 +34,9 @@ function promptDeliveryFromSettlement( export function launchAgentInStructuredNewTab( args: StructuredNewTabLaunchArgs ): StructuredNewTabLaunch { - const hasPrompt = args.prompt.length > 0 - const structuredSettlement = settleStructuredAgentLaunch( - args.worktreeId, - args.agent, - { - prompt: args.prompt, - promptDelivery: args.promptDelivery, - onPromptDelivered: args.onPromptDelivered - }, - { + const hasPrompt = Boolean(args.plan.prompt) + const structuredSettlement = args.plan + .launch({ legacyFallback: async () => { const fallback = args.legacyLaunch() // Why: a legacy launch with no delivery promise still delivered an argv-carried or draft @@ -67,8 +51,15 @@ export function launchAgentInStructuredNewTab( ...(promptDeliveryResult ? { promptDeliveryResult } : {}) } } - } - ).catch((error: unknown): StructuredAgentLaunchSettlement => ({ kind: 'failed', error })) + }) + .then( + (settlement): StructuredAgentLaunchSettlement => + settlement ?? { + kind: 'failed', + error: new Error('Launch planned off the structured route') + }, + (error: unknown): StructuredAgentLaunchSettlement => ({ kind: 'failed', error }) + ) void structuredSettlement.then((settlement) => { // Why: unknown already shows the launch badge and failed already toasted; this is the log // line the old fire-and-forget fallback claim kept. @@ -79,7 +70,7 @@ export function launchAgentInStructuredNewTab( return { structuredSettlement, // Why: draft mode has no delivery event; the composer adopts the text and the user sends it. - ...(hasPrompt && args.promptDelivery !== 'draft' + ...(hasPrompt && args.plan.promptDelivery !== 'draft' ? { promptDeliveryResult: structuredSettlement.then(promptDeliveryFromSettlement) } : {}) } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 3043edc6692..ddd5b97419f 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -30,11 +30,8 @@ import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import { launchAgentInStructuredNewTab } from '@/lib/launch-agent-in-new-tab-structured' import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' -import { - resolveAgentLaunchRouteForWorkspace, - workspaceKindForWorktreeId -} from '@/lib/agent-launch-route-input' +import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' +import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' export type LaunchAgentInNewTabArgs = { agent: TuiAgent @@ -199,23 +196,21 @@ function launchAgentInNewTabInternal( } } - const launchRoute = forceLegacy - ? 'legacy-native-chat' - : resolveAgentLaunchRouteForWorkspace(store, { + // Why: the legacy re-entry is the plan's own fallback; deciding a route again would loop. + const plan = forceLegacy + ? null + : planAgentSessionLaunch(store, { agent, workspace: { kind: workspaceKindForWorktreeId(worktreeId), worktreeId }, prompt: trimmedPrompt, promptDelivery: viewModePromptDelivery, tuiCustomization: { cwd: initialCwd, agentArgs }, - initialSessionOptions: startupPlan.sessionOptions + initialSessionOptions: startupPlan.sessionOptions, + onPromptDelivered }) - if (launchRoute === 'structured-native-chat' && isAgentSessionHandleProvider(agent)) { + if (plan?.route === 'structured-native-chat') { const structured = launchAgentInStructuredNewTab({ - worktreeId, - agent, - prompt: trimmedPrompt, - promptDelivery: viewModePromptDelivery, - onPromptDelivered, + plan, legacyLaunch: () => launchAgentInNewTabInternal(args, true) }) return { diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts index 111ad447091..fc3658bc52e 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts @@ -22,19 +22,25 @@ vi.mock('@/lib/native-chat-transcript-readability', () => ({ isNativeChatTranscriptLocalReadable: vi.fn(() => true) })) +import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' import { markDirectWorkItemAgentTrusted, settleDirectWorkItemStructuredLaunch } from './launch-work-item-direct-agent-routing' +const structuredPlan = adoptAgentSessionLaunchVerdict({ + route: 'structured-native-chat', + agent: 'codex', + worktreeId: 'worktree-1', + prompt: 'Fix the route', + promptDelivery: 'draft' +}) + const baseArgs = { - structuredLaunch: true, - agent: 'codex' as const, + plan: structuredPlan, worktreeId: 'worktree-1', workspacePath: '/repo/worktree', connectionId: null, - draftContent: 'Fix the route', - promptDelivery: 'draft' as const, primaryTabId: null, startupPlan: null, launchSource: 'task_page' as const @@ -125,7 +131,10 @@ describe('settleDirectWorkItemStructuredLaunch', () => { it('skips the loop when the route is not structured', async () => { await expect( - settleDirectWorkItemStructuredLaunch({ ...baseArgs, structuredLaunch: false }) + settleDirectWorkItemStructuredLaunch({ + ...baseArgs, + plan: adoptAgentSessionLaunchVerdict({ ...structuredPlan, route: 'legacy-native-chat' }) + }) ).resolves.toEqual({ completed: false, structuredLaunch: false, diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts index f324e18dcd7..144bcc053dc 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts @@ -1,4 +1,3 @@ -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import type { TuiAgent } from '../../../shared/tui-agent' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import type { LaunchSource } from '../../../shared/telemetry-events' @@ -10,7 +9,7 @@ import { buildDirectWorkItemAgentStartupPlan, buildDirectWorkItemStartupOpts } from '@/lib/launch-work-item-direct-agent' -import { settleStructuredAgentLaunch } from '@/lib/structured-agent-launch-settlement' +import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' import { preflightAgentTrust } from '@/lib/agent-trust-preflight' @@ -104,13 +103,10 @@ export async function markDirectWorkItemAgentTrusted(args: { } export async function settleDirectWorkItemStructuredLaunch(args: { - structuredLaunch: boolean - agent: TuiAgent | null + plan: AgentSessionLaunchPlan | null worktreeId: string workspacePath: string connectionId: string | null - draftContent: string - promptDelivery: PromptDelivery primaryTabId: string | null startupPlan: AgentStartupPlan | null launchSource: LaunchSource @@ -120,40 +116,40 @@ export async function settleDirectWorkItemStructuredLaunch(args: { visibilityUnknown: boolean primaryTabId: string | null }> { - if (!args.structuredLaunch || !isAgentSessionHandleProvider(args.agent)) { - return { - completed: false, - structuredLaunch: args.structuredLaunch, - visibilityUnknown: false, - primaryTabId: args.primaryTabId - } + const { plan } = args + const notLaunched = (structuredLaunch: boolean) => ({ + completed: false, + structuredLaunch, + visibilityUnknown: false, + primaryTabId: args.primaryTabId + }) + if (plan?.route !== 'structured-native-chat') { + return notLaunched(false) } - const agent = args.agent - const settlement = await settleStructuredAgentLaunch( - args.worktreeId, - agent, - { prompt: args.draftContent, promptDelivery: args.promptDelivery }, - { - legacyFallback: async () => { - await preflightAgentTrust({ + const { agent } = plan + const settlement = await plan.launch({ + legacyFallback: async () => { + await preflightAgentTrust({ + agent, + workspacePath: args.workspacePath, + connectionId: args.connectionId + }) + const activation = activateAndRevealWorktree(args.worktreeId, { + sidebarRevealBehavior: 'auto', + createNewTerminalForStartup: true, + ...buildDirectWorkItemStartupOpts( agent, - workspacePath: args.workspacePath, - connectionId: args.connectionId - }) - const activation = activateAndRevealWorktree(args.worktreeId, { - sidebarRevealBehavior: 'auto', - createNewTerminalForStartup: true, - ...buildDirectWorkItemStartupOpts( - agent, - args.startupPlan, - args.launchSource, - args.promptDelivery === 'draft' ? args.draftContent : undefined - ) - }) - return { activation, primaryTabId: activation === false ? null : activation.primaryTabId } - } + args.startupPlan, + args.launchSource, + plan.promptDelivery === 'draft' ? plan.prompt : undefined + ) + }) + return { activation, primaryTabId: activation === false ? null : activation.primaryTabId } } - ) + }) + if (!settlement) { + return notLaunched(true) + } switch (settlement.kind) { case 'structured': return { diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index de68b87dffc..5ded68be36d 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -3,7 +3,10 @@ import type { AppState } from '@/store/types' import { getConnectionId } from '@/lib/connection-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import type { resolveAgentLaunchRouteForWorkspace } from '@/lib/agent-launch-route-input' +import type { + AgentSessionLaunchPlan, + planAgentSessionLaunch +} from '@/lib/agent-session-launch-plan' import { buildDirectWorkItemStartup, markDirectWorkItemAgentTrusted, @@ -18,6 +21,8 @@ export type DirectWorkItemAgentLaunchPreparation = { draftLaunchedNatively: boolean startupPlanFailed: boolean structuredLaunch: boolean + /** Null when no agent was selected. */ + plan: AgentSessionLaunchPlan | null } export async function prepareDirectWorkItemAgentLaunch(args: { @@ -33,7 +38,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { promptDelivery: 'draft' | 'submit-after-ready' launchPlatform?: NodeJS.Platform repoProjectRuntime?: Parameters[0]['repoProjectRuntime'] - routeResolver: typeof resolveAgentLaunchRouteForWorkspace + planLaunch: typeof planAgentSessionLaunch }): Promise { const launchConnectionId = getConnectionId(args.worktreeId) ?? args.repoConnectionId const agentSelection = await resolveDirectWorkItemAgent({ @@ -51,7 +56,8 @@ export async function prepareDirectWorkItemAgentLaunch(args: { startupPlan: null, draftLaunchedNatively: false, startupPlanFailed: false, - structuredLaunch: false + structuredLaunch: false, + plan: null } } @@ -83,16 +89,18 @@ export async function prepareDirectWorkItemAgentLaunch(args: { : undefined }) - const structuredLaunch = - effectiveAgent !== null && - args.routeResolver(args.latestStore, { - agent: effectiveAgent, - workspace: { kind: 'git-worktree', worktreeId: args.worktreeId }, - prompt: args.draftContent, - promptDelivery: args.promptDelivery, - tuiCustomization: { agentArgs: args.agentArgs }, - initialSessionOptions: startupPlan?.sessionOptions - }) === 'structured-native-chat' + const plan = + effectiveAgent === null + ? null + : args.planLaunch(args.latestStore, { + agent: effectiveAgent, + workspace: { kind: 'git-worktree', worktreeId: args.worktreeId }, + prompt: args.draftContent, + promptDelivery: args.promptDelivery, + tuiCustomization: { agentArgs: args.agentArgs }, + initialSessionOptions: startupPlan?.sessionOptions + }) + const structuredLaunch = plan?.route === 'structured-native-chat' await markDirectWorkItemAgentTrusted({ structuredLaunch, @@ -108,6 +116,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { startupPlan, draftLaunchedNatively, startupPlanFailed, - structuredLaunch + structuredLaunch, + plan } } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 5c9f7e5d6d2..e85426f52a9 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -36,7 +36,10 @@ import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { settleDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing' import { prepareDirectWorkItemAgentLaunch } from '@/lib/launch-work-item-direct-route-preparation' -import { resolveAgentLaunchRouteForWorkspace } from '@/lib/agent-launch-route-input' +import { + planAgentSessionLaunch, + type AgentSessionLaunchPlan +} from '@/lib/agent-session-launch-plan' /** * "Use" flow: create the workspace, activate it, launch the default agent, @@ -161,7 +164,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom let startupPlan = null as ReturnType['startupPlan'] let effectiveAgent: TuiAgent | null = null let draftLaunchedNatively = false - let structuredLaunch = false + let plan: AgentSessionLaunchPlan | null = null const draftContent = await getDirectWorkItemDraftContent(item, repoConnectionId) let startupPlanFailed = false try { @@ -209,7 +212,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom promptDelivery, launchPlatform: args.launchPlatform, repoProjectRuntime, - routeResolver: resolveAgentLaunchRouteForWorkspace + planLaunch: planAgentSessionLaunch }) if (launchPreparation.unavailable) { activateAndRevealWorktree(worktreeId, { @@ -223,13 +226,13 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom startupPlan = launchPreparation.startupPlan draftLaunchedNatively = launchPreparation.draftLaunchedNatively startupPlanFailed = launchPreparation.startupPlanFailed - structuredLaunch = launchPreparation.structuredLaunch + plan = launchPreparation.plan const activation = activateAndRevealWorktree(worktreeId, { sidebarRevealBehavior: 'auto', setup: result.setup, defaultTabs: result.defaultTabs, - ...(structuredLaunch + ...(launchPreparation.structuredLaunch ? { providesInitialSurface: true } : buildDirectWorkItemStartupOpts( effectiveAgent, @@ -254,13 +257,10 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom store.setSidebarOpen(true) const structuredResult = await settleDirectWorkItemStructuredLaunch({ - structuredLaunch, - agent: effectiveAgent, + plan, worktreeId, workspacePath: worktreePath, connectionId: repoConnectionId, - draftContent, - promptDelivery, primaryTabId, startupPlan, launchSource diff --git a/src/renderer/src/lib/onboarding-folder-agent-launch.ts b/src/renderer/src/lib/onboarding-folder-agent-launch.ts index 81139899db4..26d90f5147c 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-launch.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-launch.ts @@ -1,19 +1,63 @@ -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import type { ExecutionHostId } from '../../../shared/execution-host' -import type { - OnboardingFolderAgentStartup, - resolveDismissedOnboardingFolderAgentLaunch +import type { OnboardingState } from '../../../shared/onboarding-state-types' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { AgentLaunchRouteStore } from '@/lib/agent-launch-route-input' +import { + planAgentSessionLaunch, + type AgentSessionLaunchPlan +} from '@/lib/agent-session-launch-plan' +import { + buildDismissedOnboardingFolderAgentStartup, + type OnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' -import { settleStructuredAgentLaunch } from '@/lib/structured-agent-launch-settlement' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +export type OnboardingFolderAgentLaunch = { + agent: TuiAgent | null + /** Planned before the folder workspace row exists; null when no default agent applies. */ + plan: AgentSessionLaunchPlan | null + startup?: OnboardingFolderAgentStartup + fallbackStartup?: OnboardingFolderAgentStartup +} + +/** Why: lives beside the launch, not the startup builder, because the store root imports that + * builder eagerly and the planner's launch graph reaches back to the store root. */ +export function resolveDismissedOnboardingFolderAgentLaunch(args: { + store: AgentLaunchRouteStore + onboarding: OnboardingState | null + hasExistingProject: boolean + executionHostId: string + nativeChatTranscriptIsLocalReadable?: boolean +}): OnboardingFolderAgentLaunch { + const startup = buildDismissedOnboardingFolderAgentStartup( + args.store.settings ?? null, + args.onboarding, + args.hasExistingProject, + args.nativeChatTranscriptIsLocalReadable + ) + const agent = startup?.launchAgent ?? null + if (!startup || !agent) { + return { agent: null, plan: null } + } + const plan = planAgentSessionLaunch(args.store, { + agent, + workspace: { kind: 'folder', executionHostId: args.executionHostId }, + initialSessionOptions: startup.sessionOptions + }) + return { + agent, + plan, + ...(plan.route === 'structured-native-chat' ? { fallbackStartup: startup } : { startup }) + } +} + /** Reveal a folder just added after dismissed onboarding and start its default agent on the - * resolved route. Both add-folder paths (local store action, SSH dialog) share this; the store + * planned route. Both add-folder paths (local store action, SSH dialog) share this; the store * path must import it lazily because the launch graph reaches the store root. */ export async function revealOnboardingFolderWithAgentLaunch(args: { worktreeId: string executionHostId: ExecutionHostId | undefined - launch: ReturnType + launch: OnboardingFolderAgentLaunch }): Promise { const reveal = ( startup: OnboardingFolderAgentStartup | undefined, @@ -25,21 +69,20 @@ export async function revealOnboardingFolderWithAgentLaunch(args: { ...(startup ? { startup } : {}), ...(providesInitialSurface ? { providesInitialSurface: true } : {}) }) - const structured = args.launch.route === 'structured-native-chat' + const { plan } = args.launch + const structured = plan?.route === 'structured-native-chat' reveal(args.launch.startup, structured) - if (!structured || !isAgentSessionHandleProvider(args.launch.agent)) { + if (!structured) { return } // Why: the outcome is not consumed; the workspace is already revealed and the launch layer toasts. - await settleStructuredAgentLaunch( - args.worktreeId, - args.launch.agent, - {}, + await plan.launch( { legacyFallback: async () => { const activation = reveal(args.launch.fallbackStartup) return { activation, primaryTabId: activation === false ? null : activation.primaryTabId } } - } + }, + { worktreeId: args.worktreeId } ) } diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index f2ba051f60e..5dbc893ecbd 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -13,11 +13,6 @@ import type { OnboardingState } from '../../../shared/onboarding-state-types' import type { TuiAgent } from '../../../shared/tui-agent' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import type { SessionOptionValue } from '../../../shared/native-chat-session-options' -import type { AgentLaunchRoute } from '@/lib/agent-launch-routing' -import { - resolveAgentLaunchRouteForWorkspace, - type AgentLaunchRouteStore -} from '@/lib/agent-launch-route-input' export type OnboardingFolderAgentStartup = { command: string @@ -107,37 +102,3 @@ export function buildDismissedOnboardingFolderAgentStartup( } return buildOnboardingFolderAgentStartup(settings, nativeChatTranscriptIsLocalReadable) } - -export function resolveDismissedOnboardingFolderAgentLaunch(args: { - store: AgentLaunchRouteStore - onboarding: OnboardingState | null - hasExistingProject: boolean - executionHostId: string - nativeChatTranscriptIsLocalReadable?: boolean -}): { - agent: TuiAgent | null - route: AgentLaunchRoute - startup?: OnboardingFolderAgentStartup - fallbackStartup?: OnboardingFolderAgentStartup -} { - const startup = buildDismissedOnboardingFolderAgentStartup( - args.store.settings ?? null, - args.onboarding, - args.hasExistingProject, - args.nativeChatTranscriptIsLocalReadable - ) - const agent = startup?.launchAgent ?? null - if (!startup || !agent) { - return { agent: null, route: 'terminal-tui' } - } - const route = resolveAgentLaunchRouteForWorkspace(args.store, { - agent, - workspace: { kind: 'folder', executionHostId: args.executionHostId }, - initialSessionOptions: startup.sessionOptions - }) - return { - agent, - route, - ...(route === 'structured-native-chat' ? { fallbackStartup: startup } : { startup }) - } -} diff --git a/src/renderer/src/lib/structured-agent-launch-settlement-caller-census.test.ts b/src/renderer/src/lib/structured-agent-launch-settlement-caller-census.test.ts index 4277ad627e3..8ba1efbe920 100644 --- a/src/renderer/src/lib/structured-agent-launch-settlement-caller-census.test.ts +++ b/src/renderer/src/lib/structured-agent-launch-settlement-caller-census.test.ts @@ -7,18 +7,10 @@ const REPO_ROOT = join(import.meta.dirname, '../../../..') const CENSUS_FILE = 'src/renderer/src/lib/structured-agent-launch-settlement-caller-census.test.ts' const LOOP_FILE = 'src/renderer/src/lib/structured-agent-launch-settlement.ts' -// Why: every structured entrypoint consumes the one settle loop and decides its route before -// calling it. A new caller is a new entrypoint and must be reviewed for route, cancellation, -// fallback, and draft-seed handling before it lands here. -const SETTLE_LOOP_CALLERS = [ - 'src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts', - 'src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts', - 'src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts', - 'src/renderer/src/lib/launch-agent-in-new-tab-structured.ts', - 'src/renderer/src/lib/launch-work-item-direct-agent-routing.ts', - 'src/renderer/src/lib/onboarding-folder-agent-launch.ts', - 'src/renderer/src/lib/worktree-creation-structured-session.ts' -] +// Why: every structured entrypoint reaches the settle loop through the planner, which decided +// its route and delivery mode first. A second caller is a bypass of that decision, not a new +// entrypoint; entrypoints add a plan, never a loop call. +const SETTLE_LOOP_CALLERS = ['src/renderer/src/lib/agent-session-launch-plan.ts'] describe('structured launch settle loop caller census', () => { it('pins every production settleStructuredAgentLaunch caller', async () => { diff --git a/src/renderer/src/lib/worktree-creation-structured-session.test.ts b/src/renderer/src/lib/worktree-creation-structured-session.test.ts index fc2c732bf9a..36ef6a49593 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.test.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.test.ts @@ -74,6 +74,7 @@ const request = { name: 'routing-recovery', setupDecision: 'run' as const, agent: 'codex' as const, + agentLaunchRoute: 'structured-native-chat' as const, pendingFirstAgentMessageRename: true, note: '', startupPlan: null, @@ -417,6 +418,7 @@ describe('launchStructuredWorktreeSession', () => { name: 'routing-recovery', setupDecision: 'run', agent: 'codex', + agentLaunchRoute: 'structured-native-chat', pendingFirstAgentMessageRename: false, note: '', startupPlan: null, @@ -470,6 +472,7 @@ describe('launchStructuredWorktreeSession', () => { name: 'routing-recovery', setupDecision: 'run', agent: 'codex', + agentLaunchRoute: 'structured-native-chat', pendingFirstAgentMessageRename: false, note: '', startupPlan: null, diff --git a/src/renderer/src/lib/worktree-creation-structured-session.ts b/src/renderer/src/lib/worktree-creation-structured-session.ts index 507bf265f7d..25d6ebc75dc 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.ts @@ -1,11 +1,8 @@ import { useAppStore } from '@/store' import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding' import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation' -import { - settleStructuredAgentLaunch, - type StructuredAgentLegacyFallbackResult -} from '@/lib/structured-agent-launch-settlement' -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { StructuredAgentLegacyFallbackResult } from '@/lib/structured-agent-launch-settlement' +import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation' import { preflightAgentTrust } from '@/lib/agent-trust-preflight' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' @@ -104,8 +101,8 @@ export async function launchStructuredWorktreeSession( ): Promise { const { activation, primaryTabId } = args const settled = { accepted: true, cancelled: false, visibilityUnknown: false } - const agent = args.request.agent - if (!isAgentSessionHandleProvider(agent)) { + const { agent, agentLaunchRoute } = args.request + if (!agent) { return { ...settled, activation, primaryTabId } } const isCancelled = (): boolean => @@ -114,15 +111,19 @@ export async function launchStructuredWorktreeSession( return { ...settled, cancelled: true, activation, primaryTabId } } let refused = false - const settlement = await settleStructuredAgentLaunch( - args.worktreeId, + // Why: the composer decided route and delivery mode before the worktree existed; re-entering + // with that persisted verdict is what keeps recovery from re-resolving on a changed host. + const plan = adoptAgentSessionLaunchVerdict({ + route: agentLaunchRoute ?? 'terminal-tui', agent, - args.recoverUnknownLaunch + ...(args.recoverUnknownLaunch ? {} : { prompt: args.request.launchDraftPrompt ?? args.request.quickPrompt, ...(args.request.promptDelivery ? { promptDelivery: args.request.promptDelivery } : {}) - }, + }) + }) + const settlement = await plan.launch( { cancellation: { isCancelled, @@ -142,8 +143,12 @@ export async function launchStructuredWorktreeSession( activateStructuredAgentSessionById({ worktreeId: args.worktreeId, sessionId }) } } - } + }, + { worktreeId: args.worktreeId } ) + if (!settlement) { + return { ...settled, activation, primaryTabId } + } switch (settlement.kind) { case 'cancelled': // Why: a refusal means no session exists on the host, so there is nothing to retire. diff --git a/src/renderer/src/store/repos/repo-add-actions.ts b/src/renderer/src/store/repos/repo-add-actions.ts index f8cad8f661e..a4afef1890c 100644 --- a/src/renderer/src/store/repos/repo-add-actions.ts +++ b/src/renderer/src/store/repos/repo-add-actions.ts @@ -5,7 +5,6 @@ import type { Repo } from '../../../../shared/repo-types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getRepoHostIdentity } from '../slices/repo-host-identity' import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' -import { resolveDismissedOnboardingFolderAgentLaunch } from '@/lib/onboarding-folder-agent-startup' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { translate } from '@/i18n/i18n' @@ -185,6 +184,11 @@ export function createRepoAddActions( ) if (folderWorktree) { const onboarding = await window.api.onboarding.get().catch(() => null) + // Why: lazy-import to avoid a circular module load (the launch graph imports the store root). + const { + resolveDismissedOnboardingFolderAgentLaunch, + revealOnboardingFolderWithAgentLaunch + } = await import('@/lib/onboarding-folder-agent-launch') // Why: adding the first folder from Landing skips onboarding's completeRepo hook; carry the default agent into the first terminal here. const launch = resolveDismissedOnboardingFolderAgentLaunch({ store: get(), @@ -195,9 +199,6 @@ export function createRepoAddActions( repo.connectionId ) }) - // Why: lazy-import to avoid a circular module load (the launch graph imports the store root). - const { revealOnboardingFolderWithAgentLaunch } = - await import('@/lib/onboarding-folder-agent-launch') await revealOnboardingFolderWithAgentLaunch({ worktreeId: folderWorktree.id, executionHostId,