From fda448740e28beb6f5e5988b31a19728a5fc3ebb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:21:43 -0700 Subject: [PATCH] Support custom agents with host-aware launch recovery routing - Custom agents route through agentLaunch contract for dynamic args/env instead of legacy fields - Mobile UI projects available custom agents based on detected base harnesses - SSH Windows hosts resolve default shell for correct startup command quoting - Capability probe errors now distinguish permanent (old relay method-not-found) from transient (connection) failures - Background launch recovery routes to the execution host where the attempt occurred, not the repo owner - Kimi agent removed from resumable agent list --- .../app/h/[hostId]/session/[worktreeId].tsx | 35 ++-- mobile/app/h/[hostId]/tasks.tsx | 98 +++++----- ...ew-worktree-agent-identity-support.test.ts | 60 ++++++ .../new-worktree-agent-identity-support.ts | 23 +++ .../use-new-workspace-create-submit.ts | 18 +- .../mobile-new-tab-agent-loader.test.ts | 43 +++++ .../session/mobile-new-tab-agent-loader.ts | 7 +- .../mobile-new-tab-agent-options.test.ts | 49 +++++ .../session/mobile-new-tab-agent-options.ts | 39 ++-- .../mobile-new-tab-create-fields.test.ts | 34 ++++ .../session/mobile-new-tab-create-fields.ts | 24 +++ .../mobile-new-tab-create-result.test.ts | 31 ++++ .../session/mobile-new-tab-create-result.ts | 24 +++ .../task-workspace-agent-catalog.test.ts | 37 ++++ .../src/tasks/task-workspace-agent-catalog.ts | 40 ++++ .../task-workspace-create-result.test.ts | 34 ++++ .../src/tasks/task-workspace-create-result.ts | 30 +++ .../tasks/workspace-agent-selection.test.ts | 3 + mobile/src/tasks/workspace-agent-selection.ts | 9 +- .../src/tasks/workspace-create-params.test.ts | 30 +++ mobile/src/tasks/workspace-create-params.ts | 15 +- .../transport/host-client-context-contract.ts | 1 + .../agent-launch-host-state.test.ts | 172 +++++++++++++++--- .../agent-launch/agent-launch-host-state.ts | 51 +++++- .../agent-launch-worktree-resolution.test.ts | 127 +++++++++++-- .../agent-launch-worktree-resolution.ts | 12 +- .../service-production-wiring.test.ts | 21 +++ src/main/ipc/preflight-remote-ssh.test.ts | 29 +++ ...wn-agent-resume-shell-and-delivery.test.ts | 43 +++++ src/main/ipc/pty/ipc/spawn-agent-launch.ts | 12 +- .../worktrees-ssh-local-base-refresh.test.ts | 6 +- src/main/preflight/agent-detection.ts | 10 +- .../ssh-agent-session-capabilities.ts | 28 +-- .../ssh-agent-session-capability-probe.ts | 7 + .../ssh-agent-session-claim-validation.ts | 19 +- .../ssh-agent-session-create-operation.ts | 14 +- .../ssh-launch-token-echo-capability.ts | 13 +- ...der-agent-session-create-operation.test.ts | 6 +- ...ssh-pty-provider-launch-token-echo.test.ts | 41 ++++- src/main/providers/ssh-pty-provider.test.ts | 18 ++ src/main/providers/ssh-pty-spawn-dispatch.ts | 5 +- ...runtime-automation-launch-classify.test.ts | 76 +++++++- .../terminal-agent-launch-resolution.test.ts | 4 +- .../terminal-agent-launch-resolution.ts | 26 ++- .../src/components/sidebar/WorktreeCard.tsx | 5 +- ...ktreeCardBackgroundLaunchFailures.test.tsx | 85 ++++++--- .../WorktreeCardBackgroundLaunchFailures.tsx | 44 +++-- .../fresh-spawn-agent-launch-result.ts | 54 ++++++ .../pty-connection/fresh-spawn-start.ts | 61 +------ .../src/store/slices/worktree-helpers.ts | 4 + ...-agent-launch-actions-host-routing.test.ts | 53 ++++++ .../worktree-agent-launch-actions.ts | 55 ++++-- src/shared/agent-session-resume.test.ts | 5 + src/shared/agent-session-resume.ts | 6 +- 54 files changed, 1492 insertions(+), 304 deletions(-) create mode 100644 mobile/src/components/new-worktree-agent-identity-support.test.ts create mode 100644 mobile/src/components/new-worktree-agent-identity-support.ts create mode 100644 mobile/src/session/mobile-new-tab-create-fields.test.ts create mode 100644 mobile/src/session/mobile-new-tab-create-fields.ts create mode 100644 mobile/src/session/mobile-new-tab-create-result.test.ts create mode 100644 mobile/src/session/mobile-new-tab-create-result.ts create mode 100644 mobile/src/tasks/task-workspace-agent-catalog.test.ts create mode 100644 mobile/src/tasks/task-workspace-agent-catalog.ts create mode 100644 mobile/src/tasks/task-workspace-create-result.test.ts create mode 100644 mobile/src/tasks/task-workspace-create-result.ts create mode 100644 src/main/automations/service-production-wiring.test.ts create mode 100644 src/main/providers/ssh-agent-session-capability-probe.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-agent-launch-result.ts create mode 100644 src/renderer/src/store/slices/worktrees/worktree-agent-launch-actions-host-routing.test.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 5fe65e7e7aa..6394c139282 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -153,6 +153,7 @@ import { normalizeBrowserUrl } from '../../../../src/browser/browser-url' import { StatusDot } from '../../../../src/components/StatusDot' import { ActionSheetModal } from '../../../../src/components/ActionSheetModal' import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon' +import { useAgentCatalogSnapshot } from '../../../../src/components/use-agent-catalog-snapshot' import { TextInputModal } from '../../../../src/components/TextInputModal' import { ConfirmModal } from '../../../../src/components/ConfirmModal' import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader' @@ -189,6 +190,8 @@ import { } from '../../../../src/session/mobile-terminal-tab-agent' import type { MobileNewTabAgentOption } from '../../../../src/session/mobile-new-tab-agent-options' import { loadMobileNewTabAgentOptions } from '../../../../src/session/mobile-new-tab-agent-loader' +import { buildMobileNewTabCreateFields } from '../../../../src/session/mobile-new-tab-create-fields' +import { readMobileNewTabCreatedTerminal } from '../../../../src/session/mobile-new-tab-create-result' import { useMobileSessionImageAttachments } from '../../../../src/session/use-mobile-session-image-attachments' import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate' import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste' @@ -302,7 +305,6 @@ import type { RuntimeRepoSummary, SessionTabsResult, Terminal, - TerminalCreateResult, TerminalGestureInputBucket, TerminalGestureInputQueue } from '../../../../src/session/mobile-session-route-types' @@ -599,6 +601,7 @@ export default function SessionScreen() { const insets = useSafeAreaInsets() // Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md). const { client, state: connState } = useHostClient(hostId) + const agentCatalog = useAgentCatalogSnapshot(hostId) const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) const relayRecovery = useRelayRecoveryStatus(hostId) @@ -3555,7 +3558,8 @@ export default function SessionScreen() { void (async () => { const options = await loadMobileNewTabAgentOptions({ client, - worktreeId + worktreeId, + catalogSnapshot: agentCatalog }) if (stale) { return @@ -3572,7 +3576,7 @@ export default function SessionScreen() { return () => { stale = true } - }, [client, connState, pendingDiffNotesDelivery, showCreateTabDrawer, worktreeId]) + }, [agentCatalog, client, connState, pendingDiffNotesDelivery, showCreateTabDrawer, worktreeId]) async function handleCreateTerminal( agent?: MobileNewTabAgentOption['agent'], @@ -3599,19 +3603,18 @@ export default function SessionScreen() { worktree: `id:${worktreeId}`, afterTabId: activeSessionTabId ?? undefined, clientMutationId, - ...(options?.startupCommand ? { command: options.startupCommand } : {}), - ...(options?.startupCommandDelivery - ? { startupCommandDelivery: options.startupCommandDelivery } - : {}), - ...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}), - ...(agent ? { agent } : {}), + ...buildMobileNewTabCreateFields({ + agent, + agentPrompt: options?.agentPrompt, + startupCommand: options?.startupCommand, + startupCommandDelivery: options?.startupCommandDelivery + }), activate: false, select: true, navigation: 'caller' }) if (response.ok) { - const result = (response as RpcSuccess).result as TerminalCreateResult - const created = result.tab + const created = readMobileNewTabCreatedTerminal((response as RpcSuccess).result) // Why: unsubscribe the old terminal so the server restores its desktop dims; otherwise its restore timer is never set. const prev = activeHandleRef.current if (prev) { @@ -3705,16 +3708,18 @@ export default function SessionScreen() { setCreateError(message) if (options?.errorToast) { triggerError() - showToast(message, 1800) } + showToast(message, 1800) } - } catch { - const message = options?.errorToast ?? 'Failed to create terminal' + } catch (error) { + const message = + options?.errorToast ?? + (error instanceof Error ? error.message : 'Failed to create terminal') setCreateError(message) if (options?.errorToast) { triggerError() - showToast(message, 1800) } + showToast(message, 1800) } finally { creatingTerminalRef.current = false setCreating(false) diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index a306ca21c58..f9cb09cb8ad 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -47,6 +47,7 @@ import { BottomDrawer } from '../../../src/components/BottomDrawer' import { ConfirmModal } from '../../../src/components/ConfirmModal' import { MobileMarkdown } from '../../../src/components/MobileMarkdown' import { MobileAgentIcon } from '../../../src/components/MobileAgentIcon' +import { useAgentCatalogSnapshot } from '../../../src/components/use-agent-catalog-snapshot' import { MobileWorkspaceNameInput } from '../../../src/components/MobileWorkspaceNameInput' import { MobileSearchField } from '../../../src/components/MobileSearchField' import { MobileSyntaxSegments } from '../../../src/components/MobileSyntaxSegments' @@ -69,15 +70,17 @@ import { getHostedChecksLabel } from '../../../src/tasks/mobile-hosted-check-status' import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params' +import { readTaskWorkspaceCreatedResult } from '../../../src/tasks/task-workspace-create-result' import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability' import { - filterWorkspaceAgents, isWorkspaceAgentEnabled, + normalizeWorkspaceAgent, pickWorkspaceAgent, resolveWorkspaceAgentSelection, workspaceAgentLabel, type WorkspaceAgentChoice } from '../../../src/tasks/workspace-agent-selection' +import { buildTaskWorkspaceAgentCatalog } from '../../../src/tasks/task-workspace-agent-catalog' import { shouldResolveHostedReviewStartPoint } from '../../../src/tasks/hosted-review-start-point' import { getLinkedWorkItemSuggestedName } from '../../../src/tasks/mobile-workspace-name' import { @@ -143,7 +146,6 @@ import { extractLinearIssueReadItems, type LinearMobileIssue } from '../../../src/tasks/linear-mobile-issue-read' -import { MOBILE_TUI_AGENT_AUTO_PICK_ORDER } from '../../../src/tasks/mobile-tui-agents' import { resolveComposerBranchSelection } from '../../../src/tasks/mobile-composer-branch-selection' import { clearMobileTaskCopyFeedbackTimer, @@ -2072,6 +2074,7 @@ export default function MobileTasksScreen() { const router = useRouter() const insets = useSafeAreaInsets() const { client, state: connState } = useHostClient(hostId) + const agentCatalog = useAgentCatalogSnapshot(hostId) const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) const relayRecovery = useRelayRecoveryStatus(hostId) @@ -4826,30 +4829,22 @@ export default function MobileTasksScreen() { !workspaceSparseDraftError && workspaceSparseDraftParsed !== null - const workspaceAgentOptions = useMemo[]>(() => { - const enabledAgents = filterWorkspaceAgents( - MOBILE_TUI_AGENT_AUTO_PICK_ORDER, - runtimeTaskSettings.disabledTuiAgents - ) - const availableAgents = - workspaceDetectedAgentIds === null - ? new Set(enabledAgents) - : new Set(enabledAgents.filter((agent) => workspaceDetectedAgentIds.has(agent))) - if ( - workspaceAgent && - workspaceAgent !== 'blank' && - isWorkspaceAgentEnabled(workspaceAgent, runtimeTaskSettings.disabledTuiAgents) && - (workspaceDetectedAgentIds === null || workspaceDetectedAgentIds.has(workspaceAgent)) - ) { - availableAgents.add(workspaceAgent) - } - const agents = MOBILE_TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => availableAgents.has(agent)) - return [ - ...agents.map((agent) => ({ - value: agent, - label: workspaceAgentLabel(agent), - subtitle: agent, - renderIcon: () => + const workspaceAgentCatalog = useMemo( + () => + buildTaskWorkspaceAgentCatalog( + agentCatalog, + workspaceDetectedAgentIds, + runtimeTaskSettings.disabledTuiAgents + ), + [agentCatalog, runtimeTaskSettings.disabledTuiAgents, workspaceDetectedAgentIds] + ) + const workspaceAgentOptions = useMemo[]>( + () => [ + ...workspaceAgentCatalog.rows.map((row) => ({ + value: row.id, + label: row.label, + subtitle: row.isCustom ? `Custom ${workspaceAgentLabel(row.baseAgent!)}` : row.id, + renderIcon: () => })), { value: 'blank' as const, @@ -4857,8 +4852,9 @@ export default function MobileTasksScreen() { subtitle: 'Open a shell', renderIcon: () => } - ] - }, [runtimeTaskSettings.disabledTuiAgents, workspaceAgent, workspaceDetectedAgentIds]) + ], + [workspaceAgentCatalog.rows] + ) const openWorkspaceCreate = useCallback((item: ActionableTaskItem, repoIdOverride?: string) => { const suggestedName = taskWorkspaceSuggestedName(item) setWorkspaceCreateDraft({ item, ...(repoIdOverride ? { repoIdOverride } : {}) }) @@ -5323,6 +5319,7 @@ export default function MobileTasksScreen() { selectionActive: tasksSupported && workspaceCreateDraft !== null, settings: runtimeTaskSettings, detectedAgentIds: workspaceDetectedAgentIds, + customAgentBases: workspaceAgentCatalog.customAgentBases, agent: workspaceAgent, overridden: workspaceAgentOverridden }) @@ -5336,10 +5333,13 @@ export default function MobileTasksScreen() { setWorkspaceAgentOverridden(workspaceAgentSelection.overridden) } - const resolvedWorkspaceAgent = useMemo( - () => workspaceAgent ?? pickWorkspaceAgent(runtimeTaskSettings, workspaceDetectedAgentIds), - [runtimeTaskSettings, workspaceAgent, workspaceDetectedAgentIds] - ) + const resolvedWorkspaceAgent = + workspaceAgent ?? + pickWorkspaceAgent( + runtimeTaskSettings, + workspaceDetectedAgentIds, + workspaceAgentCatalog.customAgentBases + ) const workspaceAgentDetectionPending = workspaceCreateDraft != null && workspaceCreateTargetRepo != null && @@ -5430,17 +5430,20 @@ export default function MobileTasksScreen() { } catch { // Best-effort refresh; the runtime still validates agent availability before spawning. } - const selectedAgent = - agentOverride && - (agentOverride === 'blank' || + const agentOverrideAvailable = + agentOverride === 'blank' || + (agentOverride !== undefined && + normalizeWorkspaceAgent(agentOverride, workspaceAgentCatalog.customAgentBases) === + agentOverride && isWorkspaceAgentEnabled(agentOverride, latestRuntimeTaskSettings.disabledTuiAgents)) - ? agentOverride - : pickWorkspaceAgent(latestRuntimeTaskSettings, workspaceDetectedAgentIds) - if ( - agentOverride && - agentOverride !== 'blank' && - !isWorkspaceAgentEnabled(agentOverride, latestRuntimeTaskSettings.disabledTuiAgents) - ) { + const selectedAgent = agentOverrideAvailable + ? agentOverride! + : pickWorkspaceAgent( + latestRuntimeTaskSettings, + workspaceDetectedAgentIds, + workspaceAgentCatalog.customAgentBases + ) + if (agentOverride && agentOverride !== 'blank' && !agentOverrideAvailable) { setWorkspaceAgent(selectedAgent) setWorkspaceAgentOverridden(false) throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') @@ -5606,10 +5609,7 @@ export default function MobileTasksScreen() { if (!isSuccess(response)) { throw new Error(response.error.message) } - const result = response.result as { - worktree: { id: string; displayName?: string } - warning?: string - } + const result = readTaskWorkspaceCreatedResult(response.result) setActionItem(null) setWorkspaceCreateDraft(null) setSetupPrompt(null) @@ -5638,6 +5638,7 @@ export default function MobileTasksScreen() { taskStateHydrated, tasksSupported, trustedOrcaHooks, + workspaceAgentCatalog.customAgentBases, workspaceDetectedAgentIds ] ) @@ -11034,7 +11035,10 @@ export default function MobileTasksScreen() { ? 'Connect repository first' : workspaceAgentDetectionPending ? 'Detecting agents...' - : workspaceAgentLabel(resolvedWorkspaceAgent)} + : workspaceAgentLabel( + resolvedWorkspaceAgent, + workspaceAgentCatalog.customAgentLabels + )} diff --git a/mobile/src/components/new-worktree-agent-identity-support.test.ts b/mobile/src/components/new-worktree-agent-identity-support.test.ts new file mode 100644 index 00000000000..ecf0550005d --- /dev/null +++ b/mobile/src/components/new-worktree-agent-identity-support.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentCatalogSnapshot } from '../../../src/shared/agent-catalog-snapshot' +import type { RpcClient } from '../transport/rpc-client' +import { newWorktreeAgentOptionFor } from './new-worktree-agent-selection' +import { resolveNewWorktreeAgentIdentitySupport } from './new-worktree-agent-identity-support' + +const catalog: AgentCatalogSnapshot = { + version: 1, + revision: 1, + defaultAgent: 'auto', + disabledAgents: [], + customAgents: [ + { + id: 'custom-agent:claude:one', + baseAgent: 'claude', + label: 'My Claude', + args: '', + syncEnv: false, + status: 'ready', + envState: 'none', + availabilityCheck: 'baseline-detection' + } + ], + deletedCustomAgents: [] +} + +describe('new worktree agent identity support', () => { + it('keeps a catalog-backed custom on identity launch after a transient status failure', async () => { + const client = { sendRequest: vi.fn().mockRejectedValue(new Error('temporary')) } + await expect( + resolveNewWorktreeAgentIdentitySupport({ + client: client as unknown as RpcClient, + selectedAgent: newWorktreeAgentOptionFor('custom-agent:claude:one', catalog), + catalogSnapshot: catalog + }) + ).resolves.toBe(true) + }) + + it('does not infer identity support for a built-in from the custom catalog', async () => { + const client = { sendRequest: vi.fn().mockRejectedValue(new Error('temporary')) } + await expect( + resolveNewWorktreeAgentIdentitySupport({ + client: client as unknown as RpcClient, + selectedAgent: newWorktreeAgentOptionFor('claude'), + catalogSnapshot: catalog + }) + ).resolves.toBe(false) + }) + + it('honors an explicit status response that lacks identity support', async () => { + const client = { sendRequest: vi.fn().mockResolvedValue({ ok: true, result: {} }) } + await expect( + resolveNewWorktreeAgentIdentitySupport({ + client: client as unknown as RpcClient, + selectedAgent: newWorktreeAgentOptionFor('custom-agent:claude:one', catalog), + catalogSnapshot: catalog + }) + ).resolves.toBe(false) + }) +}) diff --git a/mobile/src/components/new-worktree-agent-identity-support.ts b/mobile/src/components/new-worktree-agent-identity-support.ts new file mode 100644 index 00000000000..6f03792caac --- /dev/null +++ b/mobile/src/components/new-worktree-agent-identity-support.ts @@ -0,0 +1,23 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import type { AgentCatalogValue } from '../transport/agent-catalog-sync' +import { hostSupportsAgentLaunchIdentity } from '../session/agent-launch-identity-capability' +import { buildMobileAgentPickerRows } from '../tasks/mobile-agent-catalog-projection' +import type { NewWorktreeAgentOption } from './new-worktree-agent-selection' + +export async function resolveNewWorktreeAgentIdentitySupport(args: { + client: Pick + selectedAgent: NewWorktreeAgentOption + catalogSnapshot: AgentCatalogValue | null +}): Promise { + const status = await args.client.sendRequest('status.get').catch(() => null) + if (status?.ok) { + return hostSupportsAgentLaunchIdentity((status as RpcSuccess).result) + } + if (!args.selectedAgent.isCustom) { + return false + } + return buildMobileAgentPickerRows(args.catalogSnapshot, { includeCustomAgents: true }).some( + (row) => row.isCustom && row.id === args.selectedAgent.id + ) +} diff --git a/mobile/src/components/use-new-workspace-create-submit.ts b/mobile/src/components/use-new-workspace-create-submit.ts index 945c528c87c..05b64686a87 100644 --- a/mobile/src/components/use-new-workspace-create-submit.ts +++ b/mobile/src/components/use-new-workspace-create-submit.ts @@ -27,8 +27,8 @@ import type { MobileWorkspaceRepo, SetupRunPolicy } from './new-worktree-modal-t import type { SetupTrustPrompt } from './SetupHookTrustDrawer' import type { NewWorktreeDrawerView } from './use-new-worktree-drawer-navigation' import { getSuggestedCreatureName } from './worktree-name-suggestion' -import { hostSupportsAgentLaunchIdentity } from '../session/agent-launch-identity-capability' import { buildInteractiveLaunchParams } from './interactive-worktree-launch-params' +import { resolveNewWorktreeAgentIdentitySupport } from './new-worktree-agent-identity-support' type CreateOptions = { setupOverride?: Exclude @@ -106,17 +106,11 @@ export function useNewWorkspaceCreateSubmit(args: { } catch { // The runtime validates the same setting before spawning. } - let hasIdentityCapability = false - try { - const statusResponse = await client.sendRequest('status.get') - if (statusResponse.ok) { - hasIdentityCapability = hostSupportsAgentLaunchIdentity( - (statusResponse as RpcSuccess).result - ) - } - } catch { - // Best-effort probe; older hosts keep the legacy launch path. - } + const hasIdentityCapability = await resolveNewWorktreeAgentIdentitySupport({ + client, + selectedAgent: args.selectedAgent, + catalogSnapshot: args.agentCatalog + }) if ( args.selectedAgent.id !== '__blank__' && !isMobileTuiAgentEnabled(args.selectedAgent.id, latestRuntimeSettings?.disabledTuiAgents) diff --git a/mobile/src/session/mobile-new-tab-agent-loader.test.ts b/mobile/src/session/mobile-new-tab-agent-loader.test.ts index 9f51e7fd878..84b20efa88e 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.test.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace' import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader' +import type { AgentCatalogSnapshot } from '../../../src/shared/agent-catalog-snapshot' function createClient( handler: (method: string, params?: unknown) => Promise @@ -73,4 +74,46 @@ describe('mobile new-tab agent loading', () => { 'preflight.detectRemoteAgents' ]) }) + + it('projects custom agents from the already-synced host catalog', async () => { + const catalogSnapshot: AgentCatalogSnapshot = { + version: 1, + revision: 2, + defaultAgent: 'auto', + disabledAgents: [], + customAgents: [ + { + id: 'custom-agent:claude:one', + baseAgent: 'claude', + label: 'My Claude', + args: '', + syncEnv: false, + status: 'ready', + envState: 'none', + availabilityCheck: 'baseline-detection' + } + ], + deletedCustomAgents: [] + } + const client = createClient(async (method) => { + if (method === 'settings.get') { + return { ok: true, result: { settings: {} } } + } + if (method === 'preflight.detectAgents') { + return { ok: true, result: ['claude'] } + } + throw new Error(`unexpected request: ${method}`) + }) + + await expect( + loadMobileNewTabAgentOptions({ + client, + worktreeId: FLOATING_WORKSPACE_WORKTREE_ID, + catalogSnapshot + }) + ).resolves.toEqual([ + { agent: 'claude', label: 'Claude' }, + { agent: 'custom-agent:claude:one', label: 'My Claude' } + ]) + }) }) diff --git a/mobile/src/session/mobile-new-tab-agent-loader.ts b/mobile/src/session/mobile-new-tab-agent-loader.ts index 12e443b3b13..90a13241d90 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.ts @@ -1,5 +1,6 @@ import type { RpcClient } from '../transport/rpc-client' import type { RpcFailure, RpcSuccess } from '../transport/types' +import type { AgentCatalogValue } from '../transport/agent-catalog-sync' import { isFloatingWorkspaceWorktreeId } from './floating-workspace' import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers' import { @@ -16,8 +17,9 @@ type RuntimeRepoSummary = { export async function loadMobileNewTabAgentOptions(args: { client: RpcClient worktreeId: string + catalogSnapshot?: AgentCatalogValue | null }): Promise { - const { client, worktreeId } = args + const { client, worktreeId, catalogSnapshot = null } = args // Why: the floating workspace runs on the paired host, so it has no repo connection to resolve. const detectedAgentsRequest = isFloatingWorkspaceWorktreeId(worktreeId) ? client.sendRequest('preflight.detectAgents') @@ -40,7 +42,8 @@ export async function loadMobileNewTabAgentOptions(args: { ).settings return buildMobileNewTabAgentOptions( settings, - (detectedResponse as RpcSuccess).result as unknown[] + (detectedResponse as RpcSuccess).result as unknown[], + catalogSnapshot ) } diff --git a/mobile/src/session/mobile-new-tab-agent-options.test.ts b/mobile/src/session/mobile-new-tab-agent-options.test.ts index 5de44caa942..81068cdd408 100644 --- a/mobile/src/session/mobile-new-tab-agent-options.test.ts +++ b/mobile/src/session/mobile-new-tab-agent-options.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import type { AgentCatalogSnapshot } from '../../../src/shared/agent-catalog-snapshot' import { buildMobileNewTabAgentOptions, @@ -6,6 +7,26 @@ import { } from './mobile-new-tab-agent-options' describe('mobile new-tab agent options', () => { + const catalog: AgentCatalogSnapshot = { + version: 1, + revision: 1, + defaultAgent: 'auto', + disabledAgents: [], + customAgents: [ + { + id: 'custom-agent:claude:one', + baseAgent: 'claude', + label: 'My Claude', + args: '', + syncEnv: false, + status: 'ready', + envState: 'none', + availabilityCheck: 'baseline-detection' + } + ], + deletedCustomAgents: [] + } + it('orders the enabled detected default first', () => { expect(orderMobileNewTabAgents('codex', ['gemini', 'codex', 'claude'], ['gemini'])).toEqual([ 'codex', @@ -26,4 +47,32 @@ describe('mobile new-tab agent options', () => { it('does not show stale presets while detection is pending', () => { expect(buildMobileNewTabAgentOptions({ defaultTuiAgent: 'codex' }, null)).toEqual([]) }) + + it('includes catalog-backed custom agents when their base is detected', () => { + expect(buildMobileNewTabAgentOptions({}, ['claude', 'codex'], catalog)).toEqual([ + { agent: 'claude', label: 'Claude' }, + { agent: 'custom-agent:claude:one', label: 'My Claude' }, + { agent: 'codex', label: 'Codex' } + ]) + }) + + it('orders an available custom default first', () => { + expect( + buildMobileNewTabAgentOptions( + { defaultTuiAgent: 'custom-agent:claude:one' }, + ['claude', 'codex'], + catalog + ) + ).toEqual([ + { agent: 'custom-agent:claude:one', label: 'My Claude' }, + { agent: 'claude', label: 'Claude' }, + { agent: 'codex', label: 'Codex' } + ]) + }) + + it('hides a custom agent when its base is not detected', () => { + expect(buildMobileNewTabAgentOptions({}, ['codex'], catalog)).toEqual([ + { agent: 'codex', label: 'Codex' } + ]) + }) }) diff --git a/mobile/src/session/mobile-new-tab-agent-options.ts b/mobile/src/session/mobile-new-tab-agent-options.ts index b3b57684043..94ffb590727 100644 --- a/mobile/src/session/mobile-new-tab-agent-options.ts +++ b/mobile/src/session/mobile-new-tab-agent-options.ts @@ -1,9 +1,10 @@ import type { BuiltInTuiAgent, TuiAgent } from '../../../src/shared/types' +import type { AgentCatalogValue } from '../transport/agent-catalog-sync' +import { buildMobileAgentPickerRows } from '../tasks/mobile-agent-catalog-projection' import { filterEnabledMobileTuiAgents, isMobileTuiAgent, - MOBILE_TUI_AGENT_AUTO_PICK_ORDER, - MOBILE_TUI_AGENT_LABELS + MOBILE_TUI_AGENT_AUTO_PICK_ORDER } from '../tasks/mobile-tui-agents' export type MobileNewTabAgentSettings = { @@ -40,17 +41,33 @@ export function orderMobileNewTabAgents( export function buildMobileNewTabAgentOptions( settings: MobileNewTabAgentSettings | null | undefined, - detectedAgentIds: Iterable | null + detectedAgentIds: Iterable | null, + catalogSnapshot: AgentCatalogValue | null = null ): MobileNewTabAgentOption[] { if (!detectedAgentIds) { return [] } - return orderMobileNewTabAgents( - settings?.defaultTuiAgent, - detectedAgentIds, - settings?.disabledTuiAgents - ).map((agent) => ({ - agent, - label: MOBILE_TUI_AGENT_LABELS[agent] - })) + const detected = new Set([...detectedAgentIds].filter(isMobileTuiAgent)) + const options = buildMobileAgentPickerRows(catalogSnapshot, { + includeCustomAgents: true + }) + .filter((row) => { + if (row.isCustom) { + return row.baseAgent !== undefined && detected.has(row.baseAgent) + } + return ( + detected.has(row.id as BuiltInTuiAgent) && + filterEnabledMobileTuiAgents([row.id], settings?.disabledTuiAgents).length > 0 + ) + }) + .map((row) => ({ agent: row.id, label: row.label })) + const preferred = settings?.defaultTuiAgent + if (!preferred || preferred === 'blank') { + return options + } + const preferredIndex = options.findIndex((option) => option.agent === preferred) + if (preferredIndex <= 0) { + return options + } + return [options[preferredIndex]!, ...options.filter((_, index) => index !== preferredIndex)] } diff --git a/mobile/src/session/mobile-new-tab-create-fields.test.ts b/mobile/src/session/mobile-new-tab-create-fields.test.ts new file mode 100644 index 00000000000..e9d065b9b7e --- /dev/null +++ b/mobile/src/session/mobile-new-tab-create-fields.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import type { TuiAgent } from '../../../src/shared/tui-agent' +import { buildMobileNewTabCreateFields } from './mobile-new-tab-create-fields' + +const customAgent = 'custom-agent:claude:0f8b7c6a-1d2e-4a3b-9c4d-5e6f7a8b9c0d' as TuiAgent + +describe('buildMobileNewTabCreateFields', () => { + it('routes a custom picker row through the custom-admitting agentLaunch field', () => { + expect(buildMobileNewTabCreateFields({ agent: customAgent })).toEqual({ + agentLaunch: { + selection: { kind: 'agent', agent: customAgent }, + allowEmptyPromptLaunch: true + } + }) + }) + + it('puts a custom agent prompt inside the atomic launch request', () => { + expect(buildMobileNewTabCreateFields({ agent: customAgent, agentPrompt: 'Review it' })).toEqual( + { + agentLaunch: { + selection: { kind: 'agent', agent: customAgent }, + prompt: 'Review it' + } + } + ) + }) + + it('preserves the legacy fields for built-in agents', () => { + expect(buildMobileNewTabCreateFields({ agent: 'codex', agentPrompt: 'Review it' })).toEqual({ + agent: 'codex', + agentPrompt: 'Review it' + }) + }) +}) diff --git a/mobile/src/session/mobile-new-tab-create-fields.ts b/mobile/src/session/mobile-new-tab-create-fields.ts new file mode 100644 index 00000000000..8268fea2c36 --- /dev/null +++ b/mobile/src/session/mobile-new-tab-create-fields.ts @@ -0,0 +1,24 @@ +import { isCustomTuiAgentId } from '../../../src/shared/custom-tui-agent-identity' +import type { TuiAgent } from '../../../src/shared/tui-agent' + +export function buildMobileNewTabCreateFields(args: { + agent?: TuiAgent + agentPrompt?: string + startupCommand?: string + startupCommandDelivery?: 'shell-ready' +}): Record { + if (args.agent && isCustomTuiAgentId(args.agent)) { + return { + agentLaunch: { + selection: { kind: 'agent', agent: args.agent }, + ...(args.agentPrompt ? { prompt: args.agentPrompt } : { allowEmptyPromptLaunch: true }) + } + } + } + return { + ...(args.startupCommand ? { command: args.startupCommand } : {}), + ...(args.startupCommandDelivery ? { startupCommandDelivery: args.startupCommandDelivery } : {}), + ...(args.agentPrompt ? { agentPrompt: args.agentPrompt } : {}), + ...(args.agent ? { agent: args.agent } : {}) + } +} diff --git a/mobile/src/session/mobile-new-tab-create-result.test.ts b/mobile/src/session/mobile-new-tab-create-result.test.ts new file mode 100644 index 00000000000..38f3e7e7cc4 --- /dev/null +++ b/mobile/src/session/mobile-new-tab-create-result.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { readMobileNewTabCreatedTerminal } from './mobile-new-tab-create-result' + +describe('readMobileNewTabCreatedTerminal', () => { + it('returns a created terminal tab', () => { + const tab = { id: 'tab-1', type: 'terminal', title: 'Agent', terminal: 'pty-1' } as const + expect(readMobileNewTabCreatedTerminal({ tab })).toBe(tab) + }) + + it('surfaces a disabled custom agent from the success-without-tab envelope', () => { + expect(() => + readMobileNewTabCreatedTerminal({ + agentLaunch: { + status: 'failed', + failure: { code: 'custom_agent_disabled' } + } + }) + ).toThrow("Couldn't start the agent (custom_agent_disabled).") + }) + + it('preserves an admission rejection code', () => { + expect(() => + readMobileNewTabCreatedTerminal({ + agentLaunch: { + status: 'rejected', + requestError: { code: 'untrusted_reference' } + } + }) + ).toThrow("Couldn't create the terminal (untrusted_reference).") + }) +}) diff --git a/mobile/src/session/mobile-new-tab-create-result.ts b/mobile/src/session/mobile-new-tab-create-result.ts new file mode 100644 index 00000000000..d12ff33a991 --- /dev/null +++ b/mobile/src/session/mobile-new-tab-create-result.ts @@ -0,0 +1,24 @@ +import type { + AgentLaunchFailureCode, + AgentLaunchRequestError +} from '../../../src/shared/agent-launch-contract' +import type { TerminalCreateResult } from './mobile-session-route-types' + +export function readMobileNewTabCreatedTerminal(result: unknown): TerminalCreateResult['tab'] { + const envelope = result as { + tab?: TerminalCreateResult['tab'] + agentLaunch?: + | { status: 'failed'; failure: { code: AgentLaunchFailureCode } } + | { status: 'rejected'; requestError: AgentLaunchRequestError } + } | null + if (envelope?.tab) { + return envelope.tab + } + if (envelope?.agentLaunch?.status === 'failed') { + throw new Error(`Couldn't start the agent (${envelope.agentLaunch.failure.code}).`) + } + if (envelope?.agentLaunch?.status === 'rejected') { + throw new Error(`Couldn't create the terminal (${envelope.agentLaunch.requestError.code}).`) + } + throw new Error('Created terminal response was invalid') +} diff --git a/mobile/src/tasks/task-workspace-agent-catalog.test.ts b/mobile/src/tasks/task-workspace-agent-catalog.test.ts new file mode 100644 index 00000000000..001c93b4f52 --- /dev/null +++ b/mobile/src/tasks/task-workspace-agent-catalog.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import type { AgentCatalogSnapshot } from '../../../src/shared/agent-catalog-snapshot' +import { buildTaskWorkspaceAgentCatalog } from './task-workspace-agent-catalog' + +const catalog: AgentCatalogSnapshot = { + version: 1, + revision: 1, + defaultAgent: 'auto', + disabledAgents: [], + customAgents: [ + { + id: 'custom-agent:claude:one', + baseAgent: 'claude', + label: 'My Claude', + args: '', + syncEnv: false, + status: 'ready', + envState: 'none', + availabilityCheck: 'baseline-detection' + } + ], + deletedCustomAgents: [] +} + +describe('task workspace agent catalog', () => { + it('provides custom bases and labels when the base harness is detected', () => { + const projection = buildTaskWorkspaceAgentCatalog(catalog, new Set(['claude']), []) + expect(projection.rows.map((row) => row.id)).toEqual(['claude', 'custom-agent:claude:one']) + expect(projection.customAgentBases.get('custom-agent:claude:one')).toBe('claude') + expect(projection.customAgentLabels.get('custom-agent:claude:one')).toBe('My Claude') + }) + + it('hides custom rows when their base harness is unavailable', () => { + const projection = buildTaskWorkspaceAgentCatalog(catalog, new Set(['codex'])) + expect(projection.rows.some((row) => row.isCustom)).toBe(false) + }) +}) diff --git a/mobile/src/tasks/task-workspace-agent-catalog.ts b/mobile/src/tasks/task-workspace-agent-catalog.ts new file mode 100644 index 00000000000..aca5385b4f3 --- /dev/null +++ b/mobile/src/tasks/task-workspace-agent-catalog.ts @@ -0,0 +1,40 @@ +import type { TuiAgent } from '../../../src/shared/types' +import type { AgentCatalogValue } from '../transport/agent-catalog-sync' +import { + buildMobileAgentPickerRows, + type MobileAgentPickerRow +} from './mobile-agent-catalog-projection' +import { + isWorkspaceAgentEnabled, + type WorkspaceCustomAgentBases +} from './workspace-agent-selection' + +export type TaskWorkspaceAgentCatalog = { + rows: MobileAgentPickerRow[] + customAgentBases: WorkspaceCustomAgentBases + customAgentLabels: ReadonlyMap +} + +export function buildTaskWorkspaceAgentCatalog( + snapshot: AgentCatalogValue | null, + detectedAgentIds: Set | null, + disabledTuiAgents?: unknown +): TaskWorkspaceAgentCatalog { + const rows = buildMobileAgentPickerRows(snapshot, { includeCustomAgents: true }).filter((row) => { + const detectedId = row.isCustom ? row.baseAgent : row.id + return ( + detectedId !== undefined && + (row.isCustom || isWorkspaceAgentEnabled(row.id, disabledTuiAgents)) && + (detectedAgentIds === null || detectedAgentIds.has(detectedId)) + ) + }) + const customAgentBases = new Map>() + const customAgentLabels = new Map() + for (const row of rows) { + if (row.isCustom && row.baseAgent) { + customAgentBases.set(row.id, row.baseAgent) + customAgentLabels.set(row.id, row.label) + } + } + return { rows, customAgentBases, customAgentLabels } +} diff --git a/mobile/src/tasks/task-workspace-create-result.test.ts b/mobile/src/tasks/task-workspace-create-result.test.ts new file mode 100644 index 00000000000..114e45b5644 --- /dev/null +++ b/mobile/src/tasks/task-workspace-create-result.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { readTaskWorkspaceCreatedResult } from './task-workspace-create-result' + +describe('readTaskWorkspaceCreatedResult', () => { + it('returns a created workspace', () => { + expect( + readTaskWorkspaceCreatedResult({ worktree: { id: 'wt-1', displayName: 'Task' } }) + ).toEqual({ worktree: { id: 'wt-1', displayName: 'Task' } }) + }) + + it('surfaces a pre-create custom-agent failure without dereferencing worktree', () => { + expect(() => + readTaskWorkspaceCreatedResult({ + created: false, + agentLaunchResult: { + status: 'failed', + failure: { code: 'custom_agent_disabled' } + } + }) + ).toThrow("Couldn't start the agent (custom_agent_disabled).") + }) + + it('surfaces an admission rejection without dereferencing worktree', () => { + expect(() => + readTaskWorkspaceCreatedResult({ + created: false, + agentLaunchResult: { + status: 'rejected', + requestError: { code: 'untrusted_reference' } + } + }) + ).toThrow("Couldn't create the workspace (untrusted_reference).") + }) +}) diff --git a/mobile/src/tasks/task-workspace-create-result.ts b/mobile/src/tasks/task-workspace-create-result.ts new file mode 100644 index 00000000000..8fcebe7cd4d --- /dev/null +++ b/mobile/src/tasks/task-workspace-create-result.ts @@ -0,0 +1,30 @@ +import type { AgentLaunchFailureCode } from '../../../src/shared/agent-launch-contract' + +export type TaskWorkspaceCreatedResult = { + worktree: { id: string; displayName?: string } + warning?: string +} + +export function readTaskWorkspaceCreatedResult(result: unknown): TaskWorkspaceCreatedResult { + if (!result || typeof result !== 'object') { + throw new Error('Created workspace response was invalid') + } + const value = result as + | TaskWorkspaceCreatedResult + | { + agentLaunchResult?: + | { status: 'failed'; failure: { code: AgentLaunchFailureCode } } + | { status: 'rejected'; requestError: { code: string } } + } + if ('worktree' in value && typeof value.worktree?.id === 'string') { + return value + } + const agentLaunchResult = 'agentLaunchResult' in value ? value.agentLaunchResult : undefined + if (agentLaunchResult?.status === 'failed') { + throw new Error(`Couldn't start the agent (${agentLaunchResult.failure.code}).`) + } + if (agentLaunchResult?.status === 'rejected') { + throw new Error(`Couldn't create the workspace (${agentLaunchResult.requestError.code}).`) + } + throw new Error('Created workspace response was invalid') +} diff --git a/mobile/src/tasks/workspace-agent-selection.test.ts b/mobile/src/tasks/workspace-agent-selection.test.ts index 181ca639a80..77390d3432a 100644 --- a/mobile/src/tasks/workspace-agent-selection.test.ts +++ b/mobile/src/tasks/workspace-agent-selection.test.ts @@ -58,6 +58,9 @@ describe('workspace agent selection', () => { it('normalizes legacy blank sentinel and labels known choices', () => { expect(normalizeWorkspaceAgent('__blank__')).toBe('blank') expect(workspaceAgentLabel('codex')).toBe('Codex') + expect(workspaceAgentLabel(customClaudeId, new Map([[customClaudeId, 'My Claude']]))).toBe( + 'My Claude' + ) }) it('accepts a custom default only when the catalog vouches for it', () => { diff --git a/mobile/src/tasks/workspace-agent-selection.ts b/mobile/src/tasks/workspace-agent-selection.ts index 82eb27d4c01..0a4885f95af 100644 --- a/mobile/src/tasks/workspace-agent-selection.ts +++ b/mobile/src/tasks/workspace-agent-selection.ts @@ -32,13 +32,18 @@ type ResolveWorkspaceAgentSelectionArgs = WorkspaceAgentSelectionState & { customAgentBases?: WorkspaceCustomAgentBases } -export function workspaceAgentLabel(agent: WorkspaceAgentChoice): string { +export function workspaceAgentLabel( + agent: WorkspaceAgentChoice, + customAgentLabels?: ReadonlyMap +): string { if (agent === 'blank') { return 'Blank Terminal' } // Custom ids get their label from the synced catalog (later units); the // static parity table only knows built-ins. - return isMobileTuiAgent(agent) ? MOBILE_TUI_AGENT_LABELS[agent] : agent + return isMobileTuiAgent(agent) + ? MOBILE_TUI_AGENT_LABELS[agent] + : (customAgentLabels?.get(agent) ?? agent) } export function normalizeWorkspaceAgent( diff --git a/mobile/src/tasks/workspace-create-params.test.ts b/mobile/src/tasks/workspace-create-params.test.ts index 36eed50d80c..d0215c70978 100644 --- a/mobile/src/tasks/workspace-create-params.test.ts +++ b/mobile/src/tasks/workspace-create-params.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' import { agentLaunchCreateFields, buildTaskWorkspaceCreateParams } from './workspace-create-params' +import type { TuiAgent } from '../../../src/shared/tui-agent' + +const customAgent = 'custom-agent:claude:0f8b7c6a-1d2e-4a3b-9c4d-5e6f7a8b9c0d' as TuiAgent describe('agentLaunchCreateFields', () => { it('sends startupAgent + createdWithAgent so the host resolves launch args', () => { @@ -15,6 +18,33 @@ describe('agentLaunchCreateFields', () => { }) describe('task workspace create params', () => { + it('routes a custom choice through the host-atomic launch contract', () => { + const params = buildTaskWorkspaceCreateParams({ + item: { + provider: 'github', + source: { + type: 'issue', + repoId: 'repo-1', + number: 42, + title: 'Custom agent task', + url: 'https://github.com/acme/app/issues/42' + } + }, + targetRepoId: 'repo-1', + setupDecision: 'inherit', + agent: customAgent + }) + + expect(params).toMatchObject({ + agentLaunch: { + selection: { kind: 'agent', agent: customAgent }, + prompt: 'https://github.com/acme/app/issues/42' + } + }) + expect(params).not.toHaveProperty('createdWithAgent') + expect(params).not.toHaveProperty('startupDraft') + }) + it('passes a GitHub PR URL as an agent draft and links the PR', () => { expect( buildTaskWorkspaceCreateParams({ diff --git a/mobile/src/tasks/workspace-create-params.ts b/mobile/src/tasks/workspace-create-params.ts index 218c4fe37b0..5cc47046729 100644 --- a/mobile/src/tasks/workspace-create-params.ts +++ b/mobile/src/tasks/workspace-create-params.ts @@ -4,6 +4,7 @@ import type { SetupDecision } from '../../../src/shared/worktree/create-types' import type { GitPushTarget } from '../../../src/shared/worktree/types' +import { isCustomTuiAgentId } from '../../../src/shared/custom-tui-agent-identity' import { getWorkspaceSourceName } from '../../../src/shared/new-workspace/workspace-source' import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' import type { WorkspaceAgentChoice } from './workspace-agent-selection' @@ -105,6 +106,15 @@ export function buildTaskWorkspaceCreateParams(args: { } = args const shouldLaunchAgent = agent !== 'blank' const createdWithAgent = shouldLaunchAgent ? (agent as TuiAgent) : undefined + const customAgentLaunch = + createdWithAgent && isCustomTuiAgentId(createdWithAgent) + ? { + agentLaunch: { + selection: { kind: 'agent' as const, agent: createdWithAgent }, + prompt: item.source.url + } + } + : null const comment = note?.trim() const selectedBaseBranch = baseBranch || hostedStartPoint?.baseBranch const selectedPushTarget = pushTarget ?? hostedStartPoint?.pushTarget @@ -128,8 +138,9 @@ export function buildTaskWorkspaceCreateParams(args: { const common = { setupDecision, activate: true, - ...(shouldLaunchAgent ? { startupDraft: item.source.url } : {}), - ...(createdWithAgent ? { createdWithAgent } : {}), + ...(shouldLaunchAgent && !customAgentLaunch ? { startupDraft: item.source.url } : {}), + ...(createdWithAgent && !customAgentLaunch ? { createdWithAgent } : {}), + ...customAgentLaunch, ...(selectedBaseBranch ? { baseBranch: selectedBaseBranch } : {}), ...(compareBaseRef ? { compareBaseRef } : {}), ...(branchNameOverride ? { branchNameOverride } : {}), diff --git a/mobile/src/transport/host-client-context-contract.ts b/mobile/src/transport/host-client-context-contract.ts index 5397689deb6..dd6181459f1 100644 --- a/mobile/src/transport/host-client-context-contract.ts +++ b/mobile/src/transport/host-client-context-contract.ts @@ -30,6 +30,7 @@ export type HostClientContextValue = { getLastConnectedAt: (hostId: string) => number | null getActivePath: (hostId: string) => MobileConnectionPath getPendingPath: (hostId: string) => MobileConnectionPath | null + isPairingRejected: (hostId: string) => boolean subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void getAllClients: () => Array<{ hostId: string; client: RpcClient }> subscribeAllHosts: (listener: () => void) => () => void diff --git a/src/main/agent-launch/agent-launch-host-state.test.ts b/src/main/agent-launch/agent-launch-host-state.test.ts index e9c4c5f3795..a8792078d93 100644 --- a/src/main/agent-launch/agent-launch-host-state.test.ts +++ b/src/main/agent-launch/agent-launch-host-state.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { deriveAgentLaunchHostState, + detectionBaseAgentsForLaunch, defaultTransportConfidentiality, describeSpawnExecutionHost, detectionUnavailable, @@ -31,10 +32,18 @@ describe('executionHostIdForDescriptor', () => { 'wsl:Ubuntu%2022.04' ) expect( - executionHostIdForDescriptor({ kind: 'ssh', connectionId: 'my host', platform: 'linux' }) + executionHostIdForDescriptor({ + kind: 'ssh', + connectionId: 'my host', + platform: 'linux' + }) ).toBe('ssh:my%20host') expect( - executionHostIdForDescriptor({ kind: 'runtime', environmentId: 'env/1', platform: 'linux' }) + executionHostIdForDescriptor({ + kind: 'runtime', + environmentId: 'env/1', + platform: 'linux' + }) ).toBe('runtime:env%2F1') }) }) @@ -43,18 +52,32 @@ describe('platformForDescriptor / isRemoteForDescriptor', () => { it('forces linux for WSL and keeps the named platform otherwise', () => { expect(platformForDescriptor({ kind: 'wsl', distro: 'Ubuntu' })).toBe('linux') expect(platformForDescriptor({ kind: 'local', platform: 'win32' })).toBe('win32') - expect(platformForDescriptor({ kind: 'ssh', connectionId: 'h', platform: 'linux' })).toBe( - 'linux' - ) + expect( + platformForDescriptor({ + kind: 'ssh', + connectionId: 'h', + platform: 'linux' + }) + ).toBe('linux') }) it('treats SSH and default runtime as remote, local and WSL as local', () => { expect(isRemoteForDescriptor({ kind: 'local', platform: 'darwin' })).toBe(false) expect(isRemoteForDescriptor({ kind: 'wsl', distro: 'Ubuntu' })).toBe(false) - expect(isRemoteForDescriptor({ kind: 'ssh', connectionId: 'h', platform: 'linux' })).toBe(true) - expect(isRemoteForDescriptor({ kind: 'runtime', environmentId: 'e', platform: 'linux' })).toBe( - true - ) + expect( + isRemoteForDescriptor({ + kind: 'ssh', + connectionId: 'h', + platform: 'linux' + }) + ).toBe(true) + expect( + isRemoteForDescriptor({ + kind: 'runtime', + environmentId: 'e', + platform: 'linux' + }) + ).toBe(true) expect( isRemoteForDescriptor({ kind: 'runtime', @@ -71,10 +94,18 @@ describe('defaultTransportConfidentiality', () => { expect(defaultTransportConfidentiality({ kind: 'local', platform: 'darwin' })).toBeUndefined() expect(defaultTransportConfidentiality({ kind: 'wsl', distro: 'Ubuntu' })).toBeUndefined() expect( - defaultTransportConfidentiality({ kind: 'ssh', connectionId: 'h', platform: 'linux' }) + defaultTransportConfidentiality({ + kind: 'ssh', + connectionId: 'h', + platform: 'linux' + }) ).toBe(true) expect( - defaultTransportConfidentiality({ kind: 'runtime', environmentId: 'e', platform: 'linux' }) + defaultTransportConfidentiality({ + kind: 'runtime', + environmentId: 'e', + platform: 'linux' + }) ).toBe(false) }) }) @@ -105,7 +136,10 @@ describe('deriveAgentLaunchHostState', () => { expect([...state.target.detectedStockBaseAgents!].sort()).toEqual(['claude', 'codex']) // Same-host: confidentiality is omitted (undefined), not false. expect('transportConfidentialityAvailable' in state.target).toBe(false) - expect(state.variables).toEqual({ repoPath: '/repo', worktreePath: '/repo/wt' }) + expect(state.variables).toEqual({ + repoPath: '/repo', + worktreePath: '/repo/wt' + }) expect(state.getCatalogRevision()).toBe(3) }) @@ -122,6 +156,33 @@ describe('deriveAgentLaunchHostState', () => { expect(state.target.transportConfidentialityAvailable).toBe(true) }) + it('uses the target-owned SSH shell when the descriptor has no shell', async () => { + const resolveStartupShell = vi.fn(async () => 'cmd' as const) + const descriptor: AgentLaunchHostDescriptor = { + kind: 'ssh', + connectionId: 'box-1', + platform: 'win32' + } + const state = await deriveAgentLaunchHostState( + makeDeps({ resolveStartupShell }), + descriptor, + {} + ) + expect(resolveStartupShell).toHaveBeenCalledWith(descriptor) + expect(state.target.shell).toBe('cmd') + }) + + it('does not replace a shell already proven by the descriptor', async () => { + const resolveStartupShell = vi.fn(async () => 'powershell' as const) + const state = await deriveAgentLaunchHostState( + makeDeps({ resolveStartupShell }), + { kind: 'ssh', connectionId: 'box-1', platform: 'win32', shell: 'cmd' }, + {} + ) + expect(resolveStartupShell).not.toHaveBeenCalled() + expect(state.target.shell).toBe('cmd') + }) + it('derives a WSL target as local linux with a wsl host id', async () => { const state = await deriveAgentLaunchHostState( makeDeps({ resolveTargetHomePath: async () => null }), @@ -191,11 +252,55 @@ describe('deriveAgentLaunchHostState', () => { }) }) +describe('detectionBaseAgentsForLaunch', () => { + const customId = 'custom-agent:codex:01234567-89ab-4cde-8f01-23456789abcd' as const + it('narrows only immutable built-in identities to one stock base', () => { + expect(detectionBaseAgentsForLaunch({ selection: { kind: 'agent', agent: 'claude' } })).toEqual( + ['claude'] + ) + expect(detectionBaseAgentsForLaunch({ selection: { kind: 'agent', agent: customId } })).toBe( + undefined + ) + }) + + it('keeps full detection for mutable defaults and trusts snapshot base identity', () => { + expect(detectionBaseAgentsForLaunch({ selection: { kind: 'default' } })).toBeUndefined() + expect( + detectionBaseAgentsForLaunch( + { selection: { kind: 'default' } }, + { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } + } + ) + ).toEqual(['claude']) + }) +}) + describe('describeSpawnExecutionHost', () => { it('describes a local target with this machine platform', () => { - const descriptor = describeSpawnExecutionHost({ connectionId: null, cwd: '/repo' }) + const descriptor = describeSpawnExecutionHost({ + connectionId: null, + cwd: '/repo' + }) expect(descriptor.kind).toBe('local') - expect(descriptor).toMatchObject({ kind: 'local', platform: process.platform }) + expect(descriptor).toMatchObject({ + kind: 'local', + platform: process.platform + }) }) it('describes an SSH target and infers linux from a POSIX cwd', () => { @@ -203,7 +308,11 @@ describe('describeSpawnExecutionHost', () => { connectionId: 'host-1', cwd: '/home/user/repo' }) - expect(descriptor).toEqual({ kind: 'ssh', connectionId: 'host-1', platform: 'linux' }) + expect(descriptor).toEqual({ + kind: 'ssh', + connectionId: 'host-1', + platform: 'linux' + }) }) it('infers win32 for an SSH target with a Windows-shaped cwd', () => { @@ -211,12 +320,20 @@ describe('describeSpawnExecutionHost', () => { connectionId: 'host-1', cwd: 'C:\\Users\\me\\repo' }) - expect(descriptor).toEqual({ kind: 'ssh', connectionId: 'host-1', platform: 'win32' }) + expect(descriptor).toEqual({ + kind: 'ssh', + connectionId: 'host-1', + platform: 'win32' + }) }) it('defaults an SSH target to linux when the cwd is unknown', () => { const descriptor = describeSpawnExecutionHost({ connectionId: 'host-1' }) - expect(descriptor).toEqual({ kind: 'ssh', connectionId: 'host-1', platform: 'linux' }) + expect(descriptor).toEqual({ + kind: 'ssh', + connectionId: 'host-1', + platform: 'linux' + }) }) // A WSL UNC cwd runs a Linux userland; win32 here picks the Windows executable @@ -237,7 +354,10 @@ describe('describeSpawnExecutionHost', () => { // PowerShell quotes typed into a cmd.exe tab reach the agent verbatim. it('quotes for the tab shell override ahead of the global Windows shell', () => { const original = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true + }) try { expect( describeSpawnExecutionHost({ @@ -263,17 +383,27 @@ describe('describeSpawnExecutionHost', () => { it('describes a legacy \\\\wsl$ UNC cwd as the same wsl host', () => { expect( - describeSpawnExecutionHost({ connectionId: null, cwd: '\\\\wsl$\\Debian\\srv\\app' }) + describeSpawnExecutionHost({ + connectionId: null, + cwd: '\\\\wsl$\\Debian\\srv\\app' + }) ).toEqual({ kind: 'wsl', distro: 'Debian' }) }) }) describe('resolveLocalTargetHomePath', () => { it('returns a home dir for local and null for every other surface', async () => { - const local: AgentLaunchHostDescriptor = { kind: 'local', platform: process.platform } + const local: AgentLaunchHostDescriptor = { + kind: 'local', + platform: process.platform + } await expect(resolveLocalTargetHomePath(local)).resolves.toEqual(expect.any(String)) await expect( - resolveLocalTargetHomePath({ kind: 'ssh', connectionId: 'h', platform: 'linux' }) + resolveLocalTargetHomePath({ + kind: 'ssh', + connectionId: 'h', + platform: 'linux' + }) ).resolves.toBeNull() await expect(resolveLocalTargetHomePath({ kind: 'wsl', distro: 'Ubuntu' })).resolves.toBeNull() }) diff --git a/src/main/agent-launch/agent-launch-host-state.ts b/src/main/agent-launch/agent-launch-host-state.ts index 06747bff6ec..919ef5bd08a 100644 --- a/src/main/agent-launch/agent-launch-host-state.ts +++ b/src/main/agent-launch/agent-launch-host-state.ts @@ -14,8 +14,12 @@ import { homedir } from 'node:os' import type { BuiltInTuiAgent, GlobalSettings } from '../../shared/types' +import type { AgentLaunchSpawnRequest } from '../../shared/agent-launch-spawn-request' import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' -import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' +import type { + AgentLaunchExecutionHostId, + AgentLaunchSnapshot +} from '../../shared/agent-launch-host-contract' import { isBuiltInTuiAgent } from '../../shared/tui-agent-config' import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../shared/execution-host' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' @@ -47,7 +51,12 @@ export type AgentLaunchHostDescriptor = // resolver translate Windows/UNC values to Linux form. Detection/home stay // honest unknowns until a caller can probe the distro's own PATH/$HOME. | { kind: 'wsl'; distro: string; shell?: AgentStartupShell } - | { kind: 'ssh'; connectionId: string; platform: NodeJS.Platform; shell?: AgentStartupShell } + | { + kind: 'ssh' + connectionId: string + platform: NodeJS.Platform + shell?: AgentStartupShell + } | { kind: 'runtime' environmentId: string @@ -180,11 +189,17 @@ export type AgentLaunchHostStateDeps = { /** Detect stock base agents on the target's baseline PATH. Return null when * detection is genuinely unavailable — never an empty list to mean unknown. */ detectStockBaseAgents: ( - descriptor: AgentLaunchHostDescriptor + descriptor: AgentLaunchHostDescriptor, + baseAgents?: readonly BuiltInTuiAgent[] ) => Promise /** Resolve the target host's home dir for `~` expansion, or null when the host * has not resolved it (SSH before resolveHome, an unknown WSL distro $HOME). */ resolveTargetHomePath: (descriptor: AgentLaunchHostDescriptor) => Promise + /** Resolve a target-owned shell when the descriptor cannot carry one + * synchronously (notably a native-Windows SSH relay). */ + resolveStartupShell?: ( + descriptor: AgentLaunchHostDescriptor + ) => Promise /** Override the default confidentiality derivation when a cross-host channel's * binding is identifiable (e.g. a runtime env reached over SSH). */ resolveTransportConfidentiality?: (descriptor: AgentLaunchHostDescriptor) => boolean | undefined @@ -201,20 +216,40 @@ export type AgentLaunchHostState = { variables: { repoPath: string | null; worktreePath: string | null } } +/** Restrict an eligibility probe only when the base identity is immutable during + * the async probe. Defaults and custom definitions use full detection because a + * settings edit can change their base before synchronous launch resolution. */ +export function detectionBaseAgentsForLaunch( + request: AgentLaunchSpawnRequest, + snapshot?: AgentLaunchSnapshot +): readonly BuiltInTuiAgent[] | undefined { + if (snapshot) { + return [snapshot.baseAgent] + } + if (request.selection.kind === 'default') { + return undefined + } + return isBuiltInTuiAgent(request.selection.agent) ? [request.selection.agent] : undefined +} + /** Derive the per-surface host state for a launch. Performs the async host reads * (detection, target home) once, up front, so the boundary's synchronous * re-resolution inside the admission coordinator only re-reads settings. */ export async function deriveAgentLaunchHostState( deps: AgentLaunchHostStateDeps, descriptor: AgentLaunchHostDescriptor, - variables: { repoPath?: string | null; worktreePath?: string | null } + variables: { repoPath?: string | null; worktreePath?: string | null }, + options: { detectionBaseAgents?: readonly BuiltInTuiAgent[] } = {} ): Promise { const platform = platformForDescriptor(descriptor) const isRemote = isRemoteForDescriptor(descriptor) const executionHostId = executionHostIdForDescriptor(descriptor) - const [detected, targetHomePath] = await Promise.all([ - deps.detectStockBaseAgents(descriptor), - deps.resolveTargetHomePath(descriptor) + const [detected, targetHomePath, resolvedShell] = await Promise.all([ + options.detectionBaseAgents === undefined + ? deps.detectStockBaseAgents(descriptor) + : deps.detectStockBaseAgents(descriptor, options.detectionBaseAgents), + deps.resolveTargetHomePath(descriptor), + descriptor.shell ? Promise.resolve(undefined) : deps.resolveStartupShell?.(descriptor) ]) const confidentiality = (deps.resolveTransportConfidentiality ?? defaultTransportConfidentiality)( descriptor @@ -222,7 +257,7 @@ export async function deriveAgentLaunchHostState( const target: AgentLaunchSpawnTarget = { platform, - ...(descriptor.shell ? { shell: descriptor.shell } : {}), + ...(descriptor.shell || resolvedShell ? { shell: descriptor.shell ?? resolvedShell } : {}), isRemote, executionHostId, targetHomePath: targetHomePath ?? null, diff --git a/src/main/agent-launch/agent-launch-worktree-resolution.test.ts b/src/main/agent-launch/agent-launch-worktree-resolution.test.ts index 66769d3f3ca..aac4031dcdf 100644 --- a/src/main/agent-launch/agent-launch-worktree-resolution.test.ts +++ b/src/main/agent-launch/agent-launch-worktree-resolution.test.ts @@ -53,7 +53,10 @@ function makeLaunch( displayLabel: 'Claude', argv: snapshot.argv, agentEnv: snapshot.agentEnv, - variables: { values: { repoPath: '/repo', worktreePath }, referenced: ['worktreePath'] }, + variables: { + values: { repoPath: '/repo', worktreePath }, + referenced: ['worktreePath'] + }, snapshot, policy: { intent: 'interactive', @@ -101,12 +104,77 @@ const CONTEXT: WorktreeAgentLaunchContext = { } describe('two-stage worktree agent-launch resolution', () => { + it('keeps default-agent detection valid when settings change during the host probe', async () => { + const claudeId = 'custom-agent:claude:01234567-89ab-4cde-8f01-23456789abcd' as const + const codexId = 'custom-agent:codex:12345678-9abc-4def-8012-3456789abcde' as const + const customTuiAgents = [ + { + id: claudeId, + baseAgent: 'claude' as const, + label: 'Claude Review', + args: '', + env: {}, + syncEnv: false + }, + { + id: codexId, + baseAgent: 'codex' as const, + label: 'Codex Review', + args: '', + env: {}, + syncEnv: false + } + ] + let settings = { + agentCatalogRevision: 1, + defaultTuiAgent: claudeId, + customTuiAgents + } as unknown as GlobalSettings + const { deps } = makeSetup(() => ({ + ok: false, + failure: { code: 'base_agent_unavailable', baseAgent: 'codex' } + })) + deps.getSettings = () => settings + delete deps.resolve + const detect = vi.fn( + async (_descriptor, baseAgents?: readonly string[]): Promise => { + settings = { + agentCatalogRevision: 2, + defaultTuiAgent: codexId, + customTuiAgents + } as unknown as GlobalSettings + return baseAgents ?? ['claude', 'codex'] + } + ) + deps.detectStockBaseAgents = detect + + const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, { + repoPath: '/repo', + worktreePath: '/wt-provisional' + }) + + expect(detect).toHaveBeenCalledWith(CONTEXT.descriptor) + expect(prepared.ok).toBe(true) + if (prepared.ok) { + expect(prepared.requestedAgent).toBe(codexId) + } + }) + it('pins the config digest pre-git and admits it post-git across a changed path', async () => { const resolve = vi .fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>() - .mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-prov', 'sd-1', '/wt-provisional') }) - .mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') }) - .mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') }) + .mockReturnValueOnce({ + ok: true, + launch: makeLaunch('fp-prov', 'sd-1', '/wt-provisional') + }) + .mockReturnValueOnce({ + ok: true, + launch: makeLaunch('fp-real', 'sd-1', '/wt-real') + }) + .mockReturnValueOnce({ + ok: true, + launch: makeLaunch('fp-real', 'sd-1', '/wt-real') + }) const { deps, store } = makeSetup(resolve) const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, { @@ -146,8 +214,14 @@ describe('two-stage worktree agent-launch resolution', () => { it('releases the reservation and reports a config change when the digest moved', async () => { const resolve = vi .fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>() - .mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-prov', 'sd-1', '/wt-provisional') }) - .mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-real', 'sd-2', '/wt-real') }) + .mockReturnValueOnce({ + ok: true, + launch: makeLaunch('fp-prov', 'sd-1', '/wt-provisional') + }) + .mockReturnValueOnce({ + ok: true, + launch: makeLaunch('fp-real', 'sd-2', '/wt-real') + }) const { deps, store } = makeSetup(resolve) const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, { @@ -181,7 +255,10 @@ describe('two-stage worktree agent-launch resolution', () => { it('threads the stage-2 worktree into admission so the per-worktree cap counts it (L3-#3)', async () => { const resolve = vi .fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>() - .mockReturnValue({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') }) + .mockReturnValue({ + ok: true, + launch: makeLaunch('fp-real', 'sd-1', '/wt-real') + }) const { deps, store } = makeSetup(resolve) const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, { repoPath: '/repo', @@ -195,7 +272,10 @@ describe('two-stage worktree agent-launch resolution', () => { deps, { ...CONTEXT, worktreeId: 'wt-created' }, { repoPath: '/repo', worktreePath: '/wt-real' }, - { reservationId: prepared.reservationId, expectedStableInputDigest: 'sd-1' } + { + reservationId: prepared.reservationId, + expectedStableInputDigest: 'sd-1' + } ) expect(executed.ok).toBe(true) expect(store.pendingForWorktree('wt-created')).toBe(1) @@ -204,11 +284,18 @@ describe('two-stage worktree agent-launch resolution', () => { it("defaults the admission worktree to a background intent's own worktree (L3-#3)", async () => { const resolve = vi .fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>() - .mockReturnValue({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') }) + .mockReturnValue({ + ok: true, + launch: makeLaunch('fp-real', 'sd-1', '/wt-real') + }) const { deps, store } = makeSetup(resolve) const context: WorktreeAgentLaunchContext = { ...CONTEXT, - intent: { kind: 'background', attemptId: 'attempt-1', worktreeId: 'wt-bg' }, + intent: { + kind: 'background', + attemptId: 'attempt-1', + worktreeId: 'wt-bg' + }, scope: 'attempt-1' } const prepared = await prepareWorktreeAgentLaunch(deps, context, { @@ -223,7 +310,10 @@ describe('two-stage worktree agent-launch resolution', () => { deps, context, { repoPath: '/repo', worktreePath: '/wt-real' }, - { reservationId: prepared.reservationId, expectedStableInputDigest: 'sd-1' } + { + reservationId: prepared.reservationId, + expectedStableInputDigest: 'sd-1' + } ) expect(executed.ok).toBe(true) // Scope is the attempt id, yet the cap counts the attempt's worktree. @@ -234,7 +324,10 @@ describe('two-stage worktree agent-launch resolution', () => { it('frees the hold when a stage-2 host read rejects, so repeated races keep capacity', async () => { const resolve = vi .fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>() - .mockReturnValue({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') }) + .mockReturnValue({ + ok: true, + launch: makeLaunch('fp-real', 'sd-1', '/wt-real') + }) const { deps, store } = makeSetup(resolve) // More rounds than MAX_PENDING_LAUNCHES_PER_PRINCIPAL: a leaked hold per round @@ -257,7 +350,10 @@ describe('two-stage worktree agent-launch resolution', () => { deps, CONTEXT, { repoPath: '/repo', worktreePath: '/wt-real' }, - { reservationId: prepared.reservationId, expectedStableInputDigest: 'sd-1' } + { + reservationId: prepared.reservationId, + expectedStableInputDigest: 'sd-1' + } ) expect(executed.ok).toBe(false) if (executed.ok) { @@ -272,7 +368,10 @@ describe('two-stage worktree agent-launch resolution', () => { it('takes no reservation when pre-git resolution fails', async () => { const resolve = vi .fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>() - .mockReturnValueOnce({ ok: false, failure: { code: 'custom_agent_disabled' } }) + .mockReturnValueOnce({ + ok: false, + failure: { code: 'custom_agent_disabled' } + }) const { deps, store } = makeSetup(resolve) const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, { diff --git a/src/main/agent-launch/agent-launch-worktree-resolution.ts b/src/main/agent-launch/agent-launch-worktree-resolution.ts index a5449ca7a23..3657f8461fe 100644 --- a/src/main/agent-launch/agent-launch-worktree-resolution.ts +++ b/src/main/agent-launch/agent-launch-worktree-resolution.ts @@ -13,6 +13,7 @@ import type { GlobalSettings } from '../../shared/types' import type { AgentLaunchSpawnRequest } from '../../shared/agent-launch-spawn-request' import type { LaunchIntent, ResolvedAgentLaunch } from '../../shared/agent-launch-host-contract' import { + detectionBaseAgentsForLaunch, deriveAgentLaunchHostState, type AgentLaunchHostDescriptor, type AgentLaunchHostStateDeps @@ -61,6 +62,7 @@ export type WorktreeAgentLaunchDeps = { getCatalogRevision: () => number detectStockBaseAgents: AgentLaunchHostStateDeps['detectStockBaseAgents'] resolveTargetHomePath: AgentLaunchHostStateDeps['resolveTargetHomePath'] + resolveStartupShell?: AgentLaunchHostStateDeps['resolveStartupShell'] resolveTransportConfidentiality?: AgentLaunchHostStateDeps['resolveTransportConfidentiality'] /** Best-effort workspace trust for the resolved base agent, run as the * boundary's pre-admission preflight OUTSIDE the coordinator. A throw maps to @@ -110,18 +112,21 @@ export async function prepareWorktreeAgentLaunch( context: WorktreeAgentLaunchContext, provisionalPaths: { repoPath: string | null; worktreePath: string | null } ): Promise { + const detectionBaseAgents = detectionBaseAgentsForLaunch(context.request) const hostState = await deriveAgentLaunchHostState( { getSettings: deps.getSettings, getCatalogRevision: deps.getCatalogRevision, detectStockBaseAgents: deps.detectStockBaseAgents, resolveTargetHomePath: deps.resolveTargetHomePath, + ...(deps.resolveStartupShell ? { resolveStartupShell: deps.resolveStartupShell } : {}), ...(deps.resolveTransportConfidentiality ? { resolveTransportConfidentiality: deps.resolveTransportConfidentiality } : {}) }, context.descriptor, - provisionalPaths + provisionalPaths, + detectionBaseAgents === undefined ? {} : { detectionBaseAgents } ) const resolve = buildHostStateResolve(toSpawnDeps(deps), { request: context.request, @@ -145,18 +150,21 @@ export async function executeWorktreeAgentLaunch( reservation: { reservationId: string; expectedStableInputDigest: string } ): Promise { try { + const detectionBaseAgents = detectionBaseAgentsForLaunch(context.request) const hostState = await deriveAgentLaunchHostState( { getSettings: deps.getSettings, getCatalogRevision: deps.getCatalogRevision, detectStockBaseAgents: deps.detectStockBaseAgents, resolveTargetHomePath: deps.resolveTargetHomePath, + ...(deps.resolveStartupShell ? { resolveStartupShell: deps.resolveStartupShell } : {}), ...(deps.resolveTransportConfidentiality ? { resolveTransportConfidentiality: deps.resolveTransportConfidentiality } : {}) }, context.descriptor, - authoritativePaths + authoritativePaths, + detectionBaseAgents === undefined ? {} : { detectionBaseAgents } ) const resolve = buildHostStateResolve(toSpawnDeps(deps), { request: context.request, diff --git a/src/main/automations/service-production-wiring.test.ts b/src/main/automations/service-production-wiring.test.ts new file mode 100644 index 00000000000..1a702d19c89 --- /dev/null +++ b/src/main/automations/service-production-wiring.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('automation production agent-launch gate wiring', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + it('classifies the resolved agent before desktop and headless dispatch', () => { + const serviceStart = source.indexOf('automations = new AutomationService(store, {') + const dispatcherStart = source.indexOf('headlessDispatcher:', serviceStart) + const classifierStart = source.indexOf('classifyAgentLaunch:', serviceStart) + + expect(serviceStart).toBeGreaterThanOrEqual(0) + expect(classifierStart).toBeGreaterThan(serviceStart) + expect(classifierStart).toBeLessThan(dispatcherStart) + expect(source.slice(classifierStart, dispatcherStart)).toContain( + 'runtimeService.classifyAgentLaunchForAutomation(' + ) + expect(source.slice(classifierStart, dispatcherStart)).toContain('target.cwd') + }) +}) diff --git a/src/main/ipc/preflight-remote-ssh.test.ts b/src/main/ipc/preflight-remote-ssh.test.ts index 14d79e953e8..b2fbc34919d 100644 --- a/src/main/ipc/preflight-remote-ssh.test.ts +++ b/src/main/ipc/preflight-remote-ssh.test.ts @@ -87,6 +87,7 @@ vi.mock('../gitea/client', () => ({ })) import { registerPreflightHandlers } from './preflight' +import { detectRemoteAgentsIfReachable } from '../preflight/agent-detection' import { resetPreflightMocks, type HandlerMap } from './preflight-test-harness' describe('preflight', () => { @@ -141,6 +142,34 @@ describe('preflight', () => { }) }) + it('restricts launch eligibility detection to the requested stock base', async () => { + const request = vi.fn().mockResolvedValue({ agents: ['codex'] }) + getActiveMultiplexerMock.mockReturnValue({ + isDisposed: () => false, + request + }) + + await expect( + detectRemoteAgentsIfReachable({ connectionId: 'ssh-1', baseAgents: ['codex'] }) + ).resolves.toEqual(['codex']) + const commands = request.mock.calls[0]?.[1]?.commands as { id: string }[] + expect(commands.length).toBeGreaterThan(0) + expect(commands.every((command) => command.id === 'codex')).toBe(true) + }) + + it('skips the relay when launch eligibility has no resolvable base', async () => { + const request = vi.fn() + getActiveMultiplexerMock.mockReturnValue({ + isDisposed: () => false, + request + }) + + await expect( + detectRemoteAgentsIfReachable({ connectionId: 'ssh-1', baseAgents: [] }) + ).resolves.toEqual([]) + expect(request).not.toHaveBeenCalled() + }) + it('returns no remote agents when the SSH connection is unavailable', async () => { getActiveMultiplexerMock.mockReturnValue(null) diff --git a/src/main/ipc/pty-spawn-agent-resume-shell-and-delivery.test.ts b/src/main/ipc/pty-spawn-agent-resume-shell-and-delivery.test.ts index 6ec1a2c3501..e35bc7ea06b 100644 --- a/src/main/ipc/pty-spawn-agent-resume-shell-and-delivery.test.ts +++ b/src/main/ipc/pty-spawn-agent-resume-shell-and-delivery.test.ts @@ -163,4 +163,47 @@ describe('registerPtyHandlers agent resume', () => { }) ) }) + + it('quotes a native-Windows SSH resume for the relay default shell', async () => { + const spawn = vi.fn(async () => ({ id: 'remote-win-pty' })) + const getDefaultShell = vi.fn(async () => 'C:\\Windows\\System32\\cmd.exe') + registerSshPtyProvider('ssh-win-resume', { + spawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + getDefaultShell, + getProfiles: vi.fn(), + acknowledgeDataEvent: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => ({})) as never) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + connectionId: 'ssh-win-resume', + cwd: 'C:\\Users\\me\\wt-1', + agentLaunch: resumeLaunch('codex-ssh-win-cmd'), + launchConfig: LEGACY_LAUNCH_CONFIG, + legacyResumeRecordedConnectionId: 'ssh-win-resume' + }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ command: `codex "resume" "codex-ssh-win-cmd"` }) + ) + expect(getDefaultShell).toHaveBeenCalledOnce() + }) }) diff --git a/src/main/ipc/pty/ipc/spawn-agent-launch.ts b/src/main/ipc/pty/ipc/spawn-agent-launch.ts index f3fbcf7204e..7abce4b8678 100644 --- a/src/main/ipc/pty/ipc/spawn-agent-launch.ts +++ b/src/main/ipc/pty/ipc/spawn-agent-launch.ts @@ -10,6 +10,7 @@ import type { } from '../../../../shared/agent-launch-spawn-request' import { resolveStartupShell } from '../../../../shared/tui-agent-startup-shell' import type { TuiAgent } from '../../../../shared/types' +import { resolveWindowsShellStartupFamily } from '../../../../shared/windows-terminal-shell' import { describeSpawnExecutionHost, deriveAgentLaunchHostState, @@ -55,7 +56,7 @@ export async function resolvePtyIpcAgentLaunch( const getLaunchSettings = ctx.deps.getSettings const descriptor = describeSpawnExecutionHost({ connectionId: args.connectionId, - cwd: ctx.cwd, + cwd: ctx.cwd ?? args.cwd, shellOverride: args.shellOverride, terminalWindowsShell: getLaunchSettings()?.terminalWindowsShell, projectRuntime: args.projectRuntime @@ -65,7 +66,14 @@ export async function resolvePtyIpcAgentLaunch( getSettings: getLaunchSettings, getCatalogRevision: () => getLaunchSettings()?.agentCatalogRevision ?? 1, detectStockBaseAgents: detectionUnavailable, - resolveTargetHomePath: resolveLocalTargetHomePath + resolveTargetHomePath: resolveLocalTargetHomePath, + resolveStartupShell: async (target) => { + if (target.kind !== 'ssh' || target.platform !== 'win32') { + return undefined + } + const shell = args.shellOverride ?? (await ctx.provider.getDefaultShell()) + return resolveWindowsShellStartupFamily(shell) + } }, descriptor, { worktreePath: ctx.cwd ?? null, repoPath: null } diff --git a/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts b/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts index cde0cb00d6f..c189ccdf49d 100644 --- a/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts +++ b/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts @@ -567,7 +567,7 @@ describe('registerWorktreeHandlers', () => { const result = (await handlers['worktrees:create'](null, { repoId: 'repo-ssh', name: 'improve-dashboard' - })) as CreateWorktreeResult + })) as CreatedWorktreeResult expect(provider.exec).toHaveBeenCalledWith( ['show-ref', '--verify', '--quiet', '--', 'refs/heads/main'], @@ -583,7 +583,7 @@ describe('registerWorktreeHandlers', () => { const result = (await handlers['worktrees:create'](null, { repoId: 'repo-ssh', name: 'improve-dashboard' - })) as CreateWorktreeResult + })) as CreatedWorktreeResult expect(result.localBaseRefRefresh).toEqual({ status: 'skipped_not_fast_forward', @@ -600,7 +600,7 @@ describe('registerWorktreeHandlers', () => { const result = (await handlers['worktrees:create'](null, { repoId: 'repo-ssh', name: 'improve-dashboard' - })) as CreateWorktreeResult + })) as CreatedWorktreeResult expect(result.localBaseRefRefresh).toEqual({ status: 'skipped_not_fast_forward', diff --git a/src/main/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts index aac7dfb9726..9da16fca9a2 100644 --- a/src/main/preflight/agent-detection.ts +++ b/src/main/preflight/agent-detection.ts @@ -242,13 +242,21 @@ export async function refreshShellPathAndDetectAgents( * unavailable and provides no evidence that the host lacks an agent. */ export async function detectRemoteAgentsIfReachable(args: { connectionId: string + /** Omitted for full catalog discovery; present for launch eligibility. */ + baseAgents?: readonly string[] }): Promise { const mux = getActiveMultiplexer(args.connectionId) if (!mux || mux.isDisposed()) { return null } + const commands = args.baseAgents + ? KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter((command) => args.baseAgents?.includes(command.id)) + : KNOWN_TUI_AGENT_DETECTION_COMMANDS + if (commands.length === 0) { + return [] + } const result = (await mux.request('preflight.detectAgents', { - commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS + commands })) as { agents: string[] } return uniqueAgentIds(result.agents) } diff --git a/src/main/providers/ssh-agent-session-capabilities.ts b/src/main/providers/ssh-agent-session-capabilities.ts index 6767f3c1ba2..32fcd6cbb80 100644 --- a/src/main/providers/ssh-agent-session-capabilities.ts +++ b/src/main/providers/ssh-agent-session-capabilities.ts @@ -20,9 +20,13 @@ export class SshAgentSessionCapabilities { await waitForSshCapabilityProbe(probe, options.signal) this.claimSupported = true return true - } catch { - if (!options.signal?.aborted && this.claimProbe === probe) { - // Why: negative physical probes must follow a relay upgraded on this connection. + } catch (error) { + if ( + !options.signal?.aborted && + this.claimProbe === probe && + (error as { capabilityProbeTransportFailure?: unknown }).capabilityProbeTransportFailure === + true + ) { this.claimProbe = null this.claimSupported = false } @@ -41,13 +45,12 @@ export class SshAgentSessionCapabilities { try { supported = await waitForSshCapabilityProbe(probe, options.signal) } catch { - // Why: one canceled waiter must not cancel or evict the shared physical probe used by peers. + // Why: a transport failure is unknown and may recover on this connection. + if (!options.signal?.aborted && this.createOperationProbe === probe) { + this.createOperationProbe = null + } return false } - if (!supported && this.createOperationProbe === probe) { - // Why: negative capability results must follow a relay upgraded on the same connection. - this.createOperationProbe = null - } return supported } @@ -58,13 +61,12 @@ export class SshAgentSessionCapabilities { try { supported = await waitForSshCapabilityProbe(probe, options.signal) } catch { - // Why: one canceled waiter must not cancel or evict the shared physical probe used by peers. + // Why: a transport failure is unknown and may recover on this connection. + if (!options.signal?.aborted && this.launchTokenEchoProbe === probe) { + this.launchTokenEchoProbe = null + } return false } - if (!supported && this.launchTokenEchoProbe === probe) { - // Why: negative capability results must follow a relay upgraded on the same connection. - this.launchTokenEchoProbe = null - } this.launchTokenEchoSupported = supported return supported } diff --git a/src/main/providers/ssh-agent-session-capability-probe.ts b/src/main/providers/ssh-agent-session-capability-probe.ts new file mode 100644 index 00000000000..37e96bbfbaf --- /dev/null +++ b/src/main/providers/ssh-agent-session-capability-probe.ts @@ -0,0 +1,7 @@ +import { JsonRpcErrorCode } from '../ssh/relay-protocol' + +export const SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS = 5_000 + +export function isSshCapabilityMethodUnavailable(error: unknown): boolean { + return (error as { code?: unknown })?.code === JsonRpcErrorCode.MethodNotFound +} diff --git a/src/main/providers/ssh-agent-session-claim-validation.ts b/src/main/providers/ssh-agent-session-claim-validation.ts index eaf33487a34..b3400f466f0 100644 --- a/src/main/providers/ssh-agent-session-claim-validation.ts +++ b/src/main/providers/ssh-agent-session-claim-validation.ts @@ -6,7 +6,10 @@ import { } from '../../shared/agent-session-host-authority' import type { PtySpawnResult } from './pty-spawn-result' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-create-operation' +import { + isSshCapabilityMethodUnavailable, + SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS +} from './ssh-agent-session-capability-probe' import { isPtyIncarnationId } from '../../shared/pty-incarnation' export type ClaimedSshSpawnValidation = @@ -17,17 +20,23 @@ export async function proveSshAgentSessionClaimCapability( mux: SshChannelMultiplexer, options: { signal?: AbortSignal } = {} ): Promise { + let result: { agentSessionClaimVersion?: unknown } try { - const result = (await mux.request('pty.getCapabilities', undefined, { + result = (await mux.request('pty.getCapabilities', undefined, { signal: options.signal, timeoutMs: SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS })) as { agentSessionClaimVersion?: unknown } - if (result.agentSessionClaimVersion !== AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION) { - throw new Error('unsupported') + } catch (error) { + if (isSshCapabilityMethodUnavailable(error)) { + throw new Error('agent_session_claim_unavailable') } - } catch { + throw Object.assign(new Error('agent_session_claim_unavailable'), { + capabilityProbeTransportFailure: true + }) + } + if (result.agentSessionClaimVersion !== AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION) { throw new Error('agent_session_claim_unavailable') } } diff --git a/src/main/providers/ssh-agent-session-create-operation.ts b/src/main/providers/ssh-agent-session-create-operation.ts index adc0eb264d8..57126acaab9 100644 --- a/src/main/providers/ssh-agent-session-create-operation.ts +++ b/src/main/providers/ssh-agent-session-create-operation.ts @@ -10,8 +10,10 @@ import { type PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' import { validateClaimedSshSpawn } from './ssh-agent-session-claim-validation' - -export const SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS = 5_000 +import { + isSshCapabilityMethodUnavailable, + SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS +} from './ssh-agent-session-capability-probe' export function assertSshAgentSessionCreateResult( result: unknown @@ -46,9 +48,11 @@ export async function sshSupportsAgentSessionCreateOperations( return ( result.agentSessionCreateOperationVersion === AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION ) - } catch { - // Why: capability probing does not spawn, so an old relay can safely keep legacy behavior. - return false + } catch (error) { + if (isSshCapabilityMethodUnavailable(error)) { + return false + } + throw error } } diff --git a/src/main/providers/ssh-launch-token-echo-capability.ts b/src/main/providers/ssh-launch-token-echo-capability.ts index afa80420746..0e5dfd74b44 100644 --- a/src/main/providers/ssh-launch-token-echo-capability.ts +++ b/src/main/providers/ssh-launch-token-echo-capability.ts @@ -1,6 +1,9 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { LAUNCH_TOKEN_ECHO_PROTOCOL_VERSION } from '../../shared/agent-launch-token-echo-protocol' -import { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-create-operation' +import { + isSshCapabilityMethodUnavailable, + SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS +} from './ssh-agent-session-capability-probe' export async function sshEchoesLaunchTokens( mux: SshChannelMultiplexer, @@ -12,8 +15,10 @@ export async function sshEchoesLaunchTokens( timeoutMs: SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS })) as { launchTokenEchoVersion?: unknown } return result.launchTokenEchoVersion === LAUNCH_TOKEN_ECHO_PROTOCOL_VERSION - } catch { - // Why: probing never spawns, so an unreachable or old relay just keeps the tokenless path. - return false + } catch (error) { + if (isSshCapabilityMethodUnavailable(error)) { + return false + } + throw error } } diff --git a/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts b/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts index e088b21f025..b19fbddc8a9 100644 --- a/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts +++ b/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts @@ -158,14 +158,14 @@ describe('SSH fresh agent-session create operations', () => { ) }) - it('re-probes a negative capability after an in-place relay upgrade', async () => { + it('caches an old-relay negative for the provider connection', async () => { request.mockResolvedValueOnce({}).mockResolvedValueOnce({ agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION }) await expect(provider.supportsAgentSessionCreateOperations()).resolves.toBe(false) - await expect(provider.supportsAgentSessionCreateOperations()).resolves.toBe(true) - expect(request).toHaveBeenCalledTimes(2) + await expect(provider.supportsAgentSessionCreateOperations()).resolves.toBe(false) + expect(request).toHaveBeenCalledOnce() }) it('keeps a shared capability probe alive when one waiter disconnects', async () => { diff --git a/src/main/providers/ssh-pty-provider-launch-token-echo.test.ts b/src/main/providers/ssh-pty-provider-launch-token-echo.test.ts index 4fcc4d79594..2b94e3d4188 100644 --- a/src/main/providers/ssh-pty-provider-launch-token-echo.test.ts +++ b/src/main/providers/ssh-pty-provider-launch-token-echo.test.ts @@ -30,9 +30,13 @@ describe('SSH launch-token echo negotiation', () => { ) await provider.spawn({ cols: 80, rows: 24, command: 'claude', launchToken: 'tok-1' }) + await provider.spawn({ cols: 80, rows: 24, command: 'claude', launchToken: 'tok-2' }) expect('launchToken' in spawnParams()).toBe(false) expect(provider.providesLaunchTokenListings()).toBe(false) + expect(request.mock.calls.filter(([method]) => method === 'pty.getCapabilities')).toHaveLength( + 1 + ) }) it('sends the token once the relay advertises the echo', async () => { @@ -59,13 +63,48 @@ describe('SSH launch-token echo negotiation', () => { expect('launchToken' in spawnParams()).toBe(false) }) - it('re-probes a negative echo capability after an in-place relay upgrade', async () => { + it('caches an old-relay negative for the provider connection', async () => { request.mockResolvedValueOnce({}).mockResolvedValueOnce({ launchTokenEchoVersion: LAUNCH_TOKEN_ECHO_PROTOCOL_VERSION }) + await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(false) + await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(false) + expect(request).toHaveBeenCalledOnce() + }) + + it('caches an old relay without the capability method', async () => { + request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) + + await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(false) + await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(false) + expect(request).toHaveBeenCalledOnce() + }) + + it('retries an unverifiable transport failure on the same connection', async () => { + request.mockRejectedValueOnce(new Error('connection stalled')).mockResolvedValueOnce({ + launchTokenEchoVersion: LAUNCH_TOKEN_ECHO_PROTOCOL_VERSION + }) + await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(false) await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(true) expect(request).toHaveBeenCalledTimes(2) }) + + it('re-probes capabilities on a replacement provider connection', async () => { + request.mockResolvedValueOnce({}).mockResolvedValueOnce({ + launchTokenEchoVersion: LAUNCH_TOKEN_ECHO_PROTOCOL_VERSION + }) + + await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(false) + const replacement = new SshPtyProvider('conn-1', { + request, + notify: vi.fn(), + onNotification: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn(() => false) + } as never) + await expect(replacement.supportsLaunchTokenEcho()).resolves.toBe(true) + expect(request).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 166f926a809..d2cbfb562bd 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -50,6 +50,24 @@ describe('SshPtyProvider', () => { expect(mux.request).toHaveBeenCalledOnce() }) + it('caches an old-relay claim negative for the provider connection', async () => { + mux.request.mockResolvedValue({}) + + await expect(provider.supportsAgentSessionClaims()).resolves.toBe(false) + await expect(provider.supportsAgentSessionClaims()).resolves.toBe(false) + expect(mux.request).toHaveBeenCalledOnce() + }) + + it('retries an unverifiable claim probe transport failure', async () => { + mux.request.mockRejectedValueOnce(new Error('connection stalled')).mockResolvedValueOnce({ + agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION + }) + + await expect(provider.supportsAgentSessionClaims()).resolves.toBe(false) + await expect(provider.supportsAgentSessionClaims()).resolves.toBe(true) + expect(mux.request).toHaveBeenCalledTimes(2) + }) + it('attach sends pty.attach request', async () => { await provider.attach(scopedPty1) expectRequest(mux.request, 'pty.attach', { id: 'pty-1' }) diff --git a/src/main/providers/ssh-pty-spawn-dispatch.ts b/src/main/providers/ssh-pty-spawn-dispatch.ts index b3e87fab587..fe6dd9649a3 100644 --- a/src/main/providers/ssh-pty-spawn-dispatch.ts +++ b/src/main/providers/ssh-pty-spawn-dispatch.ts @@ -90,9 +90,8 @@ async function spawnFreshSession( ): Promise { // Why probed before dispatch: an old relay accepts the token and never re-lists it, so // withhold it and let reconciliation keep its pre-token identification for this host. - // Concurrent because the two probes memoize separately and each evicts on a negative - // answer — serialized, a create-capable pre-echo relay paid two full `pty.getCapabilities` - // round-trips (5 s ceiling each) on every fresh spawn. + // Concurrent because the two capabilities are independent; their results are + // cached for this provider generation, including definitive old-relay negatives. // Skipped entirely when nothing is probed: awaiting settled promises would push a plain // spawn's `pty.spawn` dispatch out of the caller's turn. const [supportsCreateOperation, supportsLaunchTokenEcho] = diff --git a/src/main/runtime/orca-runtime-automation-launch-classify.test.ts b/src/main/runtime/orca-runtime-automation-launch-classify.test.ts index 69a07978313..1ea0506c31d 100644 --- a/src/main/runtime/orca-runtime-automation-launch-classify.test.ts +++ b/src/main/runtime/orca-runtime-automation-launch-classify.test.ts @@ -6,8 +6,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { Repo } from '../../shared/types' +import { homedir, tmpdir } from 'node:os' +import type { CustomTuiAgentId, Repo } from '../../shared/types' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { OrcaRuntimeService } from './orca-runtime' const testState = { dir: '' } @@ -26,6 +27,7 @@ vi.mock('electron', () => ({ async function createStore() { vi.resetModules() + installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('../persistence') initDataPath() return new Store() @@ -38,6 +40,7 @@ const REPO: Repo = { badgeColor: '#fff', addedAt: 1 } +const CUSTOM_AGENT_ID: CustomTuiAgentId = 'custom-agent:claude:01234567-89ab-4cde-8f01-23456789abcd' describe('OrcaRuntimeService.classifyAgentLaunchForAutomation (U6)', () => { beforeEach(() => { @@ -54,7 +57,7 @@ describe('OrcaRuntimeService.classifyAgentLaunchForAutomation (U6)', () => { store.updateSettings({ disabledTuiAgents: ['claude'] }) const runtime = new OrcaRuntimeService(store) - const failure = runtime.classifyAgentLaunchForAutomation('claude', REPO, 'run-1') + const failure = runtime.classifyAgentLaunchForAutomation('claude', REPO, 'run-1', '/repo') expect(failure).not.toBeNull() expect(failure?.code).toBe('base_agent_disabled') @@ -70,6 +73,71 @@ describe('OrcaRuntimeService.classifyAgentLaunchForAutomation (U6)', () => { store.addRepo(REPO) const runtime = new OrcaRuntimeService(store) - expect(runtime.classifyAgentLaunchForAutomation('claude', REPO, 'run-1')).toBeNull() + expect(runtime.classifyAgentLaunchForAutomation('claude', REPO, 'run-1', '/repo')).toBeNull() + }) + + it('classifies custom agents with automation repo and worktree variables', async () => { + const store = await createStore() + store.addRepo(REPO) + store.updateSettings({ + customTuiAgents: [ + { + id: CUSTOM_AGENT_ID, + baseAgent: 'claude', + label: 'Context Agent', + args: '--repo {repoPath} --worktree {worktreePath}', + env: { AUTOMATION_ROOT: '{worktreePath}' }, + syncEnv: false + } + ] + }) + const runtime = new OrcaRuntimeService(store) + + expect( + runtime.classifyAgentLaunchForAutomation( + CUSTOM_AGENT_ID, + REPO, + 'run-context', + '/repo/worktrees/context' + ) + ).toBeNull() + }) + + it('classifies local custom command overrides against the target home', async () => { + const store = await createStore() + store.addRepo(REPO) + store.updateSettings({ + customTuiAgents: [ + { + id: CUSTOM_AGENT_ID, + baseAgent: 'claude', + label: 'Home Agent', + commandOverride: '~/my agent/bin/claude', + args: '', + env: {}, + syncEnv: false + } + ] + }) + const runtime = new OrcaRuntimeService(store) + + expect( + runtime.classifyAgentLaunchForAutomation(CUSTOM_AGENT_ID, REPO, 'run-home', '/repo') + ).toBeNull() + }) + + it('keeps the legacy resolve-only target home unknown', async () => { + const store = await createStore() + store.addRepo(REPO) + const runtime = new OrcaRuntimeService(store) + const internals = runtime as unknown as { + buildResolveOnlySpawnTarget: ( + repo: Repo, + includeLocalTargetHome?: boolean + ) => { targetHomePath: string | null } + } + + expect(internals.buildResolveOnlySpawnTarget(REPO).targetHomePath).toBeNull() + expect(internals.buildResolveOnlySpawnTarget(REPO, true).targetHomePath).toBe(homedir()) }) }) diff --git a/src/main/runtime/terminal-agent-launch-resolution.test.ts b/src/main/runtime/terminal-agent-launch-resolution.test.ts index 15b9dc3dea2..fa8bd6b4167 100644 --- a/src/main/runtime/terminal-agent-launch-resolution.test.ts +++ b/src/main/runtime/terminal-agent-launch-resolution.test.ts @@ -135,7 +135,7 @@ describe('resolveTerminalAgentLaunch', () => { followupPrompt: 'go' }) // Detection ran against the target descriptor. - expect(detectStockBaseAgents).toHaveBeenCalledWith(DESCRIPTOR) + expect(detectStockBaseAgents).toHaveBeenCalledWith(DESCRIPTOR, ['claude']) // Trust preflight marked the workspace for the resolved launch before admission. expect(trusted).toHaveLength(1) expect(trusted[0]!.baseAgent).toBe('claude') @@ -280,7 +280,7 @@ describe('resolveTerminalAgentLaunch target-host planning (U7 oracle-14)', () => expect(captured!.shell).toBe(shell) expect(captured!.isRemote).toBe(descriptor.kind === 'ssh') // Stock detection ran against the TARGET descriptor, not a client host. - expect(detectStockBaseAgents).toHaveBeenCalledWith(descriptor) + expect(detectStockBaseAgents).toHaveBeenCalledWith(descriptor, ['claude']) } ) }) diff --git a/src/main/runtime/terminal-agent-launch-resolution.ts b/src/main/runtime/terminal-agent-launch-resolution.ts index 2061b14f37d..0c4547925d4 100644 --- a/src/main/runtime/terminal-agent-launch-resolution.ts +++ b/src/main/runtime/terminal-agent-launch-resolution.ts @@ -10,6 +10,7 @@ // so it is unit-testable. import type { BuiltInTuiAgent, GlobalSettings, Repo } from '../../shared/types' +import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' import type { AgentLaunchReceipt } from '../../shared/agent-launch-contract' import type { AgentLaunchInput, @@ -27,6 +28,7 @@ import type { } from '../../shared/agent-session-resume' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { + detectionBaseAgentsForLaunch, deriveAgentLaunchHostState, type AgentLaunchHostDescriptor } from '../agent-launch/agent-launch-host-state' @@ -86,9 +88,13 @@ export type TerminalAgentLaunchDeps = { getSettings: () => GlobalSettings getCatalogRevision: () => number detectStockBaseAgents: ( - descriptor: AgentLaunchHostDescriptor + descriptor: AgentLaunchHostDescriptor, + baseAgents?: readonly BuiltInTuiAgent[] ) => Promise resolveTargetHomePath: (descriptor: AgentLaunchHostDescriptor) => Promise + resolveStartupShell?: ( + descriptor: AgentLaunchHostDescriptor + ) => Promise /** Best-effort workspace trust for the resolved base agent, run as the * boundary's pre-admission preflight. Must not throw for a routine no-trust * agent; a throw maps to trust_preflight_failed with no admission record. */ @@ -174,20 +180,26 @@ export async function resolveTerminalAgentLaunch( deps: TerminalAgentLaunchDeps, args: TerminalAgentLaunchArgs ): Promise { + const spawnInput = resolveTerminalSpawnInput(args, deps.sessionRecordStore) + if (!spawnInput.ok) { + return { kind: 'failed', outcome: { status: 'failed', failure: spawnInput.failure } } + } + const detectionBaseAgents = detectionBaseAgentsForLaunch( + spawnInput.input.request, + spawnInput.input.persistedSnapshot + ) const hostState = await deriveAgentLaunchHostState( { getSettings: deps.getSettings, getCatalogRevision: deps.getCatalogRevision, detectStockBaseAgents: deps.detectStockBaseAgents, - resolveTargetHomePath: deps.resolveTargetHomePath + resolveTargetHomePath: deps.resolveTargetHomePath, + ...(deps.resolveStartupShell ? { resolveStartupShell: deps.resolveStartupShell } : {}) }, args.descriptor, - { worktreePath: args.worktreePath, repoPath: args.repoPath } + { worktreePath: args.worktreePath, repoPath: args.repoPath }, + detectionBaseAgents === undefined ? {} : { detectionBaseAgents } ) - const spawnInput = resolveTerminalSpawnInput(args, deps.sessionRecordStore) - if (!spawnInput.ok) { - return { kind: 'failed', outcome: { status: 'failed', failure: spawnInput.failure } } - } const resolution = await resolveAgentLaunchSpawn( { getSettings: hostState.getSettings, diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index a3d4276d2a2..46c57647a6d 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -80,7 +80,10 @@ const WorktreeCard = React.memo(function WorktreeCard({ return ( <> - + ) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardBackgroundLaunchFailures.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardBackgroundLaunchFailures.test.tsx index 3d9ed44915e..dce4ceb6b29 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardBackgroundLaunchFailures.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardBackgroundLaunchFailures.test.tsx @@ -11,11 +11,12 @@ import type { import type { BackgroundAgentLaunchAttempt } from '../../../../shared/background-agent-launch' const WORKTREE_ID = 'repo1::/tmp/wt' +const HOST_ID = 'ssh:devbox' as const type WorktreeShape = { backgroundAgentLaunches?: BackgroundAgentLaunchAttempt[] } const storeBox = vi.hoisted(() => ({ state: null as unknown })) -const worktreeBox = vi.hoisted(() => ({ worktree: null as WorktreeShape | null })) +const worktreeBox = vi.hoisted(() => ({ byHost: new Map() })) // Holds what the soft confirm hook returns; a null value reproduces a // provider-less render (the crash class that took out the WorktreeCard family). const confirmBox = vi.hoisted(() => ({ value: null as unknown })) @@ -41,8 +42,13 @@ vi.mock('@/components/confirmation-dialog-context', () => ({ })) vi.mock('@/store/selectors', () => ({ - getWorktreeMapFromState: () => - new Map(worktreeBox.worktree ? [[WORKTREE_ID, worktreeBox.worktree]] : []) + getWorktreeOnHostFromState: (_state: unknown, _worktreeId: string, hostId: string) => + worktreeBox.byHost.get(hostId) +})) + +vi.mock('@/lib/worktree-operation-route', () => ({ + resolveWorktreeOperationRouteForHost: vi.fn(), + settingsForWorktreeOperationRoute: vi.fn() })) function failure(code: AgentLaunchFailureCode): PersistedAgentLaunchFailure { @@ -75,7 +81,9 @@ async function render(): Promise { const root = createRoot(container) mountedRoots.push(root) await act(async () => { - root.render() + root.render( + + ) }) } @@ -99,7 +107,7 @@ beforeEach(() => { mocks.forgetUnknownAgentLaunchSiblings.mockResolvedValue({ forgottenCount: 0 }) mocks.confirm.mockResolvedValue(true) confirmBox.value = mocks.confirm - worktreeBox.worktree = null + worktreeBox.byHost.clear() storeBox.state = { // The container guards a missing worktreesByRepo slice (partial sibling-suite // mocks) before projecting meta; production always carries it, so seed a @@ -127,6 +135,26 @@ describe('WorktreeCardBackgroundLaunchFailures', () => { expect(document.body.querySelector('[role="alert"]')).toBeNull() }) + it('selects the attempt row on the card host when worktree ids collide', async () => { + worktreeBox.byHost.set('local', { + backgroundAgentLaunches: [attempt({ failure: failure('unknown_agent') })] + }) + worktreeBox.byHost.set(HOST_ID, { + backgroundAgentLaunches: [ + attempt({ state: 'pending', failure: failure('launch_state_unknown') }) + ] + }) + + await render() + + expect(buttonByLabel('Reconnect')).toBeTruthy() + expect( + [...document.body.querySelectorAll('button')].some( + (button) => button.textContent === 'Choose agent' + ) + ).toBe(false) + }) + it('renders nothing without throwing when the store omits the worktreesByRepo slice', async () => { // Reproduces the minimal sibling-suite store shape that made the indexed // worktree selector throw on Object.values(undefined) during G6's full run. @@ -141,18 +169,18 @@ describe('WorktreeCardBackgroundLaunchFailures', () => { }) it('renders nothing for launched/forgotten attempts (no surfacing failure)', async () => { - worktreeBox.worktree = { + worktreeBox.byHost.set(HOST_ID, { backgroundAgentLaunches: [ attempt({ attemptId: 'a', state: 'launched', failure: null }), attempt({ attemptId: 'b', state: 'forgotten', failure: failure('spawn_failed') }) ] - } + }) await render() expect(document.body.querySelector('[role="alert"]')).toBeNull() }) it('retries a failed attempt against its failure id, keyed by the attempt id', async () => { - worktreeBox.worktree = { backgroundAgentLaunches: [attempt()] } + worktreeBox.byHost.set(HOST_ID, { backgroundAgentLaunches: [attempt()] }) await render() await act(async () => { buttonByLabel('Retry').click() @@ -160,17 +188,18 @@ describe('WorktreeCardBackgroundLaunchFailures', () => { expect(mocks.retryBackgroundAgentLaunch).toHaveBeenCalledExactlyOnceWith({ attemptId: 'attempt-1', worktreeId: WORKTREE_ID, + executionHostId: HOST_ID, expectedFailureId: 'failure-7', action: { kind: 'retry-same' } }) }) it('forgets an unknown attempt after the destructive confirmation, using its operation id as the guard', async () => { - worktreeBox.worktree = { + worktreeBox.byHost.set(HOST_ID, { backgroundAgentLaunches: [ attempt({ state: 'pending', failure: failure('launch_state_unknown') }) ] - } + }) await render() await act(async () => { buttonByLabel('Forget launch…').click() @@ -185,17 +214,18 @@ describe('WorktreeCardBackgroundLaunchFailures', () => { expect(mocks.forgetBackgroundAgentLaunch).toHaveBeenCalledExactlyOnceWith({ attemptId: 'attempt-1', worktreeId: WORKTREE_ID, + executionHostId: HOST_ID, expectedOperationId: 'op-9' }) }) it('does not forget an unknown attempt when the destructive confirmation is declined', async () => { mocks.confirm.mockResolvedValue(false) - worktreeBox.worktree = { + worktreeBox.byHost.set(HOST_ID, { backgroundAgentLaunches: [ attempt({ state: 'pending', failure: failure('launch_state_unknown') }) ] - } + }) await render() await act(async () => { buttonByLabel('Forget launch…').click() @@ -212,17 +242,18 @@ describe('WorktreeCardBackgroundLaunchFailures', () => { return true } ) - worktreeBox.worktree = { + worktreeBox.byHost.set(HOST_ID, { backgroundAgentLaunches: [ attempt({ state: 'pending', failure: failure('launch_state_unknown') }) ] - } + }) await render() await act(async () => { buttonByLabel('Forget launch…').click() }) expect(mocks.unknownAgentLaunchSiblingPreflight).toHaveBeenCalledExactlyOnceWith({ - worktreeId: WORKTREE_ID + worktreeId: WORKTREE_ID, + executionHostId: HOST_ID }) expect(mocks.confirm).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ @@ -234,20 +265,22 @@ describe('WorktreeCardBackgroundLaunchFailures', () => { expect(mocks.forgetBackgroundAgentLaunch).toHaveBeenCalledExactlyOnceWith({ attemptId: 'attempt-1', worktreeId: WORKTREE_ID, + executionHostId: HOST_ID, expectedOperationId: 'op-9' }) expect(mocks.forgetUnknownAgentLaunchSiblings).toHaveBeenCalledExactlyOnceWith({ - worktreeId: WORKTREE_ID + worktreeId: WORKTREE_ID, + executionHostId: HOST_ID }) }) it('omits the opt-in and still forgets the attempt when the sibling preflight fails', async () => { mocks.unknownAgentLaunchSiblingPreflight.mockRejectedValue(new Error('unreachable')) - worktreeBox.worktree = { + worktreeBox.byHost.set(HOST_ID, { backgroundAgentLaunches: [ attempt({ state: 'pending', failure: failure('launch_state_unknown') }) ] - } + }) await render() await act(async () => { buttonByLabel('Forget launch…').click() @@ -265,11 +298,11 @@ describe('WorktreeCardBackgroundLaunchFailures', () => { // The card must still render and the destructive forget must not fire // unconfirmed, rather than throwing and crashing the whole family. confirmBox.value = null - worktreeBox.worktree = { + worktreeBox.byHost.set(HOST_ID, { backgroundAgentLaunches: [ attempt({ state: 'pending', failure: failure('launch_state_unknown') }) ] - } + }) await render() expect(document.body.querySelector('[role="alert"]')).not.toBeNull() await act(async () => { @@ -280,11 +313,11 @@ describe('WorktreeCardBackgroundLaunchFailures', () => { }) it('routes reconnect on an unknown attempt to the ssh settings pane', async () => { - worktreeBox.worktree = { + worktreeBox.byHost.set(HOST_ID, { backgroundAgentLaunches: [ attempt({ state: 'pending', failure: failure('launch_state_unknown') }) ] - } + }) await render() await act(async () => { buttonByLabel('Reconnect').click() @@ -294,9 +327,9 @@ describe('WorktreeCardBackgroundLaunchFailures', () => { }) it('routes selection recovery to the desktop-host agents settings pane', async () => { - worktreeBox.worktree = { + worktreeBox.byHost.set(HOST_ID, { backgroundAgentLaunches: [attempt({ failure: failure('unknown_agent') })] - } + }) await render() await act(async () => { buttonByLabel('Choose agent').click() @@ -309,13 +342,13 @@ describe('WorktreeCardBackgroundLaunchFailures', () => { }) it('renders one card per surfacing attempt', async () => { - worktreeBox.worktree = { + worktreeBox.byHost.set(HOST_ID, { backgroundAgentLaunches: [ attempt({ attemptId: 'a' }), attempt({ attemptId: 'b', state: 'pending', failure: failure('launch_state_unknown') }), attempt({ attemptId: 'c', state: 'launched', failure: null }) ] - } + }) await render() expect(document.body.querySelectorAll('[role="alert"]').length).toBe(2) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardBackgroundLaunchFailures.tsx b/src/renderer/src/components/sidebar/WorktreeCardBackgroundLaunchFailures.tsx index 0534673053e..b32bd50b83e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardBackgroundLaunchFailures.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardBackgroundLaunchFailures.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react' import { useAppStore } from '@/store' -import { getWorktreeMapFromState } from '@/store/selectors' +import { getWorktreeOnHostFromState } from '@/store/selectors' import { useOptionalConfirmationDialog } from '@/components/confirmation-dialog-context' import { WorktreeAgentLaunchFailure } from './WorktreeAgentLaunchFailure' import { @@ -15,11 +15,15 @@ import { AGENTS_SETTINGS_ACTIONS, RETRY_SAME_ACTIONS } from '@/lib/agent-launch-recovery-action-dispatch' -import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' +import { + resolveWorktreeOperationRouteForHost, + settingsForWorktreeOperationRoute +} from '@/lib/worktree-operation-route' import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' import type { AgentLaunchRecoveryActionId } from '@/lib/agent-launch-recovery-card' import type { AppState } from '@/store/types' import type { BackgroundAgentLaunchAttempt } from '../../../../shared/background-agent-launch' +import type { ExecutionHostId } from '../../../../shared/execution-host' // Stable empty reference so the selector never returns a fresh array identity and // re-renders in a loop when a worktree carries no background attempts. @@ -31,12 +35,16 @@ const NO_ATTEMPTS: readonly BackgroundAgentLaunchAttempt[] = [] // Object.values(undefined)) rather than hardening the shared selector. function selectBackgroundAttempts( state: AppState, - worktreeId: string + worktreeId: string, + executionHostId: ExecutionHostId ): readonly BackgroundAgentLaunchAttempt[] { if (!state.worktreesByRepo) { return NO_ATTEMPTS } - return getWorktreeMapFromState(state).get(worktreeId)?.backgroundAgentLaunches ?? NO_ATTEMPTS + return ( + getWorktreeOnHostFromState(state, worktreeId, executionHostId)?.backgroundAgentLaunches ?? + NO_ATTEMPTS + ) } type SurfacedCard = { @@ -53,11 +61,13 @@ type SurfacedCard = { * reconciles launched/forgotten back out of the meta, so this holds no attempt * state of its own. Renders nothing until an attempt has a surfacing failure. */ export function WorktreeCardBackgroundLaunchFailures({ - worktreeId + worktreeId, + executionHostId }: { worktreeId: string + executionHostId: ExecutionHostId }): React.JSX.Element | null { - const attempts = useAppStore((s) => selectBackgroundAttempts(s, worktreeId)) + const attempts = useAppStore((s) => selectBackgroundAttempts(s, worktreeId, executionHostId)) const retryBackgroundAgentLaunch = useAppStore((s) => s.retryBackgroundAgentLaunch) const forgetBackgroundAgentLaunch = useAppStore((s) => s.forgetBackgroundAgentLaunch) const unknownAgentLaunchSiblingPreflight = useAppStore( @@ -96,6 +106,7 @@ export function WorktreeCardBackgroundLaunchFailures({ await retryBackgroundAgentLaunch({ attemptId: attempt.attemptId, worktreeId, + executionHostId, expectedFailureId, action: { kind: 'retry-same' } }) @@ -117,7 +128,10 @@ export function WorktreeCardBackgroundLaunchFailures({ let siblingCount = 0 let siblingHostName = '' try { - const preflight = await unknownAgentLaunchSiblingPreflight({ worktreeId }) + const preflight = await unknownAgentLaunchSiblingPreflight({ + worktreeId, + executionHostId + }) siblingCount = preflight.count siblingHostName = preflight.hostName } catch { @@ -153,13 +167,14 @@ export function WorktreeCardBackgroundLaunchFailures({ await forgetBackgroundAgentLaunch({ attemptId: attempt.attemptId, worktreeId, + executionHostId, expectedOperationId: attempt.operationId }) // The bulk is worktree-scoped and clears only interactive siblings (the // structural guarantee keeps background-owned rows out of the count), so it // rides after the single attempt forget. if (forgetSiblings) { - await forgetUnknownAgentLaunchSiblings({ worktreeId }) + await forgetUnknownAgentLaunchSiblings({ worktreeId, executionHostId }) } } finally { setBusy(attempt.attemptId, false) @@ -179,17 +194,20 @@ export function WorktreeCardBackgroundLaunchFailures({ if (id === 'recover-capacity') { // Query the host that rejected the launch: this worktree's runtime owner, // not the local host (whose summary knows nothing of a remote's capacity). - openModal('agent-launch-capacity-recovery', { - target: getActiveRuntimeTarget( - getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId) - ) - }) + const state = useAppStore.getState() + const route = resolveWorktreeOperationRouteForHost(state, worktreeId, executionHostId) + if (route) { + openModal('agent-launch-capacity-recovery', { + target: getActiveRuntimeTarget(settingsForWorktreeOperationRoute(state.settings, route)) + }) + } } // open-terminal routes to an affordance not owned by this wave; the no-op // keeps the card honest rather than firing a wrong action. }, [ worktreeId, + executionHostId, confirm, setBusy, retryBackgroundAgentLaunch, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-agent-launch-result.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-agent-launch-result.ts new file mode 100644 index 00000000000..6d2f9e76ebc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-agent-launch-result.ts @@ -0,0 +1,54 @@ +import { useAppStore } from '@/store' +import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast' +import type { PtyConnectResult } from '../pty-transport' +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' + +export function applyFreshSpawnAgentLaunchResult( + session: ConnectPanePtySession, + result: PtyConnectResult, + coldRestore: ColdRestoreAgentResumeStartup | null +): void { + const receipt = result.agentLaunch?.status === 'launched' ? result.agentLaunch.receipt : null + if (receipt) { + session.launchToken = receipt.launchToken + useAppStore.getState().backfillTabLaunchAgent(session.deps.tabId, receipt.requestedAgent) + if (receipt.notices.length > 0) { + useAppStore.getState().attachLaunchNotices({ + worktreeId: session.deps.worktreeId, + tabId: session.deps.tabId, + launchToken: receipt.launchToken, + notices: receipt.notices + }) + } + } + if (result.launchNotices) { + useAppStore.getState().attachLaunchNotices({ + worktreeId: session.deps.worktreeId, + tabId: session.deps.tabId, + launchToken: result.launchNotices.launchToken, + notices: result.launchNotices.notices + }) + } + session.registerEffectiveLaunchConfig(result.launchConfig, { + ...(coldRestore?.launchToken ? { launchToken: coldRestore.launchToken } : {}), + ...(receipt ? { launchToken: receipt.launchToken } : {}), + ...(coldRestore ? { launchAgent: coldRestore.agent } : {}), + ...(receipt ? { launchAgent: receipt.baseAgent } : {}) + }) + const followupPrompt = result.followupPrompt ?? null + const prompt = followupPrompt ?? result.draftPrompt ?? null + const agent = receipt?.baseAgent ?? session.paneStartup?.launchAgent + if (session.startupDraftPromptNeedsPaste || !agent || !prompt) { + return + } + void pasteDraftWhenAgentReady({ + tabId: session.deps.tabId, + content: prompt, + agent, + submit: followupPrompt !== null, + forcePaste: true, + onTimeout: () => showAutomationPromptNotSentToast(agent) + }).catch(() => {}) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index 1b16ca40595..7cc448aaa67 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -2,14 +2,13 @@ import { useAppStore } from '@/store' import { hasPtySerializer } from '../pty-buffer-serializer' import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { agentLaunchOutcomeErrorMessage } from '@/lib/agent-launch-failure-copy' -import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' -import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast' import { STARTUP_CWD_FALLBACK_NOTICE } from './startup-cwd-fallback-notice' import { pendingSpawnByPaneKey, pendingSpawnGenerationByPaneKey } from './pty-connect-limits' import { shouldWritePtyOutputForeground } from './foreground-output-scan' import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' import { toProcessExitStartup } from './process-exit-startup' +import { applyFreshSpawnAgentLaunchResult } from './fresh-spawn-agent-launch-result' import type { PendingStartupCommand, FreshSpawnOptions, @@ -118,13 +117,10 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { : {}), ...(coldRestoreOverride?.legacyResumeRecordedConnectionId !== undefined ? { - legacyResumeRecordedConnectionId: - coldRestoreOverride.legacyResumeRecordedConnectionId + legacyResumeRecordedConnectionId: coldRestoreOverride.legacyResumeRecordedConnectionId } : {}), - ...(coldRestoreOverride?.launchToken - ? { launchToken: coldRestoreOverride.launchToken } - : {}), + ...(coldRestoreOverride?.launchToken ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), ...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), shouldContinue: () => { @@ -245,56 +241,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { return accepted ? resolvedPtyId : null } if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { - const launchedReceipt = - spawnedPtyId.agentLaunch?.status === 'launched' - ? spawnedPtyId.agentLaunch.receipt - : null - if (launchedReceipt) { - session.launchToken = launchedReceipt.launchToken - useAppStore - .getState() - .backfillTabLaunchAgent(session.deps.tabId, launchedReceipt.requestedAgent) - if (launchedReceipt.notices.length > 0) { - useAppStore.getState().attachLaunchNotices({ - worktreeId: session.deps.worktreeId, - tabId: session.deps.tabId, - launchToken: launchedReceipt.launchToken, - notices: launchedReceipt.notices - }) - } - } - if (spawnedPtyId.launchNotices) { - useAppStore.getState().attachLaunchNotices({ - worktreeId: session.deps.worktreeId, - tabId: session.deps.tabId, - launchToken: spawnedPtyId.launchNotices.launchToken, - notices: spawnedPtyId.launchNotices.notices - }) - } - session.registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, { - ...(coldRestoreOverride?.launchToken - ? { launchToken: coldRestoreOverride.launchToken } - : {}), - ...(launchedReceipt ? { launchToken: launchedReceipt.launchToken } : {}), - ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), - ...(launchedReceipt ? { launchAgent: launchedReceipt.baseAgent } : {}) - }) - const hostFollowupPrompt = spawnedPtyId.followupPrompt ?? null - const hostDraftPrompt = spawnedPtyId.draftPrompt ?? null - const hostDeliveredAgent = launchedReceipt?.baseAgent ?? session.paneStartup?.launchAgent - if (!session.startupDraftPromptNeedsPaste && hostDeliveredAgent) { - const hostDeliveredPrompt = hostFollowupPrompt ?? hostDraftPrompt - if (hostDeliveredPrompt) { - void pasteDraftWhenAgentReady({ - tabId: session.deps.tabId, - content: hostDeliveredPrompt, - agent: hostDeliveredAgent, - submit: hostFollowupPrompt !== null, - forcePaste: true, - onTimeout: () => showAutomationPromptNotSentToast(hostDeliveredAgent) - }).catch(() => {}) - } - } + applyFreshSpawnAgentLaunchResult(session, spawnedPtyId, coldRestoreOverride) } if (resolvedPtyId) { if ( diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 1cfcf73b928..a5602d63632 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -237,19 +237,23 @@ export type WorktreeSlice = { retryBackgroundAgentLaunch: (args: { attemptId: string worktreeId: string + executionHostId?: ExecutionHostId expectedFailureId: string action: RetryAgentLaunchAction }) => Promise forgetBackgroundAgentLaunch: (args: { attemptId: string worktreeId: string + executionHostId?: ExecutionHostId expectedOperationId: string }) => Promise unknownAgentLaunchSiblingPreflight: (args: { worktreeId: string + executionHostId?: ExecutionHostId }) => Promise<{ count: number; hostName: string }> forgetUnknownAgentLaunchSiblings: (args: { worktreeId: string + executionHostId?: ExecutionHostId }) => Promise<{ forgottenCount: number }> fetchPendingAgentLaunchSummary: ( target?: RuntimeClientTarget diff --git a/src/renderer/src/store/slices/worktrees/worktree-agent-launch-actions-host-routing.test.ts b/src/renderer/src/store/slices/worktrees/worktree-agent-launch-actions-host-routing.test.ts new file mode 100644 index 00000000000..13d2397659e --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/worktree-agent-launch-actions-host-routing.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' +import { createWorktreeAgentLaunchActions } from './worktree-agent-launch-actions' + +const mocks = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn(), + getActiveRuntimeTarget: vi.fn((settings: { activeRuntimeEnvironmentId?: string | null }) => + settings.activeRuntimeEnvironmentId + ? { kind: 'environment' as const, environmentId: settings.activeRuntimeEnvironmentId } + : { kind: 'local' as const } + ) +})) + +vi.mock('../../../runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mocks.callRuntimeRpc, + getActiveRuntimeTarget: mocks.getActiveRuntimeTarget +})) + +vi.mock('@/lib/browser-uuid', () => ({ + createBrowserUuid: () => 'mutation-1' +})) + +describe('background agent launch recovery host routing', () => { + it('routes a same-id card action to its explicit runtime host', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ status: 'launched', receipt: {} }) + const get = () => ({ + settings: { activeRuntimeEnvironmentId: 'focused-host' }, + runtimeEnvironments: [], + worktreesByRepo: {}, + detectedWorktreesByRepo: {} + }) + const actions = createWorktreeAgentLaunchActions(vi.fn() as never, get as never) + + await actions.retryBackgroundAgentLaunch({ + attemptId: 'attempt-1', + worktreeId: 'repo::/same/path', + executionHostId: 'runtime:card-host', + expectedFailureId: 'failure-1', + action: { kind: 'retry-same' } + }) + + expect(mocks.callRuntimeRpc).toHaveBeenCalledExactlyOnceWith( + { kind: 'environment', environmentId: 'card-host' }, + 'worktree.retryBackgroundAgentLaunch', + { + attemptId: 'attempt-1', + expectedFailureId: 'failure-1', + clientMutationId: 'mutation-1', + action: { kind: 'retry-same' } + }, + { timeoutMs: 10 * 60_000 } + ) + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/worktree-agent-launch-actions.ts b/src/renderer/src/store/slices/worktrees/worktree-agent-launch-actions.ts index ee9713e48ba..4d641231411 100644 --- a/src/renderer/src/store/slices/worktrees/worktree-agent-launch-actions.ts +++ b/src/renderer/src/store/slices/worktrees/worktree-agent-launch-actions.ts @@ -14,6 +14,26 @@ import { toRuntimeWorktreeSelector } from '../../../runtime/runtime-worktree-sel import { getRepoIdFromWorktreeId } from '../worktree-helpers' import { settingsForRepoOwner } from './listing/worktree-owner-settings' import { createBrowserUuid } from '@/lib/browser-uuid' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import { + resolveWorktreeOperationRouteForHost, + settingsForWorktreeOperationRoute +} from '@/lib/worktree-operation-route' + +function targetForBackgroundRecovery( + state: ReturnType, + worktreeId: string, + executionHostId?: ExecutionHostId +): RuntimeClientTarget { + if (executionHostId) { + const route = resolveWorktreeOperationRouteForHost(state, worktreeId, executionHostId) + if (!route) { + throw new Error('The workspace host is no longer available.') + } + return getActiveRuntimeTarget(settingsForWorktreeOperationRoute(state.settings, route)) + } + return getActiveRuntimeTarget(settingsForRepoOwner(state, getRepoIdFromWorktreeId(worktreeId))) +} export function createWorktreeAgentLaunchActions( _set: WorktreeSliceSet, @@ -77,10 +97,15 @@ export function createWorktreeAgentLaunchActions( ) }, - retryBackgroundAgentLaunch: async ({ attemptId, worktreeId, expectedFailureId, action }) => { - const repoId = getRepoIdFromWorktreeId(worktreeId) + retryBackgroundAgentLaunch: async ({ + attemptId, + worktreeId, + executionHostId, + expectedFailureId, + action + }) => { const clientMutationId = createBrowserUuid() - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) + const target = targetForBackgroundRecovery(get(), worktreeId, executionHostId) if (target.kind === 'local') { return window.api.worktrees.retryBackgroundAgentLaunch({ attemptId, @@ -97,10 +122,14 @@ export function createWorktreeAgentLaunchActions( ) }, - forgetBackgroundAgentLaunch: async ({ attemptId, worktreeId, expectedOperationId }) => { - const repoId = getRepoIdFromWorktreeId(worktreeId) + forgetBackgroundAgentLaunch: async ({ + attemptId, + worktreeId, + executionHostId, + expectedOperationId + }) => { const clientMutationId = createBrowserUuid() - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) + const target = targetForBackgroundRecovery(get(), worktreeId, executionHostId) if (target.kind === 'local') { return window.api.worktrees.forgetBackgroundAgentLaunch({ attemptId, @@ -116,9 +145,8 @@ export function createWorktreeAgentLaunchActions( ) }, - unknownAgentLaunchSiblingPreflight: async ({ worktreeId }) => { - const repoId = getRepoIdFromWorktreeId(worktreeId) - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) + unknownAgentLaunchSiblingPreflight: async ({ worktreeId, executionHostId }) => { + const target = targetForBackgroundRecovery(get(), worktreeId, executionHostId) if (target.kind === 'local') { const { count } = await window.api.worktrees.unknownAgentLaunchSiblingCount({ worktreeId }) return { count, hostName: '' } @@ -129,13 +157,14 @@ export function createWorktreeAgentLaunchActions( { worktree: toRuntimeWorktreeSelector(worktreeId) }, { timeoutMs: 30_000 } ) - const environment = get().runtimeEnvironments.find((entry) => entry.id === target.environmentId) + const environment = get().runtimeEnvironments.find( + (entry) => entry.id === target.environmentId + ) return { count, hostName: environment?.name || target.environmentId } }, - forgetUnknownAgentLaunchSiblings: async ({ worktreeId }) => { - const repoId = getRepoIdFromWorktreeId(worktreeId) - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) + forgetUnknownAgentLaunchSiblings: async ({ worktreeId, executionHostId }) => { + const target = targetForBackgroundRecovery(get(), worktreeId, executionHostId) if (target.kind === 'local') { return window.api.worktrees.forgetUnknownAgentLaunchSiblings({ worktreeId }) } diff --git a/src/shared/agent-session-resume.test.ts b/src/shared/agent-session-resume.test.ts index c6025936431..04b72026a12 100644 --- a/src/shared/agent-session-resume.test.ts +++ b/src/shared/agent-session-resume.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + RESUMABLE_TUI_AGENTS, agentProviderSessionsEqual, extractAgentProviderSession, getAgentResumeArgv, @@ -8,6 +9,10 @@ import { } from './agent-session-resume' describe('agent session resume metadata', () => { + it('lists every resumable agent exactly once', () => { + expect(new Set(RESUMABLE_TUI_AGENTS).size).toBe(RESUMABLE_TUI_AGENTS.length) + }) + it('treats devin as a resumable TUI agent', () => { expect(isResumableTuiAgent('devin')).toBe(true) }) diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index f993ec7a51e..bb17a93114e 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -16,8 +16,7 @@ export const RESUMABLE_TUI_AGENTS = [ 'devin', 'omp', 'prime-agent', - 'copilot', - 'kimi' + 'copilot' ] as const satisfies readonly TuiAgent[] export type ResumableTuiAgent = (typeof RESUMABLE_TUI_AGENTS)[number] @@ -332,8 +331,5 @@ export function getAgentResumeArgv( // resume commands, so local and remote resumes agree on one spelling. case 'copilot': return providerSession.key === 'session_id' ? ['copilot', `--resume=${id}`] : null - // Why: Kimi resumes by id with --session; sessions are work-dir-scoped (enforced by callers). - case 'kimi': - return providerSession.key === 'session_id' ? ['kimi', '--session', id] : null } }