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) + } + ) +})