refactor(native-chat): decide every agent launch route in one planner

The route was still resolved at seven callers, each also calling the settle
loop; two census tests only stopped an eighth. planAgentSessionLaunch is now
the one production caller of the resolver and its launch() the one caller of
the settle loop, and both censuses pin exactly that file.

The funnel is two-phase because three sites need the route before the
workspace exists and quick create persists its request for recovery: a plan
exposes route before creation and launches with the created worktree id;
a persisted quick-create request carries the verdict as data and re-enters
through adoptAgentSessionLaunchVerdict without re-resolving. Delivery mode
is fixed on the request once, so route and launch cannot disagree.
This commit is contained in:
Merge Sim
2026-09-09 00:54:07 -07:00
parent 91fa5dbfb6
commit 1f8950fbf7
26 changed files with 663 additions and 360 deletions
@@ -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) {
@@ -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
)
@@ -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'
@@ -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') {
@@ -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
@@ -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<AgentSessionLaunchVerdict> = {}) =>
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',
@@ -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<void>
}): Promise<StructuredAgentLaunchSettlement | null> {
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 }
)
}
@@ -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'
@@ -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([
@@ -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))
}
@@ -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<string[]> {
@@ -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', () => {
@@ -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()
})
})
@@ -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<AgentSessionLaunchVerdict> & {
/** Runs the structured settle loop for this plan. Null when the route is not structured. */
launch(
hooks: StructuredAgentLaunchHooks,
target?: AgentSessionLaunchTarget
): Promise<StructuredAgentLaunchSettlement | null>
}
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 } : {})
})
}
@@ -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()
})
@@ -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) }
: {})
}
+10 -15
View File
@@ -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 {
@@ -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,
@@ -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 {
@@ -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<typeof buildDirectWorkItemStartup>[0]['repoProjectRuntime']
routeResolver: typeof resolveAgentLaunchRouteForWorkspace
planLaunch: typeof planAgentSessionLaunch
}): Promise<DirectWorkItemAgentLaunchPreparation> {
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
}
}
@@ -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<typeof buildDirectWorkItemAgentStartupPlan>['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
@@ -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<typeof resolveDismissedOnboardingFolderAgentLaunch>
launch: OnboardingFolderAgentLaunch
}): Promise<void> {
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 }
)
}
@@ -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 })
}
}
@@ -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 () => {
@@ -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,
@@ -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<WorktreeCreationStructuredSessionResult> {
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.
@@ -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,