mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
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:
+12
-11
@@ -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) {
|
||||
|
||||
+6
-7
@@ -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) }
|
||||
: {})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user