diff --git a/src/main/agent-launch/agent-launch-execution.ts b/src/main/agent-launch/agent-launch-execution.ts new file mode 100644 index 00000000000..d847d4ba3d7 --- /dev/null +++ b/src/main/agent-launch/agent-launch-execution.ts @@ -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 + 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 + reuseTerminal?: never + } + promptPolicy: 'legacy-host' + terminalOnly: true + workspaces: AgentLaunchWorkspaceFactory + surfaces?: never +} + +export type AgentLaunchExecution = AgentLaunchSurfaceExecution | AgentLaunchLegacyHostExecution diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index f33e85b92b0..a618c4171a1 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -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 - 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 { @@ -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 { const { intent, surfaces } = execution diff --git a/src/main/agent-launch/agent-launch-legacy-host-policy.test.ts b/src/main/agent-launch/agent-launch-legacy-host-policy.test.ts new file mode 100644 index 00000000000..17240fb4548 --- /dev/null +++ b/src/main/agent-launch/agent-launch-legacy-host-policy.test.ts @@ -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) => ({ + 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) + }) +}) diff --git a/src/main/agent-launch/agent-launch-legacy-host.ts b/src/main/agent-launch/agent-launch-legacy-host.ts new file mode 100644 index 00000000000..3d01602d57c --- /dev/null +++ b/src/main/agent-launch/agent-launch-legacy-host.ts @@ -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' } +} diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 523ad1fe0e8..3ef3c373ba3 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -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', diff --git a/src/main/agent-launch/agent-launch-prompt-delivery.ts b/src/main/agent-launch/agent-launch-prompt-delivery.ts index a1e062b8cbc..d6a17d6b06f 100644 --- a/src/main/agent-launch/agent-launch-prompt-delivery.ts +++ b/src/main/agent-launch/agent-launch-prompt-delivery.ts @@ -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 { 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 { 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 { @@ -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, diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index b9cf2e8c6b5..168c157b16f 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -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 diff --git a/src/main/agent-launch/startup-agent-worktree-create.test.ts b/src/main/agent-launch/startup-agent-worktree-create.test.ts new file mode 100644 index 00000000000..8b39d109050 --- /dev/null +++ b/src/main/agent-launch/startup-agent-worktree-create.test.ts @@ -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 + 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) + }) +}) diff --git a/src/main/agent-launch/startup-agent-worktree-create.ts b/src/main/agent-launch/startup-agent-worktree-create.ts new file mode 100644 index 00000000000..c354f072960 --- /dev/null +++ b/src/main/agent-launch/startup-agent-worktree-create.ts @@ -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 + +type StartupAgentLaunch = { + agent: TuiAgent + prompt: AgentLaunchPrompt | undefined + connectionId: string | null +} + +export async function createWorktreeWithStartupAgent( + runtime: StartupAgentCreateRuntime, + args: RuntimeManagedWorktreeCreateArgs +): Promise { + 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 { + 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 { + 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 +} diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index a3a889c0938..9ecbd454f40 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -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 { + if (!this.store) { + return null + } + return resolveWorktreeStartupDraftAgent({ + repo, + settings: this.store.getSettings(), + ...(requestedAgent ? { requestedAgent } : {}) + }) + } + protected async buildStartupForDraft( repo: Repo, draft: string, diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 10eb7080e1f..6490294512c 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -71,7 +71,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork ? await this.buildStartupForDraft( repo, args.startupDraft, - requestedAgent, + args.startupDraftAgent ?? requestedAgent, args.startupLaunchSource ) : null diff --git a/src/main/runtime/orca-runtime-startup-draft-launch-parity.test.ts b/src/main/runtime/orca-runtime-startup-draft-launch-parity.test.ts new file mode 100644 index 00000000000..40ad34e0078 --- /dev/null +++ b/src/main/runtime/orca-runtime-startup-draft-launch-parity.test.ts @@ -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 + 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 +) { + detectInstalledAgentsWithShellPathHydrationMock.mockReset() + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(settings.detected) + detectRemoteAgentsMock.mockClear() + const metaById: Record = {} + 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) => { + 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') + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-create-startup-agent.test.ts b/src/main/runtime/rpc/methods/worktree-create-startup-agent.test.ts new file mode 100644 index 00000000000..6604e7fd74e --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-create-startup-agent.test.ts @@ -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) { + 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: (_repo: string, _id: string | undefined, run: () => Promise) => + 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) => + 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) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 3ebd27dd90a..2f7ff5f37f3 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -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 }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 9f59a872e25..dabd845316c 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -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, { diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index a848da4801a..0a9d241d8c7 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -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 diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index 3e3d99e1cac..4acf742f087 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -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 { @@ -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[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() + }) +}) diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index f065ab134aa..d5021488b40 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -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 & { requestedAgent?: TuiAgent } +): Promise { + 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 }