mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Start worktree.create's agent through the launch executor (#26709)
* Start worktree.create's agent through the launch executor The host's worktree.create handler now starts a requested agent (startupAgent, or a linked startupDraft) through executeAgentLaunch instead of calling the create directly. Nothing on the wire changes: the request, the reply and agentTerminalHandle are the same, the agent is still the workspace's startup terminal whatever the chat default says, and the create still delivers the text itself (folded into the command at any length, a follow-up for agents that read it after start, and the draft pasted unsent). Adds three host-internal executor inputs: terminalOnly, the legacy-host prompt policy, and a shared entry (createWorktreeWithStartupAgent) that host producers can call in place of createManagedWorktree. * Keep a linked-draft create recording only the agent it actually started Problem: after worktree.create moved onto the launch executor, a create from a linked issue (phone "create from issue", or a raw worktree.create with startupDraft) could differ from before in two narrow cases. When the draft's agent came from the settings default or from detection and its launch command could not be built (for example unbalanced quotes in the saved agent arguments), the workspace was recorded as created with that agent even though it never started, and a background create opened no terminal at all instead of its plain shell. When no agent was found, detection ran twice (an extra SSH round trip on remote repos). User-facing change: none intended; both cases now behave exactly as before the executor move. Mechanism: - The entry hands the draft's chosen agent to the create as a main-internal startupDraftAgent, which steers only the draft; createdWithAgent stays what the caller sent. A draft that resolves no agent is created without the draft, so the create does not detect again. - AgentLaunchExecution is a discriminated union: every normal launch must bring its surface factory, and a legacy-host launch must be terminal-only, bring a workspace factory, and cannot bring surfaces. The runtime guards the types replace are gone; the target kind is checked before any create. - Legacy-host receipts follow the delivery module's rule: a blank prompt is no prompt, a submit folded into the launch command is handed-to-terminal, a post-start send the create does not await is unconfirmed (host-internal), a draft is not-delivered. - The no-agent error lives with the legacy-host policy and is caught only by the shared entry. - Tests: a real-runtime parity test (stored agent, terminal spawns, detection count vs the create alone, including the unbuildable plan), exact-argument checks over every non-agent startup field, compile-time checks for the union, and direct tests of the draft agent resolver. * Say which creates still bypass the shared agent entry Two comments stated that headless automations and federation already go through createWorktreeWithStartupAgent; both still call createManagedWorktree directly. Reword them as the plan. Comment-only.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* What a caller hands `executeAgentLaunch`. Two shapes, so the compiler rather than a runtime throw
|
||||
* keeps each caller to the inputs its policy needs: a launch that may build its own surface must
|
||||
* bring the factory for it, and a `legacy-host` create, whose startup terminal is its only
|
||||
* surface, cannot bring one.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentLaunchIntent,
|
||||
AgentLaunchResult,
|
||||
AgentLaunchTarget
|
||||
} from '../../shared/agent-launch-intent'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { AgentLaunchModeVocabulary } from './agent-launch-mode'
|
||||
import type {
|
||||
AgentLaunchSurfaceFactory,
|
||||
AgentLaunchWorkspaceFactory
|
||||
} from './agent-launch-surface-factories'
|
||||
|
||||
/**
|
||||
* The launch as it stands once its surface exists: a complete result whose prompt receipt says only
|
||||
* what creation itself settled — carried on the launch command, a draft the host never delivers, or
|
||||
* a submit still `unconfirmed`. Complete so a host that dies during the delivery still leaves a
|
||||
* truthful answer behind.
|
||||
*/
|
||||
export type AgentLaunchPublishedSurface = AgentLaunchResult
|
||||
|
||||
type AgentLaunchExecutionBase = {
|
||||
runtime: Pick<OrcaRuntimeService, 'getStructuredAgentSessionCreateSupport' | 'getClientSettings'>
|
||||
vocabulary?: AgentLaunchModeVocabulary
|
||||
/** False when the calling client cannot show the agent's chat; absent for the host's own callers. */
|
||||
callerRendersStructured?: boolean
|
||||
/** Attributes a throw to the step that was running, the way a dispatch's own stages do. */
|
||||
onStage?: (stage: 'worktree_create' | 'mode_settle' | 'surface_create') => void
|
||||
/** The surface exists and its tab is published; runs before any prompt delivery. Must not throw. */
|
||||
onSurfacePublished?: (surface: AgentLaunchPublishedSurface) => void
|
||||
}
|
||||
|
||||
/** Every launch but a `legacy-host` create: it builds the surface itself when the create gave none. */
|
||||
export type AgentLaunchSurfaceExecution = AgentLaunchExecutionBase & {
|
||||
intent: AgentLaunchIntent
|
||||
surfaces: AgentLaunchSurfaceFactory
|
||||
workspaces?: AgentLaunchWorkspaceFactory
|
||||
/** Host-internal, never on the wire: settles a terminal agent whatever the chat default says, for
|
||||
* a caller whose contract is a terminal handle. */
|
||||
terminalOnly?: boolean
|
||||
/** The launch's own delivery: argv only when the typed line carries it, else a paste. */
|
||||
promptPolicy?: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-internal, never on the wire. `legacy-host` is `worktree.create`'s own delivery, kept for the
|
||||
* host's legacy producers: the create folds a submit into the command at any length (the
|
||||
* shell-ready staging carries long lines), sends it once a post-start agent is up, and pastes a
|
||||
* draft unsent. Only a terminal create has the startup terminal that delivery needs, and that
|
||||
* terminal is the launch's only surface.
|
||||
*/
|
||||
export type AgentLaunchLegacyHostExecution = AgentLaunchExecutionBase & {
|
||||
intent: AgentLaunchIntent & {
|
||||
target: Extract<AgentLaunchTarget, { kind: 'create-worktree' }>
|
||||
reuseTerminal?: never
|
||||
}
|
||||
promptPolicy: 'legacy-host'
|
||||
terminalOnly: true
|
||||
workspaces: AgentLaunchWorkspaceFactory
|
||||
surfaces?: never
|
||||
}
|
||||
|
||||
export type AgentLaunchExecution = AgentLaunchSurfaceExecution | AgentLaunchLegacyHostExecution
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The one place an agent is actually started — for the surfaces moved onto it, which today is
|
||||
* `agent.launch` alone. Orchestration dispatch, mobile create, CLI create and the desktop agent
|
||||
* The one place an agent launch is sequenced — for the surfaces moved onto it: `agent.launch`, and
|
||||
* `worktree.create` (CLI and mobile create) through `createWorktreeWithStartupAgent`, whose
|
||||
* `legacy-host` create still starts the agent itself. Orchestration dispatch and the desktop agent
|
||||
* tab each still start agents their own way; moving them here is later stack work.
|
||||
*
|
||||
* The mode decision is shared, not copied: `agent-launch-mode` owns it, and
|
||||
@@ -37,7 +38,6 @@ import {
|
||||
argvLaunchPrompt,
|
||||
deliverTerminalLaunchPrompt,
|
||||
HANDED_TO_TERMINAL,
|
||||
launchCommandPrompt,
|
||||
promptReceipt,
|
||||
settledAtCreation,
|
||||
settleLaunchPromptDisposal
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
workspaceKindForWorktreeId,
|
||||
type WorkspaceLaunchKind
|
||||
} from '../../shared/workspace-launch-kind'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { isDefinitiveAgentSessionCreateRefusal } from '../../shared/agent-session-definitive-refusal'
|
||||
import {
|
||||
decideAgentLaunchMode,
|
||||
@@ -57,34 +56,22 @@ import {
|
||||
DEFAULT_LAUNCH_VOCABULARY,
|
||||
warnStructuredLaunchDowngrade
|
||||
} from './agent-launch-mode'
|
||||
import {
|
||||
assertLegacyHostTarget,
|
||||
createLaunchPromptInputs,
|
||||
legacyHostCreateResult
|
||||
} from './agent-launch-legacy-host'
|
||||
import {
|
||||
AgentLaunchStructuredSessionRefusedError,
|
||||
type AgentLaunchStructuredSurface,
|
||||
type AgentLaunchSurfaceFactory,
|
||||
type AgentLaunchWorkspaceFactory
|
||||
type AgentLaunchStructuredSurface
|
||||
} from './agent-launch-surface-factories'
|
||||
import type {
|
||||
AgentLaunchExecution,
|
||||
AgentLaunchPublishedSurface,
|
||||
AgentLaunchSurfaceExecution
|
||||
} from './agent-launch-execution'
|
||||
|
||||
export type AgentLaunchExecution = {
|
||||
runtime: Pick<OrcaRuntimeService, 'getStructuredAgentSessionCreateSupport' | 'getClientSettings'>
|
||||
intent: AgentLaunchIntent
|
||||
surfaces: AgentLaunchSurfaceFactory
|
||||
workspaces?: AgentLaunchWorkspaceFactory
|
||||
vocabulary?: AgentLaunchModeVocabulary
|
||||
/** False when the calling client cannot show the agent's chat; absent for the host's own callers. */
|
||||
callerRendersStructured?: boolean
|
||||
/** Attributes a throw to the step that was running, the way a dispatch's own stages do. */
|
||||
onStage?: (stage: 'worktree_create' | 'mode_settle' | 'surface_create') => void
|
||||
/** The surface exists and its tab is published; runs before any prompt delivery. Must not throw. */
|
||||
onSurfacePublished?: (surface: AgentLaunchPublishedSurface) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The launch as it stands once its surface exists: a complete result whose prompt receipt says only
|
||||
* what creation itself settled — carried on the launch command, a draft the host never delivers, or
|
||||
* a submit still `unconfirmed`. Complete so a host that dies during the delivery still leaves a
|
||||
* truthful answer behind.
|
||||
*/
|
||||
export type AgentLaunchPublishedSurface = AgentLaunchResult
|
||||
export type { AgentLaunchExecution, AgentLaunchPublishedSurface } from './agent-launch-execution'
|
||||
|
||||
export async function executeAgentLaunch(
|
||||
execution: AgentLaunchExecution
|
||||
@@ -107,8 +94,15 @@ export async function executeAgentLaunch(
|
||||
...(execution.callerRendersStructured === false ? { callerRendersStructured: false } : {})
|
||||
},
|
||||
settings,
|
||||
...(execution.terminalOnly ? { terminalOnly: true } : {}),
|
||||
vocabulary
|
||||
})
|
||||
// The create's startup terminal is this launch's only surface, and the create delivers the text.
|
||||
if (execution.promptPolicy === 'legacy-host') {
|
||||
assertLegacyHostTarget(execution)
|
||||
const placed = await resolveWorkspace(execution, preflight)
|
||||
return published(execution, legacyHostCreateResult(execution, placed, preflight))
|
||||
}
|
||||
|
||||
// A reused terminal already downgraded in the pre-flight; there is nothing to create. Its agent
|
||||
// was running before this launch existed, so argv is unreachable and the PTY is the only way in.
|
||||
@@ -252,14 +246,13 @@ async function resolveWorkspace(
|
||||
throw new Error('agent_launch_workspace_factory_required')
|
||||
}
|
||||
execution.onStage?.('worktree_create')
|
||||
const startupPrompt = launchCommandPrompt(intent, preflight.mode)
|
||||
const created = await workspaces.createWorktree({
|
||||
// A caller migrating from `worktree.create` passes its existing params; a stale `startupAgent`
|
||||
// in there would re-create the agent-first path this executor exists to replace. The launch
|
||||
// owns the prompt for the same reason, so it re-supplies its own rather than honouring theirs.
|
||||
create: withoutReservedAgentCreateFields(intent.target.create),
|
||||
startupAgent: preflight.mode === 'structured' ? undefined : intent.agent,
|
||||
...(startupPrompt ? { startupPrompt } : {}),
|
||||
...createLaunchPromptInputs(execution, preflight.mode),
|
||||
...(preflight.mode === 'structured' ? {} : terminalLaunchInputs(intent))
|
||||
})
|
||||
// Only when a startup terminal actually came back: a create that produced none ran no command,
|
||||
@@ -281,7 +274,7 @@ export type CreatedSurface = {
|
||||
}
|
||||
|
||||
async function createSurface(
|
||||
execution: AgentLaunchExecution,
|
||||
execution: AgentLaunchSurfaceExecution,
|
||||
workspace: { worktreeId: string; connectionId: string | null | undefined },
|
||||
settled: AgentLaunchModeReceipt
|
||||
): Promise<CreatedSurface> {
|
||||
@@ -342,7 +335,7 @@ function terminalLaunchInputs(intent: AgentLaunchIntent) {
|
||||
* surface — carrying the same argv prompt — as a launch that chose a terminal outright.
|
||||
*/
|
||||
async function createTerminalSurface(
|
||||
execution: AgentLaunchExecution,
|
||||
execution: AgentLaunchSurfaceExecution,
|
||||
workspace: { worktreeId: string; connectionId: string | null | undefined }
|
||||
): Promise<CreatedSurface> {
|
||||
const { intent, surfaces } = execution
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { executeAgentLaunch, type AgentLaunchExecution } from './agent-launch-executor'
|
||||
import { decideAgentLaunchMode } from './agent-launch-mode'
|
||||
import { AgentLaunchStartupAgentNotCreatedError } from './agent-launch-legacy-host'
|
||||
import type { AgentLaunchSurfaceFactory } from './agent-launch-surface-factories'
|
||||
|
||||
const getStructuredAgentSessionCreateSupport = vi.fn(async () => ({ supported: true }))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the two runtime methods the executor reaches; the chat default is on so a terminal-only launch is proven to ignore it.
|
||||
const runtime = {
|
||||
getClientSettings: () => ({ experimentalNativeChat: true }),
|
||||
getStructuredAgentSessionCreateSupport
|
||||
} as unknown as AgentLaunchExecution['runtime']
|
||||
|
||||
function workspaces(startupTerminalHandle: string | undefined) {
|
||||
return {
|
||||
createWorktree: vi.fn(async (_args: Record<string, unknown>) => ({
|
||||
worktreeId: 'wt-new',
|
||||
connectionId: null,
|
||||
startupTerminalHandle
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function legacyLaunch(
|
||||
agent: 'claude' | 'aider' | 'codex',
|
||||
prompt: { text: string; delivery: 'submit' | 'draft' } | undefined,
|
||||
factory = workspaces('term_agent')
|
||||
) {
|
||||
return executeAgentLaunch({
|
||||
runtime,
|
||||
intent: {
|
||||
agent,
|
||||
target: { kind: 'create-worktree', create: { repo: 'repo-1' } },
|
||||
...(prompt ? { prompt } : {})
|
||||
},
|
||||
terminalOnly: true,
|
||||
promptPolicy: 'legacy-host',
|
||||
workspaces: factory
|
||||
})
|
||||
}
|
||||
|
||||
describe('terminal-only launches', () => {
|
||||
it('settle as a terminal whatever the chat default says', () => {
|
||||
expect(
|
||||
decideAgentLaunchMode({
|
||||
placement: { agent: 'claude', workspaceKind: 'git-worktree' },
|
||||
settings: { experimentalNativeChat: true },
|
||||
terminalOnly: true
|
||||
})
|
||||
).toMatchObject({
|
||||
mode: 'terminal',
|
||||
preferred: 'terminal',
|
||||
reason: 'user_default'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('the legacy-host prompt policy', () => {
|
||||
it('hands the whole text to the create and delivers nothing itself', async () => {
|
||||
const factory = workspaces('term_agent')
|
||||
|
||||
const result = await legacyLaunch('aider', { text: 'fix the bug', delivery: 'submit' }, factory)
|
||||
|
||||
const args = factory.createWorktree.mock.calls[0][0]
|
||||
expect(args).toMatchObject({
|
||||
startupAgent: 'aider',
|
||||
legacyPrompt: { text: 'fix the bug', delivery: 'submit' }
|
||||
})
|
||||
expect(args).not.toHaveProperty('startupPrompt')
|
||||
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_agent' })
|
||||
expect(getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a submit the launch command carried as handed to the terminal', async () => {
|
||||
const result = await legacyLaunch('claude', { text: 'fix the bug', delivery: 'submit' })
|
||||
|
||||
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' })
|
||||
})
|
||||
|
||||
it('reports a submit sent after the agent starts as unconfirmed, since the create does not await it', async () => {
|
||||
const result = await legacyLaunch('aider', { text: 'fix the bug', delivery: 'submit' })
|
||||
|
||||
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'unconfirmed' })
|
||||
})
|
||||
|
||||
it('reports a draft as never delivered by the host', async () => {
|
||||
const result = await legacyLaunch('codex', { text: 'https://x/1', delivery: 'draft' })
|
||||
|
||||
expect(result.prompt).toEqual({ delivery: 'draft', outcome: 'not-delivered' })
|
||||
})
|
||||
|
||||
it('reports no prompt when the launch carried none', async () => {
|
||||
const result = await legacyLaunch('claude', undefined)
|
||||
|
||||
expect(result).not.toHaveProperty('prompt')
|
||||
})
|
||||
|
||||
it('builds no second surface when the create started no agent', async () => {
|
||||
await expect(legacyLaunch('claude', undefined, workspaces(undefined))).rejects.toBeInstanceOf(
|
||||
AgentLaunchStartupAgentNotCreatedError
|
||||
)
|
||||
})
|
||||
|
||||
it('applies only to a terminal-only create, refused before anything is created', async () => {
|
||||
const factory = workspaces('term_agent')
|
||||
await expect(
|
||||
// @ts-expect-error a legacy-host launch must create its workspace
|
||||
executeAgentLaunch({
|
||||
runtime,
|
||||
intent: { agent: 'claude', target: { kind: 'existing', worktree: 'w' } },
|
||||
terminalOnly: true,
|
||||
promptPolicy: 'legacy-host',
|
||||
workspaces: factory
|
||||
})
|
||||
).rejects.toThrow('agent_launch_legacy_prompt_policy_requires_terminal_create')
|
||||
expect(factory.createWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cannot be asked for without terminal-only, or alongside a surface factory', () => {
|
||||
const intent = {
|
||||
agent: 'claude' as const,
|
||||
target: { kind: 'create-worktree' as const, create: {} }
|
||||
}
|
||||
const factory = workspaces('t')
|
||||
const surfaces: AgentLaunchSurfaceFactory = {
|
||||
createStructuredSession: vi.fn(),
|
||||
createTerminalAgent: vi.fn()
|
||||
}
|
||||
const accepts = (execution: AgentLaunchExecution) => execution
|
||||
// @ts-expect-error legacy-host requires terminalOnly
|
||||
accepts({ runtime, intent, promptPolicy: 'legacy-host', workspaces: factory })
|
||||
// @ts-expect-error the create's startup terminal is a legacy-host launch's only surface
|
||||
accepts({
|
||||
runtime,
|
||||
intent,
|
||||
terminalOnly: true,
|
||||
promptPolicy: 'legacy-host',
|
||||
workspaces: factory,
|
||||
surfaces
|
||||
})
|
||||
// @ts-expect-error every other launch builds its own surface, so it must bring the factory
|
||||
accepts({ runtime, intent, workspaces: factory })
|
||||
expect(accepts({ runtime, intent, surfaces, workspaces: factory }).surfaces).toBe(surfaces)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* The executor's half of the `legacy-host` prompt policy: `worktree.create`'s own startup contract,
|
||||
* kept for the host's legacy producers until one planner replaces both policies.
|
||||
*
|
||||
* The create owns the text, so the executor hands it the whole prompt instead of an argv offer and
|
||||
* delivers nothing itself; and the create's startup terminal is the launch's only surface.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentLaunchPrompt,
|
||||
AgentLaunchPromptDisposal,
|
||||
AgentLaunchResult
|
||||
} from '../../shared/agent-launch-intent'
|
||||
import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup'
|
||||
import type { AgentLaunchExecution, AgentLaunchLegacyHostExecution } from './agent-launch-execution'
|
||||
import type { AgentLaunchModeReceipt } from './agent-launch-mode'
|
||||
import {
|
||||
HANDED_TO_TERMINAL,
|
||||
launchCommandPrompt,
|
||||
promptReceipt
|
||||
} from './agent-launch-prompt-delivery'
|
||||
|
||||
/**
|
||||
* The create's startup terminal is a `legacy-host` launch's only surface: a create that could not
|
||||
* spawn it here handed it to the window's activation or reported why in its warning, so building
|
||||
* another would start the agent twice. The workspace exists; no agent came back.
|
||||
*
|
||||
* Caught only by `createWorktreeWithStartupAgent`, which answers with the create's own result as
|
||||
* `worktree.create` always has. That shared entry is the only `legacy-host` producer; headless
|
||||
* automations and federation, which still call `createManagedWorktree` directly, are to move onto
|
||||
* it rather than repeat this catch. Orchestration workers are not `legacy-host` and never see it.
|
||||
*/
|
||||
export class AgentLaunchStartupAgentNotCreatedError extends Error {
|
||||
readonly worktreeId: string
|
||||
|
||||
constructor(worktreeId: string) {
|
||||
super('agent_launch_startup_agent_not_created')
|
||||
this.name = 'AgentLaunchStartupAgentNotCreatedError'
|
||||
this.worktreeId = worktreeId
|
||||
}
|
||||
}
|
||||
|
||||
/** What a create is handed to deliver: the launch's argv offer, or under this policy all of it. */
|
||||
export function createLaunchPromptInputs(
|
||||
execution: AgentLaunchExecution,
|
||||
mode: AgentLaunchModeReceipt['mode']
|
||||
): { startupPrompt?: string; legacyPrompt?: AgentLaunchPrompt } {
|
||||
if (execution.promptPolicy !== 'legacy-host') {
|
||||
const startupPrompt = launchCommandPrompt(execution.intent, mode)
|
||||
return startupPrompt ? { startupPrompt } : {}
|
||||
}
|
||||
return execution.intent.prompt ? { legacyPrompt: execution.intent.prompt } : {}
|
||||
}
|
||||
|
||||
/** Refuses a cast past the type before the workspace exists, so it cannot leave one behind. */
|
||||
export function assertLegacyHostTarget(execution: AgentLaunchLegacyHostExecution): void {
|
||||
if (execution.intent.target.kind !== 'create-worktree') {
|
||||
throw new Error('agent_launch_legacy_prompt_policy_requires_terminal_create')
|
||||
}
|
||||
}
|
||||
|
||||
/** The launch once the create returned; see `AgentLaunchStartupAgentNotCreatedError` for none. */
|
||||
export function legacyHostCreateResult(
|
||||
execution: AgentLaunchLegacyHostExecution,
|
||||
placed: {
|
||||
worktreeId: string
|
||||
startupTerminalHandle: string | undefined
|
||||
startupTerminalPaneKey?: string
|
||||
warning?: string
|
||||
},
|
||||
receipt: AgentLaunchModeReceipt
|
||||
): AgentLaunchResult {
|
||||
if (!placed.startupTerminalHandle) {
|
||||
throw new AgentLaunchStartupAgentNotCreatedError(placed.worktreeId)
|
||||
}
|
||||
const { intent } = execution
|
||||
return {
|
||||
outcome: {
|
||||
kind: 'terminal',
|
||||
handle: placed.startupTerminalHandle,
|
||||
...(placed.startupTerminalPaneKey ? { paneKey: placed.startupTerminalPaneKey } : {})
|
||||
},
|
||||
worktreeId: placed.worktreeId,
|
||||
receipt,
|
||||
...(placed.warning ? { warning: placed.warning } : {}),
|
||||
...promptReceipt(intent, legacyCreatePromptDisposal(intent))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* By the delivery module's rule, each arm names an act that already happened. The create folds a
|
||||
* submit into the command that starts any agent taking it on argv (`buildAgentStartupPlan` leaves
|
||||
* no follow-up for those), so that text is handed over. A post-start agent gets it from a send the
|
||||
* create fires and does not await, so it is `unconfirmed` here: host-internal, never on the wire,
|
||||
* meaning "may have arrived, do not resend". A draft is unsent text the host never vouches for.
|
||||
*/
|
||||
function legacyCreatePromptDisposal(
|
||||
intent: AgentLaunchLegacyHostExecution['intent']
|
||||
): AgentLaunchPromptDisposal {
|
||||
if (intent.prompt?.delivery !== 'submit') {
|
||||
return { outcome: 'not-delivered' }
|
||||
}
|
||||
return agentPromptRidesLaunchCommand(intent.agent)
|
||||
? HANDED_TO_TERMINAL
|
||||
: { outcome: 'unconfirmed' }
|
||||
}
|
||||
@@ -130,6 +130,8 @@ const HOST_SUPPORT_REASON: Record<
|
||||
export function decideAgentLaunchMode(args: {
|
||||
placement: AgentLaunchModePlacement
|
||||
settings: AgentLaunchModeSettings | null | undefined
|
||||
/** Host-internal: the caller's contract is a terminal handle, so the chat default cannot apply. */
|
||||
terminalOnly?: boolean
|
||||
vocabulary?: AgentLaunchModeVocabulary
|
||||
/** Registered agents (beyond Claude and Codex) this surface can open as structured; defaults to
|
||||
* every agent this host registers. */
|
||||
@@ -137,7 +139,7 @@ export function decideAgentLaunchMode(args: {
|
||||
}): AgentLaunchModeReceipt {
|
||||
const { placement, settings } = args
|
||||
const vocabulary = args.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY
|
||||
if (!isNativeChatEnabled(settings)) {
|
||||
if (args.terminalOnly || !isNativeChatEnabled(settings)) {
|
||||
return {
|
||||
mode: 'terminal',
|
||||
preferred: 'terminal',
|
||||
|
||||
@@ -24,7 +24,8 @@ import type {
|
||||
} from '../../shared/agent-launch-intent'
|
||||
import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup'
|
||||
import type { AgentLaunchModeReceipt } from './agent-launch-mode'
|
||||
import type { AgentLaunchExecution, CreatedSurface } from './agent-launch-executor'
|
||||
import type { CreatedSurface } from './agent-launch-executor'
|
||||
import type { AgentLaunchSurfaceExecution } from './agent-launch-execution'
|
||||
import type { AgentLaunchStructuredSurface } from './agent-launch-surface-factories'
|
||||
|
||||
export const HANDED_TO_TERMINAL: AgentLaunchPromptDisposal = { outcome: 'handed-to-terminal' }
|
||||
@@ -49,7 +50,7 @@ export function settledAtCreation(
|
||||
|
||||
/** Each surface delivers its own way, so the disposal is decided where the surface is known. */
|
||||
export async function settleLaunchPromptDisposal(
|
||||
execution: AgentLaunchExecution,
|
||||
execution: AgentLaunchSurfaceExecution,
|
||||
created: CreatedSurface
|
||||
): Promise<AgentLaunchPromptDisposal> {
|
||||
if (created.structured) {
|
||||
@@ -69,7 +70,7 @@ export async function settleLaunchPromptDisposal(
|
||||
* `draft` is excluded: a structured draft belongs in the composer, and the host has none.
|
||||
*/
|
||||
async function deliverStructuredLaunchPrompt(
|
||||
execution: AgentLaunchExecution,
|
||||
execution: AgentLaunchSurfaceExecution,
|
||||
structured: AgentLaunchStructuredSurface
|
||||
): Promise<string | null> {
|
||||
const { intent, surfaces } = execution
|
||||
@@ -98,7 +99,7 @@ async function deliverStructuredLaunchPrompt(
|
||||
* composer accepted it, so a receipt claiming delivery would be a guess.
|
||||
*/
|
||||
export async function deliverTerminalLaunchPrompt(
|
||||
execution: AgentLaunchExecution,
|
||||
execution: AgentLaunchSurfaceExecution,
|
||||
handle: string,
|
||||
{ freshLaunch }: { freshLaunch: boolean }
|
||||
): Promise<AgentLaunchPromptDisposal> {
|
||||
@@ -146,8 +147,10 @@ export function launchCommandPrompt(
|
||||
* reachable only from a committed message id, `handed-to-terminal` only from a launch command that
|
||||
* carried the text or a PTY write that returned, and everything else under-claims as
|
||||
* `not-delivered`. A live answer has no "maybe": `unconfirmed` is written only into the record
|
||||
* before delivery runs (`settledAtCreation`), and is read back only by a replay. Dispatch doubt is
|
||||
* not this tier's to report: the submission row carries it.
|
||||
* before delivery runs (`settledAtCreation`), and is read back only by a replay. The one live
|
||||
* exception never reaches the wire: a `legacy-host` create's unawaited post-start send
|
||||
* (agent-launch-legacy-host.ts). Dispatch doubt is not this tier's to report: the submission row
|
||||
* carries it.
|
||||
*/
|
||||
export function promptReceipt(
|
||||
intent: AgentLaunchIntent,
|
||||
|
||||
@@ -111,6 +111,9 @@ export type AgentLaunchWorkspaceFactory = {
|
||||
/** Offered only alongside a `startupAgent` whose CLI takes the prompt on argv: agent-first
|
||||
* creation builds the startup command, so that is where the typed line is measured. */
|
||||
startupPrompt?: string
|
||||
/** Set instead of `startupPrompt` under the `legacy-host` prompt policy: this create owns the
|
||||
* text for any agent and length, as `worktree.create` always has. */
|
||||
legacyPrompt?: AgentLaunchPrompt
|
||||
/** Inputs needed when this terminal is created as the worktree's startup surface. */
|
||||
agentArgs?: string | null
|
||||
cwd?: string
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import type { RuntimeManagedWorktreeCreateArgs } from '../runtime/runtime-managed-worktree-create-types'
|
||||
import { createWorktreeWithStartupAgent } from './startup-agent-worktree-create'
|
||||
|
||||
const CHAT_DEFAULT_ON = { experimentalNativeChat: true }
|
||||
|
||||
function harness(
|
||||
options: {
|
||||
created?: Partial<CreateWorktreeResult>
|
||||
draftAgent?: string | null
|
||||
createError?: Error
|
||||
} = {}
|
||||
) {
|
||||
const created = {
|
||||
worktree: { id: 'repo-1::/wt/new' },
|
||||
startupTerminal: {
|
||||
spawned: true,
|
||||
handle: 'term_agent',
|
||||
paneKey: 'tab-1:leaf-1'
|
||||
},
|
||||
...options.created
|
||||
}
|
||||
const runtime = {
|
||||
getClientSettings: () => CHAT_DEFAULT_ON,
|
||||
getStructuredAgentSessionCreateSupport: vi.fn(async () => ({
|
||||
supported: true
|
||||
})),
|
||||
showRepo: vi.fn(async () => ({ id: 'repo-1', connectionId: null, executionHostId: null })),
|
||||
resolveStartupDraftAgent: vi.fn(async () =>
|
||||
options.draftAgent === undefined ? 'claude' : options.draftAgent
|
||||
),
|
||||
createManagedWorktree: vi.fn(async (_args: RuntimeManagedWorktreeCreateArgs) => {
|
||||
if (options.createError) {
|
||||
throw options.createError
|
||||
}
|
||||
return created
|
||||
})
|
||||
}
|
||||
return {
|
||||
runtime,
|
||||
created,
|
||||
create: (args: RuntimeManagedWorktreeCreateArgs) =>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every runtime method the entry and the executor reach.
|
||||
createWorktreeWithStartupAgent(runtime as never, args)
|
||||
}
|
||||
}
|
||||
|
||||
const BASE: RuntimeManagedWorktreeCreateArgs = {
|
||||
repoSelector: 'repo-1',
|
||||
name: 'feature',
|
||||
activate: false,
|
||||
setupDecision: 'skip',
|
||||
lineage: { noParent: true }
|
||||
}
|
||||
|
||||
/** Every non-agent field a startup create can carry, so an entry that drops one fails exactly. */
|
||||
const FULL: RuntimeManagedWorktreeCreateArgs = {
|
||||
...BASE,
|
||||
navigation: 'caller',
|
||||
createdWithAgent: 'claude',
|
||||
startupLaunchPreferences: { model: 'opus', effort: 'high' },
|
||||
startupAgentArgs: '--verbose',
|
||||
startupExtraAgentArgs: '--extra',
|
||||
startupCwd: 'packages/app',
|
||||
startupPaneKey: 'tab-1:leaf-1',
|
||||
pendingFirstAgentMessageRename: true,
|
||||
automationProvenance: {
|
||||
kind: 'created-by-automation',
|
||||
automationId: 'auto-1',
|
||||
automationNameSnapshot: 'Nightly',
|
||||
automationRunId: 'run-1',
|
||||
automationRunTitleSnapshot: 'Nightly run',
|
||||
createdAt: 1,
|
||||
executionTargetType: 'local',
|
||||
executionTargetId: 'local',
|
||||
projectId: 'project-1'
|
||||
},
|
||||
cliProvenance: { kind: 'created-by-cli', createdAt: 1, startupAgent: 'claude' },
|
||||
lineage: { parentWorktree: 'id:parent', callerTerminalHandle: 'term_parent' },
|
||||
comment: 'from the CLI',
|
||||
linkedIssue: 7
|
||||
}
|
||||
|
||||
describe('createWorktreeWithStartupAgent', () => {
|
||||
it('starts a terminal agent with the prompt folded by the create, whatever the chat default', async () => {
|
||||
const { runtime, created, create } = harness()
|
||||
const prompt = 'x'.repeat(3000)
|
||||
const args = {
|
||||
...FULL,
|
||||
startupAgent: 'claude' as const,
|
||||
startupPrompt: prompt,
|
||||
startupLaunchSource: 'cli'
|
||||
}
|
||||
|
||||
const result = await create(args)
|
||||
|
||||
expect(result).toBe(created)
|
||||
// Exactly the request: no field dropped, and nothing `worktree.create` never sent — no typed-line
|
||||
// measuring (`onStartupPromptCarry`), no waiting on setup before the reply.
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(args)
|
||||
expect(runtime.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hands a post-start agent its prompt through the create, which sends it once the agent is up', async () => {
|
||||
const { runtime, create } = harness()
|
||||
const args = { ...FULL, startupAgent: 'aider' as const, startupPrompt: 'fix the bug' }
|
||||
|
||||
await create(args)
|
||||
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(args)
|
||||
})
|
||||
|
||||
it('treats a blank prompt as none, which the create launches bare either way', async () => {
|
||||
for (const startupPrompt of ['', ' \n']) {
|
||||
const { runtime, create } = harness()
|
||||
|
||||
await create({ ...FULL, startupAgent: 'codex', startupPrompt })
|
||||
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith({ ...FULL, startupAgent: 'codex' })
|
||||
}
|
||||
})
|
||||
|
||||
it('starts a linked draft through `startupDraft`, unsent, with the agent the draft resolves', async () => {
|
||||
const { runtime, create } = harness({ draftAgent: 'codex' })
|
||||
const { createdWithAgent: _requested, ...request } = FULL
|
||||
const draft = 'https://github.com/o/r/issues/1'
|
||||
|
||||
await create({ ...request, startupDraft: draft })
|
||||
|
||||
expect(runtime.resolveStartupDraftAgent).toHaveBeenCalledWith(
|
||||
{ id: 'repo-1', connectionId: null, executionHostId: null },
|
||||
undefined
|
||||
)
|
||||
// The resolved agent steers only the draft: it is not recorded as the agent the caller asked
|
||||
// for, and a `startupAgent` would override the draft and start the agent with no URL in it.
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith({
|
||||
...request,
|
||||
startupDraft: draft,
|
||||
startupDraftAgent: 'codex'
|
||||
})
|
||||
})
|
||||
|
||||
it('asks for the requested draft agent and keeps it as the request named it', async () => {
|
||||
const { runtime, create } = harness()
|
||||
|
||||
await create({ ...FULL, startupDraft: 'https://x/1' })
|
||||
|
||||
expect(runtime.resolveStartupDraftAgent).toHaveBeenCalledWith(expect.anything(), 'claude')
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith({
|
||||
...FULL,
|
||||
startupDraft: 'https://x/1',
|
||||
startupDraftAgent: 'claude'
|
||||
})
|
||||
})
|
||||
|
||||
it('creates without the draft when it resolves no agent, so the create does not detect again', async () => {
|
||||
const { runtime, create } = harness({ draftAgent: null })
|
||||
|
||||
await create({ ...FULL, startupDraft: 'https://x/1' })
|
||||
|
||||
expect(runtime.resolveStartupDraftAgent).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(FULL)
|
||||
})
|
||||
|
||||
it('passes a request with no agent, a blank draft, or a prebuilt command straight to the create', async () => {
|
||||
for (const args of [
|
||||
BASE,
|
||||
{ ...BASE, startupDraft: ' ' },
|
||||
{
|
||||
...BASE,
|
||||
startupAgent: 'claude' as const,
|
||||
startup: { command: 'claude' }
|
||||
}
|
||||
]) {
|
||||
const { runtime, create } = harness()
|
||||
await create(args)
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(args)
|
||||
expect(runtime.showRepo).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('builds no second agent when the create spawned none here', async () => {
|
||||
const { runtime, created, create } = harness({
|
||||
created: {
|
||||
startupTerminal: undefined,
|
||||
warning: 'Failed to create the startup terminal for /wt/new: spawn failed'
|
||||
}
|
||||
})
|
||||
|
||||
const result = await create({
|
||||
...BASE,
|
||||
startupAgent: 'claude',
|
||||
startupPrompt: 'hi'
|
||||
})
|
||||
|
||||
expect(result).toBe(created)
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces a create failure unchanged', async () => {
|
||||
const failure = new Error(
|
||||
'Selected agent is disabled. Choose an enabled agent before creating.'
|
||||
)
|
||||
const { create } = harness({ createError: failure })
|
||||
|
||||
await expect(create({ ...BASE, startupAgent: 'claude' })).rejects.toBe(failure)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Creating a workspace with its agent the way `worktree.create` always has, through the executor.
|
||||
*
|
||||
* The agent is the workspace's startup terminal, never a chat, and the create delivers the text
|
||||
* itself; that is `worktree.create`'s contract, so the launch is `terminalOnly` with the
|
||||
* `legacy-host` prompt policy. Request and result are the create's own, so a caller swaps
|
||||
* `runtime.createManagedWorktree` for this and nothing a client sends or reads changes.
|
||||
*
|
||||
* This is the one `legacy-host` producer, so the no-agent outcome below is caught in one place.
|
||||
* The host's other agent-first creates (headless automations, federation) still call
|
||||
* `createManagedWorktree` directly; they are to call this entry, not the executor.
|
||||
*/
|
||||
|
||||
import type { AgentLaunchPrompt } from '../../shared/agent-launch-intent'
|
||||
import { getRepoSshConnectionId } from '../../shared/execution-host'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { RuntimeManagedWorktreeCreateArgs } from '../runtime/runtime-managed-worktree-create-types'
|
||||
import { executeAgentLaunch, type AgentLaunchExecution } from './agent-launch-executor'
|
||||
import { AgentLaunchStartupAgentNotCreatedError } from './agent-launch-legacy-host'
|
||||
|
||||
type StartupAgentCreateRuntime = AgentLaunchExecution['runtime'] &
|
||||
Pick<OrcaRuntimeService, 'createManagedWorktree' | 'showRepo' | 'resolveStartupDraftAgent'>
|
||||
|
||||
type StartupAgentLaunch = {
|
||||
agent: TuiAgent
|
||||
prompt: AgentLaunchPrompt | undefined
|
||||
connectionId: string | null
|
||||
}
|
||||
|
||||
export async function createWorktreeWithStartupAgent(
|
||||
runtime: StartupAgentCreateRuntime,
|
||||
args: RuntimeManagedWorktreeCreateArgs
|
||||
): Promise<CreateWorktreeResult> {
|
||||
const launch = await resolveStartupAgentLaunch(runtime, args)
|
||||
if ('passthrough' in launch) {
|
||||
return runtime.createManagedWorktree(launch.passthrough)
|
||||
}
|
||||
let created: CreateWorktreeResult | undefined
|
||||
try {
|
||||
await executeAgentLaunch({
|
||||
runtime,
|
||||
intent: {
|
||||
agent: launch.agent,
|
||||
target: { kind: 'create-worktree', create: args },
|
||||
...(launch.prompt ? { prompt: launch.prompt } : {}),
|
||||
...(args.startupLaunchSource ? { launchSource: args.startupLaunchSource } : {})
|
||||
},
|
||||
terminalOnly: true,
|
||||
promptPolicy: 'legacy-host',
|
||||
workspaces: {
|
||||
// Reads the typed request it was built from; the executor's `create` is the same minus the
|
||||
// agent fields, which come back as arguments here.
|
||||
createWorktree: async ({ legacyPrompt, launchSource }) => {
|
||||
created = await runtime.createManagedWorktree({
|
||||
...withoutStartupAgentFields(args),
|
||||
...legacyStartupFields(launch.agent, legacyPrompt),
|
||||
...(launchSource ? { startupLaunchSource: launchSource } : {})
|
||||
})
|
||||
return {
|
||||
worktreeId: created.worktree.id,
|
||||
connectionId: launch.connectionId,
|
||||
startupTerminalHandle: created.startupTerminal?.handle,
|
||||
...(created.startupTerminal?.paneKey
|
||||
? { startupTerminalPaneKey: created.startupTerminal.paneKey }
|
||||
: {}),
|
||||
...(created.warning ? { warning: created.warning } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// A workspace with no agent back is the answer `worktree.create` has always given here.
|
||||
if (!(error instanceof AgentLaunchStartupAgentNotCreatedError) || !created) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (!created) {
|
||||
throw new Error('agent_launch_workspace_not_created')
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
/** The launch a create asks for, or the create to run as is when it starts no agent. Mirrors the
|
||||
* create's own order: a prebuilt command wins, then `startupAgent`, then a linked draft's agent. */
|
||||
async function resolveStartupAgentLaunch(
|
||||
runtime: StartupAgentCreateRuntime,
|
||||
args: RuntimeManagedWorktreeCreateArgs
|
||||
): Promise<StartupAgentLaunch | { passthrough: RuntimeManagedWorktreeCreateArgs }> {
|
||||
if (args.startup || (!args.startupAgent && !args.startupDraft?.trim())) {
|
||||
return { passthrough: args }
|
||||
}
|
||||
const repo = await runtime.showRepo(args.repoSelector)
|
||||
const connectionId = getRepoSshConnectionId(repo)
|
||||
if (args.startupAgent) {
|
||||
return {
|
||||
agent: args.startupAgent,
|
||||
// A blank prompt is no prompt: the create launches the agent bare for it, sending nothing.
|
||||
prompt: args.startupPrompt?.trim()
|
||||
? { text: args.startupPrompt, delivery: 'submit' }
|
||||
: undefined,
|
||||
connectionId
|
||||
}
|
||||
}
|
||||
const agent = await runtime.resolveStartupDraftAgent(repo, args.createdWithAgent)
|
||||
if (!agent) {
|
||||
// The draft starts no agent, and the create would only run detection again to learn that.
|
||||
const { startupDraft: _draft, ...withoutDraft } = args
|
||||
return { passthrough: withoutDraft }
|
||||
}
|
||||
return { agent, prompt: { text: args.startupDraft ?? '', delivery: 'draft' }, connectionId }
|
||||
}
|
||||
|
||||
/** A draft starts its agent through `startupDraft`, which a `startupAgent` would override. The
|
||||
* chosen agent rides `startupDraftAgent` so the create neither chooses again nor records it as
|
||||
* the agent the caller asked for: `createdWithAgent` stays the request's. */
|
||||
function legacyStartupFields(
|
||||
agent: TuiAgent,
|
||||
prompt: AgentLaunchPrompt | undefined
|
||||
): Partial<RuntimeManagedWorktreeCreateArgs> {
|
||||
if (prompt?.delivery === 'draft') {
|
||||
return { startupDraftAgent: agent, startupDraft: prompt.text }
|
||||
}
|
||||
return {
|
||||
startupAgent: agent,
|
||||
...(prompt ? { startupPrompt: prompt.text } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function withoutStartupAgentFields(
|
||||
args: RuntimeManagedWorktreeCreateArgs
|
||||
): RuntimeManagedWorktreeCreateArgs {
|
||||
const {
|
||||
startupAgent: _agent,
|
||||
startupPrompt: _prompt,
|
||||
startupDraft: _draft,
|
||||
startupDraftAgent: _draftAgent,
|
||||
startupLaunchSource: _source,
|
||||
...rest
|
||||
} = args
|
||||
return rest
|
||||
}
|
||||
@@ -17,7 +17,8 @@ import type {
|
||||
} from './runtime-worktree-agent-startup'
|
||||
import {
|
||||
buildWorktreeStartupForAgent,
|
||||
buildWorktreeStartupForDraft
|
||||
buildWorktreeStartupForDraft,
|
||||
resolveWorktreeStartupDraftAgent
|
||||
} from './runtime-worktree-agent-startup'
|
||||
import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority'
|
||||
import type { Worktree } from '../../shared/worktree/types'
|
||||
@@ -131,6 +132,19 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
|
||||
return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake }
|
||||
}
|
||||
|
||||
/** The agent a create's linked draft starts, chosen as `buildStartupForDraft` chooses it, so a
|
||||
* launch can name the agent before the create runs. */
|
||||
async resolveStartupDraftAgent(repo: Repo, requestedAgent?: TuiAgent): Promise<TuiAgent | null> {
|
||||
if (!this.store) {
|
||||
return null
|
||||
}
|
||||
return resolveWorktreeStartupDraftAgent({
|
||||
repo,
|
||||
settings: this.store.getSettings(),
|
||||
...(requestedAgent ? { requestedAgent } : {})
|
||||
})
|
||||
}
|
||||
|
||||
protected async buildStartupForDraft(
|
||||
repo: Repo,
|
||||
draft: string,
|
||||
|
||||
@@ -71,7 +71,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
|
||||
? await this.buildStartupForDraft(
|
||||
repo,
|
||||
args.startupDraft,
|
||||
requestedAgent,
|
||||
args.startupDraftAgent ?? requestedAgent,
|
||||
args.startupLaunchSource
|
||||
)
|
||||
: null
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import './orca-runtime-test-lifecycle.spec'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
OrcaRuntimeService,
|
||||
computeWorktreePathMock,
|
||||
detectInstalledAgentsWithShellPathHydrationMock,
|
||||
detectRemoteAgentsMock,
|
||||
ensurePathWithinWorkspaceMock,
|
||||
listWorktrees
|
||||
} from './orca-runtime-test-mocks.spec'
|
||||
import type { WorktreeMeta } from './orca-runtime-test-mocks.spec'
|
||||
import { makeWorktreeMeta, store } from './orca-runtime-test-fixtures.spec'
|
||||
import { createWorktreeWithStartupAgent } from '../agent-launch/startup-agent-worktree-create'
|
||||
import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types'
|
||||
|
||||
type DraftSettings = {
|
||||
defaultTuiAgent: 'claude' | null
|
||||
agentDefaultArgs: Record<string, string>
|
||||
detected: string[]
|
||||
}
|
||||
|
||||
/** One create on a fresh runtime: what the user is left with, and how often agents were detected. */
|
||||
async function runCreate(
|
||||
route: 'create' | 'launch',
|
||||
settings: DraftSettings,
|
||||
request: Omit<RuntimeManagedWorktreeCreateArgs, 'repoSelector' | 'name'>
|
||||
) {
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockReset()
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(settings.detected)
|
||||
detectRemoteAgentsMock.mockClear()
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
defaultTuiAgent: settings.defaultTuiAgent,
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: settings.agentDefaultArgs
|
||||
}),
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta }
|
||||
return metaById[worktreeId]
|
||||
}
|
||||
})
|
||||
const spawn = vi.fn().mockResolvedValue({ id: `pty-${route}` })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const path = `/tmp/workspaces/draft-parity-${route}`
|
||||
computeWorktreePathMock.mockReturnValue(path)
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue(path)
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{ path, head: 'abc', branch: 'draft-parity', isBare: false, isMainWorktree: false }
|
||||
])
|
||||
const args = { repoSelector: 'id:repo-1', name: 'draft-parity', ...request }
|
||||
const result =
|
||||
route === 'create'
|
||||
? await runtime.createManagedWorktree(args)
|
||||
: await createWorktreeWithStartupAgent(runtime, args)
|
||||
return {
|
||||
createdWithAgent: metaById[result.worktree.id]?.createdWithAgent,
|
||||
spawned: spawn.mock.calls.map(([options]) => options.command ?? null),
|
||||
detections: detectInstalledAgentsWithShellPathHydrationMock.mock.calls.length
|
||||
}
|
||||
}
|
||||
|
||||
const DRAFT = 'https://github.com/stablyai/orca/issues/123'
|
||||
|
||||
// `worktree.create` now reaches the create through the launch executor; for a linked draft the
|
||||
// user must be left with exactly what the create alone gave them.
|
||||
describe('a linked-draft create through the launch executor', () => {
|
||||
it.each([false, true])(
|
||||
'matches the create when the default agent cannot be launched (activate %s)',
|
||||
async (activate) => {
|
||||
const settings = {
|
||||
defaultTuiAgent: 'claude' as const,
|
||||
agentDefaultArgs: { claude: '--foo "unbalanced' },
|
||||
detected: []
|
||||
}
|
||||
const request = { startupDraft: DRAFT, activate }
|
||||
|
||||
const before = await runCreate('create', settings, request)
|
||||
const after = await runCreate('launch', settings, request)
|
||||
|
||||
expect(after).toEqual(before)
|
||||
// No agent started, so none is recorded and a background create still opens its shell.
|
||||
expect(before.createdWithAgent).toBeUndefined()
|
||||
if (!activate) {
|
||||
expect(before.spawned).toHaveLength(1)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('detects once and starts the detected agent with the draft, as the create does', async () => {
|
||||
const settings = { defaultTuiAgent: null, agentDefaultArgs: {}, detected: ['claude'] }
|
||||
const request = { startupDraft: DRAFT, activate: false }
|
||||
|
||||
const before = await runCreate('create', settings, request)
|
||||
const after = await runCreate('launch', settings, request)
|
||||
|
||||
expect(after).toEqual(before)
|
||||
expect(before.detections).toBe(1)
|
||||
expect(before.createdWithAgent).toBe('claude')
|
||||
expect(before.spawned).toEqual([expect.stringContaining(DRAFT)])
|
||||
})
|
||||
|
||||
it('detects once when no agent is found, and records none', async () => {
|
||||
const settings = { defaultTuiAgent: null, agentDefaultArgs: {}, detected: [] }
|
||||
const request = { startupDraft: DRAFT, activate: false }
|
||||
|
||||
const before = await runCreate('create', settings, request)
|
||||
const after = await runCreate('launch', settings, request)
|
||||
|
||||
expect(after).toEqual(before)
|
||||
expect(before.detections).toBe(1)
|
||||
expect(before.createdWithAgent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a requested agent the request named, without detecting', async () => {
|
||||
const settings = { defaultTuiAgent: null, agentDefaultArgs: {}, detected: ['codex'] }
|
||||
const request = { startupDraft: DRAFT, createdWithAgent: 'claude' as const, activate: false }
|
||||
|
||||
const before = await runCreate('create', settings, request)
|
||||
const after = await runCreate('launch', settings, request)
|
||||
|
||||
expect(after).toEqual(before)
|
||||
expect(before.detections).toBe(0)
|
||||
expect(before.createdWithAgent).toBe('claude')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Recorded `worktree.create` requests from clients that update separately from the host: the CLI's
|
||||
* `worktree create --agent --prompt` and the phone's create-from-issue draft. The host now starts
|
||||
* their agent through the launch executor; what they send and read must not change.
|
||||
*/
|
||||
|
||||
import '../unused-default-rpc-methods.test-fixture'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { WORKTREE_METHODS } from './worktree'
|
||||
|
||||
const repo = {
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 1,
|
||||
kind: 'git' as const
|
||||
}
|
||||
|
||||
// `orca worktree create --name agent-task --agent claude --prompt "fix the login bug" --json`
|
||||
const CLI_AGENT_CREATE = {
|
||||
repo: 'id:repo-1',
|
||||
name: 'agent-task',
|
||||
displayName: 'agent-task',
|
||||
displayNameKind: 'user',
|
||||
runHooks: false,
|
||||
activate: false,
|
||||
noParent: false,
|
||||
cliProvenanceRequest: {},
|
||||
startupAgent: 'claude',
|
||||
startupPrompt: 'fix the login bug',
|
||||
launchSource: 'cli'
|
||||
}
|
||||
|
||||
// The phone's "create workspace from issue" with an agent picked.
|
||||
const PHONE_ISSUE_DRAFT_CREATE = {
|
||||
repo: 'id:repo-1',
|
||||
name: 'issue-88',
|
||||
displayName: 'Investigate login',
|
||||
displayNameKind: 'generated',
|
||||
setupDecision: 'skip',
|
||||
activate: true,
|
||||
startupDraft: 'https://github.com/acme/app/issues/88',
|
||||
createdWithAgent: 'codex',
|
||||
linkedIssue: 88,
|
||||
clientMutationId: 'mobile-mutation-1'
|
||||
}
|
||||
|
||||
function harness(created: Record<string, unknown>) {
|
||||
const createManagedWorktree = vi.fn().mockResolvedValue(created)
|
||||
const resolveStartupDraftAgent = vi.fn(async (_repo: unknown, requested?: string) => requested)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every runtime method worktree.create reaches for a create with no automation provenance.
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
dedupeWorktreeCreate: <T>(_repo: string, _id: string | undefined, run: () => Promise<T>) =>
|
||||
run(),
|
||||
showRepo: vi.fn().mockResolvedValue(repo),
|
||||
// The chat default is on: `worktree.create` still answers with a terminal agent.
|
||||
getClientSettings: () => ({ experimentalNativeChat: true }),
|
||||
getStructuredAgentSessionCreateSupport: vi.fn(async () => ({
|
||||
supported: true
|
||||
})),
|
||||
resolveStartupDraftAgent,
|
||||
createManagedWorktree
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
const dispatch = (params: Record<string, unknown>) =>
|
||||
dispatcher.dispatch({
|
||||
id: 'req-1',
|
||||
authToken: 'tok',
|
||||
method: 'worktree.create',
|
||||
params
|
||||
})
|
||||
return { dispatch, createManagedWorktree, runtime }
|
||||
}
|
||||
|
||||
describe('worktree.create with a startup agent', () => {
|
||||
it("answers the CLI's agent create with the same terminal handle, the prompt folded by the create", async () => {
|
||||
const { dispatch, createManagedWorktree, runtime } = harness({
|
||||
worktree: { id: 'repo-1::/wt/agent-task' },
|
||||
startupTerminal: {
|
||||
spawned: true,
|
||||
handle: 'term_agent',
|
||||
surface: 'background'
|
||||
}
|
||||
})
|
||||
|
||||
const response = await dispatch(CLI_AGENT_CREATE)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: { id: 'repo-1::/wt/agent-task' },
|
||||
startupTerminal: { handle: 'term_agent' },
|
||||
agentTerminalHandle: 'term_agent'
|
||||
}
|
||||
})
|
||||
expect(createManagedWorktree).toHaveBeenCalledTimes(1)
|
||||
const args = createManagedWorktree.mock.calls[0][0]
|
||||
expect(args).toMatchObject({
|
||||
startupAgent: 'claude',
|
||||
startupPrompt: 'fix the login bug',
|
||||
startupLaunchSource: 'cli',
|
||||
activate: false
|
||||
})
|
||||
expect(args).not.toHaveProperty('onStartupPromptCarry')
|
||||
expect(args).not.toHaveProperty('awaitTerminalProvisioning')
|
||||
expect(runtime.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("leaves the phone's issue URL as an unsent draft and returns no agent handle, as before", async () => {
|
||||
const { dispatch, createManagedWorktree } = harness({
|
||||
worktree: { id: 'repo-1::/wt/issue-88' },
|
||||
startupTerminal: {
|
||||
spawned: true,
|
||||
handle: 'term_draft',
|
||||
surface: 'background'
|
||||
}
|
||||
})
|
||||
|
||||
const response = await dispatch(PHONE_ISSUE_DRAFT_CREATE)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { worktree: { id: 'repo-1::/wt/issue-88' } }
|
||||
})
|
||||
expect(response).not.toHaveProperty('result.agentTerminalHandle')
|
||||
const args = createManagedWorktree.mock.calls[0][0]
|
||||
expect(args).toMatchObject({
|
||||
startupDraft: 'https://github.com/acme/app/issues/88',
|
||||
createdWithAgent: 'codex',
|
||||
linkedIssue: 88,
|
||||
activate: true
|
||||
})
|
||||
expect(args).not.toHaveProperty('startupAgent')
|
||||
expect(args).not.toHaveProperty('startupPrompt')
|
||||
})
|
||||
|
||||
it('answers without an agent handle when the create started no agent here', async () => {
|
||||
const { dispatch, createManagedWorktree } = harness({
|
||||
worktree: { id: 'repo-1::/wt/agent-task' },
|
||||
warning: 'Failed to create the startup terminal for /wt/agent-task: spawn failed'
|
||||
})
|
||||
|
||||
const response = await dispatch(CLI_AGENT_CREATE)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
warning: 'Failed to create the startup terminal for /wt/agent-task: spawn failed'
|
||||
}
|
||||
})
|
||||
expect(response).not.toHaveProperty('result.agentTerminalHandle')
|
||||
expect(createManagedWorktree).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -589,10 +589,12 @@ describe('worktree RPC methods', () => {
|
||||
})
|
||||
|
||||
it('forwards task startup drafts to runtime worktree creation', async () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every runtime method a draft create reaches, including the draft agent's choice.
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
dedupeWorktreeCreate: passthroughDedupe,
|
||||
showRepo: vi.fn().mockResolvedValue(repo),
|
||||
resolveStartupDraftAgent: vi.fn().mockResolvedValue('codex'),
|
||||
createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getExplicitWorktreeIdSelector } from '../../runtime-worktree-selection'
|
||||
import { splitWorktreeId } from '../../../../shared/worktree/id'
|
||||
import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance'
|
||||
import { displayNameUpdatePinsLabel } from '../../../../shared/worktree/display-name-provenance'
|
||||
import { createWorktreeWithStartupAgent } from '../../../agent-launch/startup-agent-worktree-create'
|
||||
import { defineMethod } from '../core'
|
||||
import { buildManagedWorktreeCreateArgs } from './worktree-create-args'
|
||||
import { resolvePairedCallerHostId } from './paired-caller-host-id'
|
||||
@@ -102,7 +103,8 @@ export const WORKTREE_METHODS = [
|
||||
// Why: provenance tokens are reserved before creation so retries can recover,
|
||||
// but failed create attempts must release the reservation for a safe retry.
|
||||
try {
|
||||
const result = await runtime.createManagedWorktree(
|
||||
const result = await createWorktreeWithStartupAgent(
|
||||
runtime,
|
||||
buildManagedWorktreeCreateArgs(
|
||||
params,
|
||||
{
|
||||
|
||||
@@ -76,6 +76,9 @@ export type RuntimeManagedWorktreeCreateArgs = {
|
||||
creatorProvenance?: Worktree['creatorProvenance']
|
||||
startup?: WorktreeStartupLaunch
|
||||
startupDraft?: string
|
||||
/** Main-internal: the agent a launch already chose for `startupDraft`, so the create does not
|
||||
* choose again. Steers only the draft; `createdWithAgent` stays what the caller asked for. */
|
||||
startupDraftAgent?: TuiAgent
|
||||
startupDraftPaste?: WorktreeStartupDraftPaste
|
||||
lineage?: {
|
||||
parentWorkspace?: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { tuiAgentToAgentKind } from '../../shared/agent-kind'
|
||||
import { getDefaultSettings } from '../../shared/constants'
|
||||
@@ -17,7 +17,8 @@ vi.mock('../preflight/agent-detection', () => ({
|
||||
import {
|
||||
buildWorktreeStartupForAgent,
|
||||
buildWorktreeStartupForDraft,
|
||||
resolveWorktreeCreateAgentStartup
|
||||
resolveWorktreeCreateAgentStartup,
|
||||
resolveWorktreeStartupDraftAgent
|
||||
} from './runtime-worktree-agent-startup'
|
||||
|
||||
function makeRepo(fields: Partial<Repo>): Repo {
|
||||
@@ -148,6 +149,30 @@ describe('buildWorktreeStartupForAgent prompt carry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// `worktree.create` through the launch executor passes no prompt for a blank one; the create must
|
||||
// launch the agent exactly as it did when handed the blank text itself.
|
||||
describe('buildWorktreeStartupForAgent blank prompt', () => {
|
||||
it.each(['claude', 'aider'] as const)(
|
||||
'launches %s bare for an absent or blank prompt',
|
||||
(agent) => {
|
||||
const build = (prompt?: string) =>
|
||||
buildWorktreeStartupForAgent({
|
||||
repo: makeRepo({}),
|
||||
settings,
|
||||
agent,
|
||||
...(prompt !== undefined ? { prompt } : {}),
|
||||
getLaunchPlatform: () => 'linux',
|
||||
toSessionOptions: () => undefined
|
||||
})
|
||||
|
||||
const bare = build()
|
||||
expect(bare.followup).toBeUndefined()
|
||||
expect(build('')).toEqual(bare)
|
||||
expect(build(' \n ')).toEqual(bare)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('buildWorktreeStartupForDraft agent detection', () => {
|
||||
it('probes the SSH host named only by executionHostId instead of this client', async () => {
|
||||
mocks.detectRemoteAgents.mockResolvedValueOnce(['claude'])
|
||||
@@ -264,3 +289,73 @@ describe('buildWorktreeStartupForAgent extra agent args', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWorktreeStartupDraftAgent', () => {
|
||||
beforeEach(() => {
|
||||
mocks.detectRemoteAgents.mockReset()
|
||||
mocks.detectInstalledAgentsWithShellPathHydration.mockReset()
|
||||
mocks.detectRemoteAgents.mockResolvedValue([])
|
||||
mocks.detectInstalledAgentsWithShellPathHydration.mockResolvedValue([])
|
||||
})
|
||||
afterEach(() => {
|
||||
mocks.detectRemoteAgents.mockReset()
|
||||
mocks.detectInstalledAgentsWithShellPathHydration.mockReset()
|
||||
})
|
||||
|
||||
const resolve = (
|
||||
fields: Partial<Parameters<typeof resolveWorktreeStartupDraftAgent>[0]['settings']>,
|
||||
requestedAgent?: 'claude' | 'codex',
|
||||
repo = makeRepo({})
|
||||
) =>
|
||||
resolveWorktreeStartupDraftAgent({
|
||||
repo,
|
||||
settings: { ...settings, ...fields },
|
||||
...(requestedAgent ? { requestedAgent } : {})
|
||||
})
|
||||
|
||||
it('returns an enabled requested agent without detecting', async () => {
|
||||
await expect(resolve({ defaultTuiAgent: 'claude' }, 'codex')).resolves.toBe('codex')
|
||||
expect(mocks.detectInstalledAgentsWithShellPathHydration).not.toHaveBeenCalled()
|
||||
expect(mocks.detectRemoteAgents).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the default when nothing was requested', async () => {
|
||||
await expect(resolve({ defaultTuiAgent: 'claude' })).resolves.toBe('claude')
|
||||
expect(mocks.detectInstalledAgentsWithShellPathHydration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The requested agent replaces the default rather than preceding it, so a disabled request goes
|
||||
// straight to detection — the order the create has always used.
|
||||
it('detects instead of using the default when the requested agent is disabled', async () => {
|
||||
mocks.detectInstalledAgentsWithShellPathHydration.mockResolvedValue(['codex', 'gemini'])
|
||||
|
||||
await expect(
|
||||
resolve({ defaultTuiAgent: 'claude', disabledTuiAgents: ['codex'] }, 'codex')
|
||||
).resolves.toBe('gemini')
|
||||
expect(mocks.detectInstalledAgentsWithShellPathHydration).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('starts no agent when the default is blank', async () => {
|
||||
await expect(resolve({ defaultTuiAgent: 'blank' })).resolves.toBeNull()
|
||||
expect(mocks.detectInstalledAgentsWithShellPathHydration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects an enabled agent on this host when nothing usable was named', async () => {
|
||||
mocks.detectInstalledAgentsWithShellPathHydration.mockResolvedValue(['codex', 'claude'])
|
||||
|
||||
await expect(resolve({ disabledTuiAgents: ['codex'] })).resolves.toBe('claude')
|
||||
expect(mocks.detectRemoteAgents).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects on the SSH host that runs the agent', async () => {
|
||||
mocks.detectRemoteAgents.mockResolvedValue(['codex'])
|
||||
|
||||
await expect(resolve({}, undefined, makeRepo({ connectionId: 'ssh-1' }))).resolves.toBe('codex')
|
||||
expect(mocks.detectRemoteAgents).toHaveBeenCalledWith({ connectionId: 'ssh-1' })
|
||||
expect(mocks.detectInstalledAgentsWithShellPathHydration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts no agent when detection finds none', async () => {
|
||||
await expect(resolve({})).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,6 +41,35 @@ type StartupEnvironment = {
|
||||
launchSource?: string
|
||||
}
|
||||
|
||||
/** The agent a linked draft starts: the requested one, else the default, else one detected on the
|
||||
* host that runs it. `null` means the draft starts no agent. */
|
||||
export async function resolveWorktreeStartupDraftAgent(
|
||||
environment: Pick<StartupEnvironment, 'repo' | 'settings'> & { requestedAgent?: TuiAgent }
|
||||
): Promise<TuiAgent | null> {
|
||||
const { repo, settings } = environment
|
||||
const preferredAgent = environment.requestedAgent ?? settings.defaultTuiAgent
|
||||
// Why: `blank` is an explicit shell-only preference, so linked drafts must not auto-pick an agent.
|
||||
if (preferredAgent === 'blank') {
|
||||
return null
|
||||
}
|
||||
if (isTuiAgent(preferredAgent) && isTuiAgentEnabled(preferredAgent, settings.disabledTuiAgents)) {
|
||||
return preferredAgent
|
||||
}
|
||||
let detected: string[] = []
|
||||
// Why: detection has to run on the machine that will run the agent, and SSH ownership has two
|
||||
// spellings — the raw field probes this client for an `executionHostId: 'ssh:*'`-only repo.
|
||||
const sshConnectionId = getRepoSshConnectionId(repo)
|
||||
try {
|
||||
// Why: startup-draft fallback can run from sparse runtime launch envs too.
|
||||
detected = sshConnectionId
|
||||
? await detectRemoteAgents({ connectionId: sshConnectionId })
|
||||
: await detectInstalledAgentsWithShellPathHydration()
|
||||
} catch {
|
||||
detected = []
|
||||
}
|
||||
return pickTuiAgent(null, detected.filter(isTuiAgent), settings.disabledTuiAgents)
|
||||
}
|
||||
|
||||
export async function buildWorktreeStartupForDraft(
|
||||
environment: StartupEnvironment & { draft: string; requestedAgent?: TuiAgent }
|
||||
): Promise<{
|
||||
@@ -53,30 +82,7 @@ export async function buildWorktreeStartupForDraft(
|
||||
return null
|
||||
}
|
||||
const { repo, settings } = environment
|
||||
const preferredAgent = environment.requestedAgent ?? settings.defaultTuiAgent
|
||||
// Why: `blank` is an explicit shell-only preference, so linked drafts must not auto-pick an agent.
|
||||
if (preferredAgent === 'blank') {
|
||||
return null
|
||||
}
|
||||
let agent =
|
||||
isTuiAgent(preferredAgent) && isTuiAgentEnabled(preferredAgent, settings.disabledTuiAgents)
|
||||
? preferredAgent
|
||||
: null
|
||||
if (!agent) {
|
||||
let detected: string[] = []
|
||||
// Why: detection has to run on the machine that will run the agent, and SSH ownership has two
|
||||
// spellings — the raw field probes this client for an `executionHostId: 'ssh:*'`-only repo.
|
||||
const sshConnectionId = getRepoSshConnectionId(repo)
|
||||
try {
|
||||
// Why: startup-draft fallback can run from sparse runtime launch envs too.
|
||||
detected = sshConnectionId
|
||||
? await detectRemoteAgents({ connectionId: sshConnectionId })
|
||||
: await detectInstalledAgentsWithShellPathHydration()
|
||||
} catch {
|
||||
detected = []
|
||||
}
|
||||
agent = pickTuiAgent(null, detected.filter(isTuiAgent), settings.disabledTuiAgents)
|
||||
}
|
||||
const agent = await resolveWorktreeStartupDraftAgent(environment)
|
||||
if (!agent) {
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user