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.
This commit is contained in:
Neil
2026-10-24 15:01:29 -07:00
committed by GitHub
parent 293bfe795d
commit 2679464ead
9 changed files with 472 additions and 50 deletions
@@ -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<void> {
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,
@@ -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
})
}
@@ -0,0 +1,114 @@
import { beforeEach, expect, it, vi } from 'vitest'
import type {
PendingWorktreeCreation,
WorktreeCreationRequest
} from '@/lib/pending-worktree-creation'
const pendingWorktreeCreations: Record<string, PendingWorktreeCreation> = {}
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<unknown>>(),
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."
)
})
@@ -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 }
}
@@ -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<CreateWorktreeResult>
) => Promise<CreatedWorktreeResult>
/** 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. */
@@ -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<CreateWorktreeResult> =>
): Promise<CreatedWorktreeResult> =>
provisionedRoot
? target.kind === 'environment'
? await adoptRuntimeProvisionedRoot(
@@ -79,15 +76,11 @@ async function runCreateAttempt(
await window.api.worktrees.create(
buildLocalWorktreeCreateArgs(request, { ...attempt, parentWorkspace })
)
: await callRuntimeRpc<CreateWorktreeResult>(
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 }
@@ -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<CreatedWorktreeResult> {
const call = (launch?: HostAgentLaunch): Promise<CreateWorktreeResult> =>
callRuntimeRpc<CreateWorktreeResult>(
{ 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<boolean> {
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'
)
}
@@ -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<CreateWorktreeArgs, 'parentWorkspace' | 'manualOrder'> & {
@@ -118,7 +133,8 @@ export function buildLocalWorktreeCreateArgs(
export function buildRuntimeWorktreeCreateParams(
request: WorktreeCreateRequest,
attempt: WorktreeCreateAttempt
attempt: WorktreeCreateAttempt,
hostAgentLaunch?: HostAgentLaunch
): Record<string, unknown> {
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<string, unknown> {
return {
startupAgent: launch.agent,
...(launch.prompt ? { startupPrompt: launch.prompt } : {}),
...(launch.launchSource ? { launchSource: launch.launchSource } : {}),
activate: true
}
}
@@ -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<string, string> | 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<typeof createWorktree> = ['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)
}
)
})