From 2679464ead29c95ec25f85fc0d0aaaa414d0e730 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:01:29 -0700 Subject: [PATCH] fix: let capable paired hosts plan composer terminal agent launches (#32192) A workspace created from the composer on a paired host launched its terminal agent with the desktop's command, arguments and environment. Hosts that negotiate agent.launch.v2 now get startupAgent (and startupPrompt) and build the launch through their own planner and settings. Older hosts, failed probes, hosts that predate the agent's name, linked-item drafts and VM recipes keep the desktop-built command. When the host planned the launch, the window never runs the desktop plan or its own agent recovery, and says it couldn't confirm the agent started if the host reported no agent terminal. --- .../src/lib/worktree-creation-completion.ts | 13 +- .../src/lib/worktree-creation-flow-execute.ts | 29 ++-- ...orktree-creation-host-agent-launch.test.ts | 114 ++++++++++++++ .../worktree-creation-host-agent-launch.ts | 59 +++++++ .../src/store/slices/worktree-helpers.ts | 8 +- .../worktrees/create/create-worktree.ts | 27 ++-- .../create/host-planned-agent-launch.ts | 72 +++++++++ .../create/worktree-create-payload.ts | 55 +++++-- ...-worktree-agent-command-owner.unit.test.ts | 145 ++++++++++++++++++ 9 files changed, 472 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/lib/worktree-creation-host-agent-launch.test.ts create mode 100644 src/renderer/src/lib/worktree-creation-host-agent-launch.ts create mode 100644 src/renderer/src/store/slices/worktrees/create/host-planned-agent-launch.ts create mode 100644 tests/e2e/paired-worktree-agent-command-owner.unit.test.ts diff --git a/src/renderer/src/lib/worktree-creation-completion.ts b/src/renderer/src/lib/worktree-creation-completion.ts index 931632c5d9e..60929b86cf3 100644 --- a/src/renderer/src/lib/worktree-creation-completion.ts +++ b/src/renderer/src/lib/worktree-creation-completion.ts @@ -14,12 +14,16 @@ export async function completeWorktreeCreation(args: { primaryTabId: string | null startupTerminalTabId?: string backendSpawned: boolean + /** The host was asked to plan the agent launch, so the desktop's plan never runs. */ + hostPlannedStartup: boolean focusOnCompletion: boolean }): Promise { const { request } = args // Why: clearing synchronously after activation lets React commit the panel-to-terminal swap in one frame. useAppStore.getState().removePendingWorktreeCreation(args.creationId, { cleanupVm: false }) - if (!args.structuredLaunchAccepted) { + // Why: an agent the host planned but did not start has no tab to seed and is not the window's to start. + const hostAgentMissing = args.hostPlannedStartup && !args.backendSpawned + if (!args.structuredLaunchAccepted && !hostAgentMissing) { seedAgentTabStateAfterWorktreeCreate({ request, worktreeId: args.worktreeId, @@ -28,7 +32,12 @@ export async function completeWorktreeCreation(args: { backendSpawned: args.backendSpawned }) } - if (!args.structuredLaunchAccepted && request.startupPlan && !args.backendSpawned) { + if ( + !args.structuredLaunchAccepted && + request.startupPlan && + !args.backendSpawned && + !args.hostPlannedStartup + ) { void ensureAgentStartupInTerminal({ worktreeId: args.worktreeId, primaryTabId: args.primaryTabId, diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 82a90afbb30..a6cc336d090 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -13,11 +13,13 @@ import { formatWorkspaceCreateError, getWorkspaceCreateErrorToastMessage } from '@/lib/workspace-create-error-format' -import type { CreateWorktreeResult } from '../../../shared/worktree/create-types' +import type { CreatedWorktreeResult } from '@/store/slices/worktrees/create/worktree-create-payload' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' -import { createBrowserUuid } from '@/lib/browser-uuid' import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode' -import { buildWorktreeCreationStartupOpt } from '@/lib/worktree-creation-flow-startup' +import { + hostPlannableAgentLaunch, + windowStartupAfterCreate +} from '@/lib/worktree-creation-host-agent-launch' import { launchStructuredWorktreeSession, type WorktreeCreationStructuredSessionResult @@ -65,7 +67,8 @@ export async function executeWorktreeCreation( preparedRequest.workspaceRunContext?.hostId ?? preparedRequest.executionHostId // Named to later steps, so a same-path twin on another host can't blur the owner (L277). const onCreationHost = creationHostId ? { executionHostId: creationHostId } : {} - let result: CreateWorktreeResult + const hostAgentLaunch = hostPlannableAgentLaunch(preparedRequest) + let result: CreatedWorktreeResult try { const provisionedRoot = getProvisionedRootCreateOptions(preparedRequest) const backendStartup = @@ -100,6 +103,7 @@ export async function executeWorktreeCreation( preparedRequest.compareBaseRef, { setupOnly: preparedRequest.setupOnly, + ...(hostAgentLaunch ? { hostAgentLaunch } : {}), executionHostId: creationHostId, ...(preparedRequest.nameWasGenerated ? { nameWasGenerated: true } : {}), ...(preparedRequest.displayNameKind @@ -160,14 +164,8 @@ export async function executeWorktreeCreation( await attachEphemeralVmRuntimeToWorkspace(preparedRequest, worktree.id) const backendSpawned = result.startupTerminal?.spawned === true - if (preparedRequest.startupPlan && !backendSpawned && !preparedRequest.startupPlan.launchToken) { - // Why: delayed delivery must target the exact pane spawned from this queued - // startup, so both halves of the handoff share one renderer-session token. - preparedRequest.startupPlan.launchToken = createBrowserUuid() - } - const startupOpt = structuredLaunch - ? undefined - : buildWorktreeCreationStartupOpt(preparedRequest, backendSpawned) + const windowStartup = windowStartupAfterCreate(preparedRequest, result, backendSpawned) + const startupOpt = windowStartup.startup // Why: only a user still watching the creation surface (or already on the new // workspace) is handed it; anyone who moved on (another workspace or an app @@ -186,7 +184,7 @@ export async function executeWorktreeCreation( activation = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', ...onCreationHost, - ...(preparedRequest.agent !== null ? { agent: preparedRequest.agent } : {}), + ...(windowStartup.agent !== null ? { agent: windowStartup.agent } : {}), ...(result.setup ? { setup: result.setup } : {}), ...(defaultTabs ? { defaultTabs } : {}), ...(startupOpt ? { startup: startupOpt } : {}), @@ -230,7 +228,7 @@ export async function executeWorktreeCreation( try { ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, { startup: startupOpt, - agent: preparedRequest.agent, + agent: windowStartup.agent, unknownHostCountIsEmpty: true }) } catch (recoveryError) { @@ -277,7 +275,7 @@ export async function executeWorktreeCreation( try { ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, { startup: startupOpt, - agent: preparedRequest.agent, + agent: windowStartup.agent, activate: false, // Why: this client just created the workspace, so it starts empty. unknownHostCountIsEmpty: true @@ -334,6 +332,7 @@ export async function executeWorktreeCreation( primaryTabId, startupTerminalTabId: result.startupTerminal?.tabId, backendSpawned, + hostPlannedStartup: result.hostPlannedStartup === true, focusOnCompletion: shouldActivateOnCompletion }) } diff --git a/src/renderer/src/lib/worktree-creation-host-agent-launch.test.ts b/src/renderer/src/lib/worktree-creation-host-agent-launch.test.ts new file mode 100644 index 00000000000..bf3e79dc3bd --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-host-agent-launch.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type { + PendingWorktreeCreation, + WorktreeCreationRequest +} from '@/lib/pending-worktree-creation' + +const pendingWorktreeCreations: Record = {} +const store = { + settings: {}, + activeView: 'terminal', + activePendingCreationId: 'creation-1', + repos: [], + pendingWorktreeCreations, + beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => { + pendingWorktreeCreations[entry.creationId] = entry + }), + updatePendingWorktreeCreation: vi.fn(), + removePendingWorktreeCreation: vi.fn((creationId: string) => { + delete pendingWorktreeCreations[creationId] + }), + updateWorktreeMeta: vi.fn(), + setActivePendingWorktreeCreation: vi.fn(), + setActiveView: vi.fn(), + setSidebarOpen: vi.fn(), + createWorktree: vi.fn<(...args: unknown[]) => Promise>(), + seedNativeChatLaunchDraft: vi.fn(), + setTabViewMode: vi.fn(), + tabsByWorktree: {}, + unifiedTabsByWorktree: {} +} + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn(() => ({ primaryTabId: 'tab-1' })) +})) +vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({ + ensureWorktreeHasInitialTerminal: vi.fn() +})) +vi.mock('@/lib/workspace-activation-terminal-focus', () => ({ + queueWorkspaceActivationTerminalFocus: vi.fn() +})) +vi.mock('@/lib/new-workspace', () => ({ ensureAgentStartupInTerminal: vi.fn() })) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) + +import { toast } from 'sonner' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { ensureAgentStartupInTerminal } from '@/lib/new-workspace' +import { continueBackgroundWorktreeCreation } from './worktree-creation-flow' + +beforeEach(() => { + vi.clearAllMocks() +}) + +function promptedClaudeRequest(): WorktreeCreationRequest { + return { + repoId: 'repo-1', + name: 'feature', + setupDecision: 'inherit', + agent: 'claude', + agentLaunchRoute: 'terminal-tui', + pendingFirstAgentMessageRename: false, + note: '', + quickPrompt: 'fix the bug', + quickTelemetry: { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + }, + startupPlan: { + agent: 'claude', + launchCommand: '/desktop/claude', + expectedProcess: 'claude', + followupPrompt: 'fix the bug', + launchConfig: { agentCommand: '/desktop/claude', agentArgs: '', agentEnv: {} } + } + } +} + +it('never runs the desktop plan when the host that planned the launch did not start it', async () => { + store.createWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' }, + warning: 'Host could not start claude.', + hostPlannedStartup: true + }) + + const request = promptedClaudeRequest() + pendingWorktreeCreations['creation-1'] = { + creationId: 'creation-1', + phase: 'fetching', + status: 'creating', + startedAt: 1, + indeterminate: true, + loaderVisible: true, + request + } + continueBackgroundWorktreeCreation('creation-1', request) + + await vi.waitFor(() => expect(store.removePendingWorktreeCreation).toHaveBeenCalled()) + expect(store.createWorktree.mock.calls[0]?.[25]).toMatchObject({ + hostAgentLaunch: { + agent: 'claude', + prompt: 'fix the bug', + launchSource: 'new_workspace_composer' + } + }) + const activation = vi.mocked(activateAndRevealWorktree).mock.calls[0]?.[1] + // Neither the desktop plan nor the window's agent recovery, which would drop the prompt. + expect(activation).not.toHaveProperty('startup') + expect(activation).not.toHaveProperty('agent') + expect(ensureAgentStartupInTerminal).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith( + "Couldn't confirm Claude started. Check its terminal before trying again." + ) +}) diff --git a/src/renderer/src/lib/worktree-creation-host-agent-launch.ts b/src/renderer/src/lib/worktree-creation-host-agent-launch.ts new file mode 100644 index 00000000000..955f35763d3 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-host-agent-launch.ts @@ -0,0 +1,59 @@ +import { toast } from 'sonner' +import { createBrowserUuid } from '@/lib/browser-uuid' +import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' +import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' +import { buildWorktreeCreationStartupOpt } from '@/lib/worktree-creation-flow-startup' +import { agentLaunchPaneNoticeText } from '@/components/terminal-pane/agent-launch-pane-notice-text' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { + CreatedWorktreeResult, + HostAgentLaunch +} from '@/store/slices/worktrees/create/worktree-create-payload' + +/** The composer's terminal agent, for a host that plans it from its own settings. Chat launches + * have their own host path; VM recipes keep the desktop plan, as their hosts hold no user settings. + * Drafts stay desktop-planned: a host's `startupDraft` swaps a host-disabled agent for another. */ +export function hostPlannableAgentLaunch( + request: WorktreeCreationRequest +): HostAgentLaunch | undefined { + if ( + !request.agent || + !request.startupPlan || + request.launchDraftPrompt || + request.agentLaunchRoute === 'structured-native-chat' || + request.ephemeralVmRecipe + ) { + return undefined + } + const launchSource = request.quickTelemetry?.launch_source + return { + agent: request.agent, + ...(request.quickPrompt.trim() ? { prompt: request.quickPrompt } : {}), + ...(launchSource ? { launchSource } : {}) + } +} + +/** What the window itself starts in the created workspace: its startup and the agent its terminal + * recovery may relaunch. A host that planned the launch owns it, so the window starts neither. */ +export function windowStartupAfterCreate( + request: WorktreeCreationRequest, + result: CreatedWorktreeResult, + backendSpawned: boolean +): { startup?: WorktreeStartupPayload; agent: TuiAgent | null } { + if (result.hostPlannedStartup) { + if (!backendSpawned) { + // Why: the host reported no agent terminal, yet its own window may still have started one. + toast.error(agentLaunchPaneNoticeText({ kind: 'unconfirmed' }, request.agent)) + } + return { agent: null } + } + if (request.startupPlan && !backendSpawned && !request.startupPlan.launchToken) { + // Why: delayed delivery must target the exact pane spawned from this queued + // startup, so both halves of the handoff share one renderer-session token. + request.startupPlan.launchToken = createBrowserUuid() + } + if (request.agentLaunchRoute === 'structured-native-chat') { + return { agent: request.agent } + } + return { startup: buildWorktreeCreationStartupOpt(request, backendSpawned), agent: request.agent } +} diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index a6487c2a7a7..d9a473e11a9 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -1,6 +1,9 @@ import type { WorkspaceAttachmentMutation } from '../../../../shared/workspace-attachment-mutation' import type { WorkspaceReferenceObservationContext } from '@/lib/workspace-attachment-observed-origin' -import type { CreateWorktreeCallOptions } from './worktrees/create/worktree-create-payload' +import type { + CreateWorktreeCallOptions, + CreatedWorktreeResult +} from './worktrees/create/worktree-create-payload' import type { WorktreeCatalogVersion } from '../../../../shared/worktree/catalog-version' import type { WorkspaceKey } from '../../../../shared/folder-workspace-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -11,7 +14,6 @@ import type { } from '../../../../shared/worktree/base-ref-drift-types' import type { CreateSparseCheckoutRequest, - CreateWorktreeResult, ForceDeleteWorktreeBranchResult, SetupDecision } from '../../../../shared/worktree/create-types' @@ -221,7 +223,7 @@ export type WorktreeSlice = { linkedGiteaPR?: number | null, compareBaseRef?: string, options?: CreateWorktreeCallOptions - ) => Promise + ) => Promise /** Register an in-flight background creation and make it the active surface. */ beginPendingWorktreeCreation: (entry: PendingWorktreeCreation) => void /** Merge a status patch into an existing pending entry. */ diff --git a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts index 1b85997f528..6a867cfdc94 100644 --- a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts @@ -1,3 +1,4 @@ +import { createWorktreeOnRuntimeHost } from './host-planned-agent-launch' import { runtimeRpcTargetForAuthorityKey } from '@/runtime/runtime-client-target' import { repoHostId } from '../listing/worktree-host-ownership' import { @@ -7,7 +8,6 @@ import { import { adoptFromEndpoint } from '../../../adopt-from-endpoint' import type { WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import type { CreateWorktreeResult } from '../../../../../../shared/worktree/create-types' import { pickClientWorktreeCreateSettings, type ClientWorktreeCreateSettings @@ -18,10 +18,7 @@ import { getGeneratedWorktreeCreateRetryCandidate, isRetryableWorktreeCreateConflict } from '../../../../../../shared/new-workspace/worktree-create-retry-policy' -import { - assertRuntimeEnvironmentCapability, - callRuntimeRpc -} from '../../../../runtime/runtime-rpc-client' +import { assertRuntimeEnvironmentCapability } from '../../../../runtime/runtime-rpc-client' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner' import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version' @@ -33,7 +30,7 @@ import { adoptRuntimeProvisionedRoot } from './runtime-provisioned-root-adoption import { isRuntimeLineageParentMissingError } from '../listing/runtime-worktree-rpc-errors' import { buildLocalWorktreeCreateArgs, - buildRuntimeWorktreeCreateParams, + type CreatedWorktreeResult, type WorktreeCreateAttempt, type WorktreeCreateRequest } from './worktree-create-payload' @@ -47,7 +44,7 @@ import { type RuntimeTarget = RuntimeClientTarget type CreateAttemptOutcome = { - result: CreateWorktreeResult + result: CreatedWorktreeResult /** The host had no such parent, so this attempt was retried unattached. */ droppedParent: boolean } @@ -61,7 +58,7 @@ async function runCreateAttempt( const provisionedRoot = request.options?.provisionedRoot const create = async ( parentWorkspace: WorktreeCreateAttempt['parentWorkspace'] - ): Promise => + ): Promise => provisionedRoot ? target.kind === 'environment' ? await adoptRuntimeProvisionedRoot( @@ -79,15 +76,11 @@ async function runCreateAttempt( await window.api.worktrees.create( buildLocalWorktreeCreateArgs(request, { ...attempt, parentWorkspace }) ) - : await callRuntimeRpc( - target, - 'worktree.create', - { - ...buildRuntimeWorktreeCreateParams(request, { ...attempt, parentWorkspace }), - // Why: the host's own store defaults would otherwise override the user's choices. - ...clientCreateSettings - }, - { timeoutMs: 10 * 60_000 } + : await createWorktreeOnRuntimeHost( + target.environmentId, + request, + { ...attempt, parentWorkspace }, + clientCreateSettings ) try { return { result: await create(attempt.parentWorkspace), droppedParent: false } diff --git a/src/renderer/src/store/slices/worktrees/create/host-planned-agent-launch.ts b/src/renderer/src/store/slices/worktrees/create/host-planned-agent-launch.ts new file mode 100644 index 00000000000..3760f3c9441 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/create/host-planned-agent-launch.ts @@ -0,0 +1,72 @@ +import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' +import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../../../shared/agent-launch-runtime-capability' +import type { CreateWorktreeResult } from '../../../../../../shared/worktree/create-types' +import type { ClientWorktreeCreateSettings } from './client-worktree-create-settings' +import { + buildRuntimeWorktreeCreateParams, + type CreatedWorktreeResult, + type HostAgentLaunch, + type WorktreeCreateAttempt, + type WorktreeCreateRequest +} from './worktree-create-payload' + +/** + * `worktree.create` on a paired host, the one place that decides who plans the agent launch: the + * host from its own settings, or the desktop's prebuilt command, which every host accepts. + */ +export async function createWorktreeOnRuntimeHost( + environmentId: string, + request: WorktreeCreateRequest, + attempt: WorktreeCreateAttempt, + clientCreateSettings: ClientWorktreeCreateSettings +): Promise { + const call = (launch?: HostAgentLaunch): Promise => + callRuntimeRpc( + { kind: 'environment', environmentId }, + 'worktree.create', + { + ...buildRuntimeWorktreeCreateParams(request, attempt, launch), + // Why: the host's own store defaults would otherwise override the user's choices. + ...clientCreateSettings + }, + { timeoutMs: 10 * 60_000 } + ) + const launch = request.options?.hostAgentLaunch + if (!launch || !(await hostPlansAgentLaunch(environmentId))) { + return call() + } + try { + return { ...(await call(launch)), hostPlannedStartup: true } + } catch (error) { + if (!isUnknownAgentRefusal(error)) { + throw error + } + // Why: a host older than the agent refuses its name while validating, before creating anything. + return call() + } +} + +/** A failed read-only probe keeps the desktop plan; a compat block still stops the create. */ +async function hostPlansAgentLaunch(environmentId: string): Promise { + try { + return await runtimeEnvironmentSupportsCapability( + environmentId, + AGENT_LAUNCH_RUNTIME_CAPABILITY + ) + } catch (error) { + if (isRuntimeCompatBlockError(error)) { + throw error + } + return false + } +} + +function isUnknownAgentRefusal(error: unknown): boolean { + return ( + error instanceof RuntimeRpcCallError && + error.code === 'invalid_argument' && + error.message === 'Unknown TUI agent' + ) +} diff --git a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts index 9eaaccc8068..401885934ca 100644 --- a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts +++ b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts @@ -1,5 +1,9 @@ +import type { TuiAgent } from '../../../../../../shared/tui-agent' import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../../../shared/sidebar-inputs-switch' -import type { CreateWorktreeArgs } from '../../../../../../shared/worktree/create-types' +import type { + CreateWorktreeArgs, + CreateWorktreeResult +} from '../../../../../../shared/worktree/create-types' import type { ExecutionHostId } from '../../../../../../shared/execution-host' import type { WorkspaceKey } from '../../../../../../shared/folder-workspace-types' import type { TaskSourceContext } from '../../../../../../shared/task-source-context' @@ -14,6 +18,8 @@ export type CreateWorktreeCallOptions = { linkedTaskSourceContext?: TaskSourceContext | null /** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */ startupDraft?: string + /** A terminal agent the owning host may plan from its own settings; `startup` stays the fallback. */ + hostAgentLaunch?: HostAgentLaunch setupOnly?: boolean /** True only when `name` came from the creature-name generator; gates host-side retirement. */ nameWasGenerated?: boolean @@ -28,6 +34,15 @@ export type CreateWorktreeCallOptions = { } } +export type HostAgentLaunch = { + agent: TuiAgent + prompt?: string + launchSource?: string +} + +/** `hostPlannedStartup`: the host was asked to build the agent launch, so no desktop plan may run. */ +export type CreatedWorktreeResult = CreateWorktreeResult & { hostPlannedStartup?: true } + /** Everything `createWorktree` received, packed once so both transports read from the same record. * The per-attempt fields live on `WorktreeCreateAttempt` instead. */ export type WorktreeCreateRequest = Omit & { @@ -118,7 +133,8 @@ export function buildLocalWorktreeCreateArgs( export function buildRuntimeWorktreeCreateParams( request: WorktreeCreateRequest, - attempt: WorktreeCreateAttempt + attempt: WorktreeCreateAttempt, + hostAgentLaunch?: HostAgentLaunch ): Record { const { startup, options } = request return { @@ -131,16 +147,29 @@ export function buildRuntimeWorktreeCreateParams( ? { creationId: request.creationId } : {}), ...(options?.startupDraft ? { startupDraft: options.startupDraft } : {}), - ...(startup - ? { - startupCommand: startup.command, - ...(startup.env ? { startupEnv: startup.env } : {}), - ...(startup.launchConfig ? { startupLaunchConfig: startup.launchConfig } : {}), - ...(startup.startupCommandDelivery - ? { startupCommandDelivery: startup.startupCommandDelivery } - : {}), - activate: true - } - : {}) + ...(hostAgentLaunch + ? buildHostAgentLaunchParams(hostAgentLaunch) + : startup + ? { + startupCommand: startup.command, + ...(startup.env ? { startupEnv: startup.env } : {}), + ...(startup.launchConfig ? { startupLaunchConfig: startup.launchConfig } : {}), + ...(startup.startupCommandDelivery + ? { startupCommandDelivery: startup.startupCommandDelivery } + : {}), + activate: true + } + : {}) + } +} + +// Why: the host carries `startupPrompt` in the launch line or sends it after readiness, as the +// desktop plan did, and refuses an agent it has disabled. +function buildHostAgentLaunchParams(launch: HostAgentLaunch): Record { + return { + startupAgent: launch.agent, + ...(launch.prompt ? { startupPrompt: launch.prompt } : {}), + ...(launch.launchSource ? { launchSource: launch.launchSource } : {}), + activate: true } } diff --git a/tests/e2e/paired-worktree-agent-command-owner.unit.test.ts b/tests/e2e/paired-worktree-agent-command-owner.unit.test.ts new file mode 100644 index 00000000000..d3ee4d9561d --- /dev/null +++ b/tests/e2e/paired-worktree-agent-command-owner.unit.test.ts @@ -0,0 +1,145 @@ +import { WorktreeCreate } from '../../src/shared/rpc-contract/worktree-create-params' +import { buildManagedWorktreeCreateArgs } from '../../src/main/runtime/rpc/methods/worktree-create-args' +import { resolveManagedWorktreeCreateStartup } from '../../src/main/runtime/workspaces/managed-worktree-create-startup' +import { buildWorktreeStartupForAgent } from '../../src/main/runtime/runtime-worktree-agent-startup' +import { getDefaultSettings } from '../../src/shared/constants' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createCompatibleRuntimeStatusResponse, + type RuntimeEnvironmentCallRequest +} from '../../src/renderer/src/runtime/runtime-compatibility-test-fixture' +import { + TEST_REPO, + makeWorktree +} from '../../src/renderer/src/store/slices/worktrees-slice-test-fixtures' +import { + createTestStore, + resetRemoteRuntimeMocks, + resetWorktreeSliceModuleMemory, + runtimeEnvironmentCall, + runtimeEnvironmentTransportCall +} from '../../src/renderer/src/store/slices/worktrees-slice-test-harness' + +vi.mock('sonner', () => ({ + toast: { warning: vi.fn(), info: vi.fn(), success: vi.fn(), error: vi.fn(), dismiss: vi.fn() } +})) + +beforeEach(() => { + resetWorktreeSliceModuleMemory() + vi.clearAllMocks() + resetRemoteRuntimeMocks() +}) + +describe('paired workspace agent startup ownership', () => { + it.each(['modern', 'prompt', 'older', 'probe-failure', 'predates-agent', 'custom'] as const)( + 'preserves launch command ownership for %s paired creation', + async (scenario) => { + const supported = scenario === 'modern' || scenario === 'prompt' + const status = createCompatibleRuntimeStatusResponse('runtime-remote') + if (status.ok && scenario === 'older') { + status.result.capabilities = status.result.capabilities?.filter( + (capability) => capability !== 'agent.launch.v2' + ) + } + let failedProbe = false + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method !== 'status.get') { + return runtimeEnvironmentCall(args) + } + if (scenario === 'probe-failure' && !failedProbe) { + failedProbe = true + throw new Error('temporary status read failure') + } + return status + }) + const hostSettings = { + ...getDefaultSettings('/host'), + agentCmdOverrides: { claude: '/host/claude-wrapper' }, + agentDefaultArgs: { claude: '--host-only' }, + agentDefaultEnv: { claude: { LAUNCH_OWNER: 'host' } } + } + const store = createTestStore() + store.setState({ repos: [{ ...TEST_REPO, executionHostId: 'runtime:env-1' }] }) + let launchedCommand = '' + let launchedEnv: Record | undefined + let delivered = '' + runtimeEnvironmentCall.mockImplementation(async (call) => { + expect(call.method).toBe('worktree.create') + if (scenario === 'predates-agent' && call.params && 'startupAgent' in call.params) { + // What a host older than the agent answers while validating its name. + return { + id: 'host-create', + ok: false, + error: { code: 'invalid_argument', message: 'Unknown TUI agent' }, + _meta: { runtimeId: 'runtime-remote' } + } + } + const params = WorktreeCreate.parse(call.params) + const args = buildManagedWorktreeCreateArgs(params, {}) + const resolved = await resolveManagedWorktreeCreateStartup(args, [], { + agent: (agent, prompt) => + buildWorktreeStartupForAgent({ + agent, + prompt, + repo: TEST_REPO, + settings: hostSettings, + getLaunchRuntime: () => ({ + platform: 'linux', + shell: 'posix', + launchRuntime: { + platform: 'linux', + pty: { shellOverride: undefined, terminalWindowsWslDistro: null } + } + }), + toSessionOptions: () => undefined + }), + draft: async () => null + }) + delivered = [resolved.effectiveStartup?.command, resolved.effectiveStartupFollowup?.prompt] + .filter(Boolean) + .join('\n') + launchedCommand = resolved.effectiveStartup?.command ?? '' + launchedEnv = resolved.effectiveStartup?.env + return { + id: 'host-create', + ok: true, + result: { + worktree: makeWorktree({ id: 'repo1::/path/host-agent', repoId: 'repo1' }), + startupTerminal: { spawned: true, tabId: 'host-tab' } + }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const createWorktree = store.getState().createWorktree + const args: Parameters = ['repo1', 'host-agent'] + args[10] = 'claude' + args[16] = { + command: '/client/claude --client-only', + env: { LAUNCH_OWNER: 'client' }, + launchAgent: 'claude' + } + args[25] = { + ...(scenario === 'custom' + ? {} + : { + hostAgentLaunch: { + agent: 'claude' as const, + ...(scenario === 'prompt' ? { prompt: 'prompt-text' } : {}) + } + }), + executionHostId: 'runtime:env-1' + } + + await createWorktree(...args) + + expect(launchedCommand).toContain(supported ? '/host/claude-wrapper' : '/client/claude') + expect(launchedCommand).toContain(supported ? '--host-only' : '--client-only') + expect(launchedEnv?.LAUNCH_OWNER).toBe(supported ? 'host' : 'client') + if (scenario === 'prompt') { + // Exactly once, through whichever channel the host's planner picked. + expect(delivered.split('prompt-text')).toHaveLength(2) + } + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(scenario === 'predates-agent' ? 2 : 1) + } + ) +})