mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
fix(agents): direct work-item and checks-fix launches quote for the execution host (#29716)
* fix(agents): direct work-item and checks-fix launches quote for the execution host GitHub Project Start, PR checks Fix and the source-control/checks preflights resolved their launch platform from the client OS whenever the repo had no SSH connection, so a Windows client starting work on a paired Linux Orca server sent a PowerShell-quoted --prefill to bash. They now read the execution-host facts #27259 introduced (server-reported OS, SSH-reported OS, WSL), and the path-shape resolver is removed so one helper decides. * fix(agents): resolve direct-launch and preflight platforms from the repo owner Review follow-up: a WSL worktree on a paired Windows server quotes for Linux, the owner-row fixture reports its server OS, and the source-control preflights read the repo owner rather than the focus-defaulted worktree route. * fix(agents): preflight platform honours a worktree's own host Review follow-up: a per-worktree host outranks the repo owner, the host-platform test's mutable client platform is typed, and the source-control panel fixture carries the host slices. * fix(agents): a server-projected SSH worktree reads its server's SSH state Review follow-up: the preflight resolver keeps the runtime owner for nested SSH worktrees instead of reading this client's own SSH connections. * fix(agents): cmd.exe pastes a multi-line draft instead of prefilling it Review follow-up: now that direct launches pass the local Windows shell, a multi-line --prefill under cmd.exe would submit at its first newline. * fix(agents): direct launches quote for the shell their tab spawns A project's Windows runtime can override the terminal setting (WSL setting, Windows runtime spawns PowerShell), so resolve the tab's effective shell before quoting. The cmd.exe parity row now expects cmd quoting.
This commit is contained in:
@@ -12,7 +12,7 @@ import { openSourceControlAiSettingsTarget } from '@/components/right-sidebar/so
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch'
|
||||
import { readSourceControlLaunchRecipeAgentId } from '../../../../shared/source-control-launch-agent-selection'
|
||||
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
|
||||
import { resolveRepoWorktreeLaunchPlatform } from '@/lib/execution-host-facts'
|
||||
import { useAppStore } from '@/store'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai'
|
||||
@@ -147,10 +147,15 @@ export function useCheckRunDetailsFixWithAI(args: {
|
||||
const connectionId = args.worktreeId
|
||||
? (getConnectionId(args.worktreeId) ?? repo?.connectionId ?? null)
|
||||
: null
|
||||
const launchPlatform = resolveSourceControlLaunchPlatform({
|
||||
connectionId,
|
||||
worktreePath: worktree?.path ?? null
|
||||
})
|
||||
// Why: the fix agent runs on the workspace's execution host, so its OS decides quoting (#27971).
|
||||
const launchPlatform =
|
||||
args.worktreeId && repo
|
||||
? resolveRepoWorktreeLaunchPlatform(
|
||||
useAppStore.getState(),
|
||||
repo,
|
||||
worktree ?? { id: args.worktreeId }
|
||||
)
|
||||
: undefined
|
||||
const fixChecksRecipe = useMemo(
|
||||
() =>
|
||||
resolveSourceControlActionRecipe({
|
||||
|
||||
@@ -22,7 +22,8 @@ export function ChecksFixDialog({
|
||||
fixChecksComposerPrompt: string | null
|
||||
setFixChecksComposerPrompt: (value: string | null) => void
|
||||
fixChecksRecipe: SourceControlActionRecipe
|
||||
fixChecksLaunchPlatform: NodeJS.Platform
|
||||
/** Unset when the repo's host has not reported its OS; the launch then reports why. */
|
||||
fixChecksLaunchPlatform: NodeJS.Platform | undefined
|
||||
repoConnectionId: string | null
|
||||
targetRepoId: string | null
|
||||
item: GitHubWorkItem
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from '@/lib/github-source-runtime-context'
|
||||
import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
|
||||
import { resolveRepoExecutionHostPlatform } from '@/lib/execution-host-facts'
|
||||
import { resolveSourceControlActionRecipe } from '../../../../../shared/source-control-ai'
|
||||
import type {
|
||||
GitHubWorkItem,
|
||||
@@ -119,29 +119,42 @@ export function ChecksTab({
|
||||
}),
|
||||
[repo, settings]
|
||||
)
|
||||
const fixChecksLaunchPlatform = useMemo(
|
||||
() =>
|
||||
resolveSourceControlLaunchPlatform({
|
||||
connectionId: repo?.connectionId ?? null,
|
||||
worktreePath: repo?.path ?? null,
|
||||
projectRuntime: repo?.connectionId
|
||||
? undefined
|
||||
: getLocalRepoProjectExecutionRuntimeContext(
|
||||
// Why: repo-scoped resolution only reads repos/projects/settings, and subscribing keeps the launch platform fresh.
|
||||
{
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
projects,
|
||||
repos,
|
||||
settings,
|
||||
worktreesByRepo: {}
|
||||
},
|
||||
repo?.id,
|
||||
CLIENT_PLATFORM
|
||||
)
|
||||
}),
|
||||
[projects, repo?.connectionId, repo?.id, repo?.path, repos, settings]
|
||||
)
|
||||
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
|
||||
const sshStateByEnvironment = useAppStore((s) => s.sshStateByEnvironment)
|
||||
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
|
||||
// Why: the fix agent runs on the repo's execution host, so its OS decides quoting (#27971).
|
||||
const fixChecksLaunchPlatform = useMemo(() => {
|
||||
if (!repo) {
|
||||
return undefined
|
||||
}
|
||||
const fact = resolveRepoExecutionHostPlatform(
|
||||
{ sshConnectionStates, sshStateByEnvironment, runtimeStatusByEnvironmentId },
|
||||
repo,
|
||||
() =>
|
||||
getLocalRepoProjectExecutionRuntimeContext(
|
||||
// Why: repo-scoped resolution only reads repos/projects/settings, and subscribing keeps the launch platform fresh.
|
||||
{
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
projects,
|
||||
repos,
|
||||
settings,
|
||||
worktreesByRepo: {}
|
||||
},
|
||||
repo.id,
|
||||
CLIENT_PLATFORM
|
||||
)
|
||||
)
|
||||
return fact.kind === 'known' ? fact.platform : undefined
|
||||
}, [
|
||||
projects,
|
||||
repo,
|
||||
repos,
|
||||
runtimeStatusByEnvironmentId,
|
||||
settings,
|
||||
sshConnectionStates,
|
||||
sshStateByEnvironment
|
||||
])
|
||||
// Why: parses a fresh host object per call, so memoize to keep the check action callbacks stable.
|
||||
const runtimeHost = useMemo(() => getGitHubSourceRuntimeHost(sourceContext), [sourceContext])
|
||||
const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext)
|
||||
|
||||
@@ -151,6 +151,9 @@ function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
|
||||
groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] },
|
||||
repos: [mocks.activeRepo],
|
||||
worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] },
|
||||
sshConnectionStates: new Map(),
|
||||
sshStateByEnvironment: new Map(),
|
||||
runtimeStatusByEnvironmentId: new Map(),
|
||||
rightSidebarOpen: false,
|
||||
rightSidebarTab: 'source-control',
|
||||
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
|
||||
+6
-9
@@ -8,8 +8,7 @@ import { readDetectedAgentsForWorktree } from '@/lib/agent-detection-target-inve
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
|
||||
import { getWorktreeGitIdentityDisplay } from '../../../../../shared/worktree/git-identity-display'
|
||||
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import { resolveRepoWorktreeLaunchPlatform } from '@/lib/execution-host-facts'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { normalizeGlobalWindowsRuntimeDefault } from '../../../../../shared/project-execution-runtime'
|
||||
import {
|
||||
@@ -218,13 +217,11 @@ export function useChecksPanelControllerState() {
|
||||
const branch = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
|
||||
const activeWorktreePath = activeWorktree?.path ?? null
|
||||
const activeWorktreePushTarget = activeWorktree?.pushTarget ?? null
|
||||
const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({
|
||||
connectionId: activeConnectionId,
|
||||
worktreePath: activeWorktreePath,
|
||||
projectRuntime: activeConnectionId
|
||||
? undefined
|
||||
: getLocalProjectExecutionRuntimeContext(useAppStore.getState(), activeWorktreeId)
|
||||
})
|
||||
// Why: agents run on the workspace's execution host, so its OS decides quoting (#27971).
|
||||
const activeSourceControlLaunchPlatform =
|
||||
activeWorktree && repo
|
||||
? resolveRepoWorktreeLaunchPlatform(useAppStore.getState(), repo, activeWorktree)
|
||||
: undefined
|
||||
const runtimeEnvironmentId = useAppStore((s) =>
|
||||
getRuntimeEnvironmentIdForWorktree(s, activeWorktreeId)
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ export type SourceControlAiControllerParams = {
|
||||
activeWorktreeId: string | null | undefined
|
||||
activeConnectionId: string | null | undefined
|
||||
activeGroupId: string | null | undefined
|
||||
activeSourceControlLaunchPlatform: NodeJS.Platform
|
||||
activeSourceControlLaunchPlatform: NodeJS.Platform | undefined
|
||||
conflictOperation: GitConflictOperation
|
||||
unresolvedConflicts: Pick<GitStatusEntry, 'path' | 'conflictKind'>[]
|
||||
stagedEntries: Pick<GitStatusEntry, 'path' | 'status' | 'area'>[]
|
||||
|
||||
@@ -90,7 +90,7 @@ export async function launchSourceControlRecoveryAgentWithDefault({
|
||||
}: {
|
||||
activeWorktreeId: string
|
||||
activeGroupId: string | null | undefined
|
||||
activeSourceControlLaunchPlatform: NodeJS.Platform
|
||||
activeSourceControlLaunchPlatform: NodeJS.Platform | undefined
|
||||
sourceRepoConnectionId?: string | null
|
||||
actionId: SourceControlLaunchActionId
|
||||
basePrompt: string | null
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { SourceControlAiStoreSnapshot } from './controller-types'
|
||||
type SourceControlRecoveryAiParams = {
|
||||
activeWorktreeId: string | null | undefined
|
||||
activeGroupId: string | null | undefined
|
||||
activeSourceControlLaunchPlatform: NodeJS.Platform
|
||||
activeSourceControlLaunchPlatform: NodeJS.Platform | undefined
|
||||
sourceRepoConnectionId?: string | null
|
||||
worktreePath: string | null
|
||||
commitMessage: string
|
||||
|
||||
+6
-9
@@ -1,8 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import { ipcOrRuntimeTargetForHostId } from '@/runtime/runtime-client-target'
|
||||
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
|
||||
import { resolveRepoWorktreeLaunchPlatform } from '@/lib/execution-host-facts'
|
||||
import { getWorktreeGitIdentityDisplay } from '../../../../../../shared/worktree/git-identity-display'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors'
|
||||
@@ -129,13 +128,11 @@ export function useSourceControlWorktreeContext() {
|
||||
const activeConnectionId = activeWorktreeId
|
||||
? (getConnectionId(activeWorktreeId) ?? activeRepoConnectionId)
|
||||
: null
|
||||
const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({
|
||||
connectionId: activeConnectionId,
|
||||
worktreePath,
|
||||
projectRuntime: activeConnectionId
|
||||
? undefined
|
||||
: getLocalProjectExecutionRuntimeContext(useAppStore.getState(), activeWorktreeId)
|
||||
})
|
||||
// Why: agents run on the workspace's execution host, so its OS decides quoting (#27971).
|
||||
const activeSourceControlLaunchPlatform =
|
||||
activeWorktree && activeRepo
|
||||
? resolveRepoWorktreeLaunchPlatform(useAppStore.getState(), activeRepo, activeWorktree)
|
||||
: undefined
|
||||
// Why: the sidebar stays mounted when closed, so gate polling on tab AND open or branchCompare/PR fetch would run with no visible consumer.
|
||||
const isBranchVisible = rightSidebarTab === 'source-control' && rightSidebarOpen
|
||||
const hasUncommittedEntries = entries.length > 0
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status'
|
||||
import { resolveRepoWorktreeLaunchPlatform } from './execution-host-facts'
|
||||
|
||||
vi.mock('./new-workspace', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
CLIENT_PLATFORM: 'win32'
|
||||
}))
|
||||
|
||||
type State = Parameters<typeof resolveRepoWorktreeLaunchPlatform>[0]
|
||||
|
||||
function linuxServer(): Map<string, RuntimeEnvironmentStatus> {
|
||||
return new Map([
|
||||
[
|
||||
'linux-box',
|
||||
{
|
||||
checkedAt: 1,
|
||||
status: {
|
||||
runtimeId: 'rt',
|
||||
rendererGraphEpoch: 0,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: null,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
hostPlatform: 'linux'
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
function state(): State {
|
||||
return {
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
detectedWorktreesByRepo: {},
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
projects: [],
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads no other setting.
|
||||
settings: {} as State['settings'],
|
||||
sshConnectionStates: new Map(),
|
||||
sshStateByEnvironment: new Map(),
|
||||
runtimeStatusByEnvironmentId: linuxServer()
|
||||
}
|
||||
}
|
||||
|
||||
const serverRepo = { path: '/srv/repo', executionHostId: 'runtime:linux-box' }
|
||||
|
||||
describe('resolveRepoWorktreeLaunchPlatform from a Windows client', () => {
|
||||
it("reads a server project's worktree as the server's OS (#27971)", () => {
|
||||
expect(
|
||||
resolveRepoWorktreeLaunchPlatform(state(), serverRepo, { id: 'wt', path: '/srv/wt' })
|
||||
).toBe('linux')
|
||||
})
|
||||
|
||||
it("lets a worktree's own host outrank its repo's", () => {
|
||||
expect(
|
||||
resolveRepoWorktreeLaunchPlatform(state(), serverRepo, {
|
||||
id: 'wt',
|
||||
path: 'C:\\wt',
|
||||
hostId: 'local'
|
||||
})
|
||||
).toBe('win32')
|
||||
})
|
||||
|
||||
it("reads a server-projected SSH worktree through that server's connection", () => {
|
||||
const nested = {
|
||||
...state(),
|
||||
sshStateByEnvironment: new Map([
|
||||
[
|
||||
'linux-box',
|
||||
{
|
||||
connectionStates: new Map([
|
||||
[
|
||||
'box',
|
||||
{
|
||||
targetId: 'box',
|
||||
status: 'connected' as const,
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
remotePlatform: 'linux' as const
|
||||
}
|
||||
]
|
||||
]),
|
||||
targetLabels: new Map(),
|
||||
targetGenerations: new Map(),
|
||||
removedTargetLabels: new Map(),
|
||||
targetsHydrated: true
|
||||
}
|
||||
]
|
||||
])
|
||||
}
|
||||
expect(
|
||||
resolveRepoWorktreeLaunchPlatform(nested, serverRepo, {
|
||||
id: 'wt',
|
||||
path: '/home/a/wt',
|
||||
hostId: 'ssh:box',
|
||||
runtimeOwnerEnvironmentId: 'linux-box'
|
||||
})
|
||||
).toBe('linux')
|
||||
})
|
||||
|
||||
it('is undefined when the server has not reported its OS', () => {
|
||||
const withheld = { ...state(), runtimeStatusByEnvironmentId: new Map() }
|
||||
expect(
|
||||
resolveRepoWorktreeLaunchPlatform(withheld, serverRepo, { id: 'wt', path: '/srv/wt' })
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -115,18 +115,22 @@ function resolveHostPlatform(
|
||||
return { kind: 'known', platform, hostKind: 'runtime' }
|
||||
}
|
||||
|
||||
/** Platform of the host that runs agents for a repo that has no workspace yet. */
|
||||
/**
|
||||
* Platform of the host that runs agents for a repo's workspaces, read from the repo row's owner.
|
||||
* `workspacePath` names a workspace on that host (e.g. a WSL worktree); it defaults to the repo.
|
||||
*/
|
||||
export function resolveRepoExecutionHostPlatform(
|
||||
state: HostFactsState,
|
||||
repo: { connectionId?: string | null; executionHostId?: string | null; path: string },
|
||||
projectRuntime: () => ProjectExecutionRuntimeResolution | undefined
|
||||
projectRuntime: () => ProjectExecutionRuntimeResolution | undefined,
|
||||
workspacePath: string | null | undefined = repo.path
|
||||
): ExecutionHostPlatformFact {
|
||||
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
|
||||
return resolveHostPlatform(state, {
|
||||
host,
|
||||
nestedSshTargetId: host?.kind === 'runtime' ? repo.connectionId?.trim() || null : null,
|
||||
sshOwnerEnvironmentId: null,
|
||||
workspacePath: repo.path,
|
||||
workspacePath,
|
||||
projectRuntime
|
||||
})
|
||||
}
|
||||
@@ -150,6 +154,50 @@ export function resolveWorktreeExecutionHostPlatform(
|
||||
})
|
||||
}
|
||||
|
||||
export type KnownExecutionHostPlatform = Extract<ExecutionHostPlatformFact, { kind: 'known' }>
|
||||
|
||||
/** A caller's platform only refines a local launch (e.g. WSL); a remote host's own OS wins. */
|
||||
export function refineLocalLaunchPlatform(
|
||||
fact: KnownExecutionHostPlatform,
|
||||
callerPlatform: NodeJS.Platform | undefined
|
||||
): KnownExecutionHostPlatform {
|
||||
return fact.hostKind === 'local' && callerPlatform ? { ...fact, platform: callerPlatform } : fact
|
||||
}
|
||||
|
||||
/**
|
||||
* Preflight platform for a worktree's agent launch: the worktree's own host when its row names
|
||||
* one, else its repo's owner. `undefined` when withheld: the launch itself resolves the host and
|
||||
* tells the user why it stopped.
|
||||
*/
|
||||
export function resolveRepoWorktreeLaunchPlatform(
|
||||
state: WorktreeHostFactsState,
|
||||
repo: { connectionId?: string | null; executionHostId?: string | null; path: string },
|
||||
worktree: {
|
||||
id: string
|
||||
path?: string | null
|
||||
hostId?: string | null
|
||||
runtimeOwnerEnvironmentId?: string | null
|
||||
}
|
||||
): NodeJS.Platform | undefined {
|
||||
const repoHost = parseExecutionHostId(getRepoExecutionHostId(repo))
|
||||
const ownHost = parseExecutionHostId(worktree.hostId)
|
||||
// Why: a per-worktree host (e.g. a local checkout of a server project) outranks the repo's.
|
||||
const host = ownHost && ownHost.id !== repoHost?.id ? ownHost : repoHost
|
||||
const serverOwner =
|
||||
worktree.runtimeOwnerEnvironmentId?.trim() ||
|
||||
(repoHost?.kind === 'runtime' ? repoHost.environmentId : null)
|
||||
const fact = resolveHostPlatform(state, {
|
||||
host,
|
||||
nestedSshTargetId:
|
||||
host?.kind === 'runtime' && host === repoHost ? repo.connectionId?.trim() || null : null,
|
||||
// Why: a server-projected SSH worktree is reached through that server's own connection.
|
||||
sshOwnerEnvironmentId: host?.kind === 'ssh' ? serverOwner : null,
|
||||
workspacePath: worktree.path ?? repo.path,
|
||||
projectRuntime: () => getLocalProjectExecutionRuntimeContext(state, worktree.id)
|
||||
})
|
||||
return fact.kind === 'known' ? fact.platform : undefined
|
||||
}
|
||||
|
||||
export function requireExecutionHostPlatform(fact: ExecutionHostPlatformFact): NodeJS.Platform {
|
||||
if (fact.kind === 'withheld') {
|
||||
throw new Error(fact.reason)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as ExecutionHostFactsModule from '@/lib/execution-host-facts'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const store = {
|
||||
@@ -54,7 +55,7 @@ const mocks = vi.hoisted(() => {
|
||||
pickSourceControlLaunchAgent: vi.fn(),
|
||||
readSourceControlLaunchRecipeAgentId: vi.fn(),
|
||||
resolveSourceControlActionRecipe: vi.fn(),
|
||||
resolveSourceControlLaunchPlatform: vi.fn(),
|
||||
resolveRepoWorktreeLaunchPlatform: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastMessage: vi.fn()
|
||||
}
|
||||
@@ -102,9 +103,15 @@ vi.mock('../../../shared/source-control-launch-agent-selection', () => ({
|
||||
readSourceControlLaunchRecipeAgentId: mocks.readSourceControlLaunchRecipeAgentId
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/source-control-launch-platform', () => ({
|
||||
resolveSourceControlLaunchPlatform: mocks.resolveSourceControlLaunchPlatform
|
||||
}))
|
||||
vi.mock('@/lib/execution-host-facts', async () => {
|
||||
const actual = await vi.importActual<typeof ExecutionHostFactsModule>(
|
||||
'@/lib/execution-host-facts'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
resolveRepoWorktreeLaunchPlatform: mocks.resolveRepoWorktreeLaunchPlatform
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: mocks.activateAndRevealWorktree
|
||||
@@ -158,7 +165,7 @@ describe('startFixChecksAgent', () => {
|
||||
mocks.resolveSourceControlActionRecipe.mockReturnValue({
|
||||
commandInputTemplate: '{basePrompt}'
|
||||
})
|
||||
mocks.resolveSourceControlLaunchPlatform.mockReturnValue('darwin')
|
||||
mocks.resolveRepoWorktreeLaunchPlatform.mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
it('activates the attached workspace as a surface-providing caller', async () => {
|
||||
@@ -230,7 +237,7 @@ describe('startFixChecksAgent', () => {
|
||||
expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the repo connection when an attached workspace lookup is unresolved', async () => {
|
||||
it("resolves the attached workspace's own execution host", async () => {
|
||||
mocks.store.repos = [
|
||||
{
|
||||
...mocks.store.repos[0],
|
||||
@@ -250,44 +257,15 @@ describe('startFixChecksAgent', () => {
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(mocks.store.ensureRemoteDetectedAgents).toHaveBeenCalledWith('ssh-1')
|
||||
expect(mocks.resolveSourceControlLaunchPlatform).toHaveBeenCalledWith({
|
||||
connectionId: 'ssh-1',
|
||||
worktreePath: '/repo/wt-1',
|
||||
projectRuntime: undefined
|
||||
})
|
||||
expect(mocks.resolveRepoWorktreeLaunchPlatform).toHaveBeenCalledWith(
|
||||
mocks.store,
|
||||
expect.objectContaining({ id: 'repo-1', connectionId: 'ssh-1' }),
|
||||
expect.objectContaining({ id: 'wt-1', path: '/repo/wt-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('passes the local project runtime when resolving an attached WSL workspace launch platform', async () => {
|
||||
mocks.store.repos = [
|
||||
{
|
||||
...mocks.store.repos[0],
|
||||
path: 'C:\\Users\\alice\\repo'
|
||||
}
|
||||
]
|
||||
mocks.store.worktrees = [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: 'C:\\Users\\alice\\repo-worktree'
|
||||
}
|
||||
]
|
||||
mocks.store.worktreesByRepo = { 'repo-1': mocks.store.worktrees }
|
||||
mocks.store.projects = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000000',
|
||||
sourceRepoIds: ['repo-1'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
}
|
||||
]
|
||||
mocks.resolveSourceControlLaunchPlatform.mockImplementation(({ projectRuntime }) =>
|
||||
projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl'
|
||||
? 'linux'
|
||||
: 'win32'
|
||||
)
|
||||
it("plans a Linux server's launch for that server from a Windows client (#27971)", async () => {
|
||||
mocks.resolveRepoWorktreeLaunchPlatform.mockReturnValue('linux')
|
||||
const { startFixChecksAgent } = await import('./fix-checks-agent-launch')
|
||||
|
||||
await expect(
|
||||
@@ -299,21 +277,13 @@ describe('startFixChecksAgent', () => {
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(mocks.resolveSourceControlLaunchPlatform).toHaveBeenCalledWith({
|
||||
connectionId: null,
|
||||
worktreePath: 'C:\\Users\\alice\\repo-worktree',
|
||||
projectRuntime: expect.objectContaining({
|
||||
status: 'resolved',
|
||||
runtime: expect.objectContaining({ kind: 'wsl', distro: 'Ubuntu' })
|
||||
})
|
||||
})
|
||||
expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ launchPlatform: 'linux' })
|
||||
)
|
||||
})
|
||||
|
||||
it('fails without launching when the launch platform cannot be resolved', async () => {
|
||||
mocks.resolveSourceControlLaunchPlatform.mockReturnValue(undefined)
|
||||
it('fails without launching when the execution host has not reported its OS', async () => {
|
||||
mocks.resolveRepoWorktreeLaunchPlatform.mockReturnValue(undefined)
|
||||
const { startFixChecksAgent } = await import('./fix-checks-agent-launch')
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { toast } from 'sonner'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import {
|
||||
ensureDetectedAgentsForRepo,
|
||||
ensureDetectedAgentsForWorktree,
|
||||
@@ -10,14 +9,12 @@ import { findGithubPrWorkspaceAttachment } from '@/lib/github-work-item-workspac
|
||||
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
|
||||
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
|
||||
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup'
|
||||
import {
|
||||
pickSourceControlLaunchAgent,
|
||||
readSourceControlLaunchRecipeAgentId
|
||||
} from '../../../shared/source-control-launch-agent-selection'
|
||||
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
|
||||
import { resolveRepoWorktreeLaunchPlatform } from '@/lib/execution-host-facts'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { useAppStore } from '@/store'
|
||||
import { resolveSourceControlActionRecipe } from '../../../shared/source-control-ai'
|
||||
@@ -158,18 +155,17 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis
|
||||
)
|
||||
return false
|
||||
}
|
||||
const targetConnectionId = getConnectionId(targetWorktreeId) ?? repo?.connectionId ?? null
|
||||
const agent = await pickExistingWorktreeAgent(targetWorktreeId, savedAgentId)
|
||||
if (!agent) {
|
||||
return false
|
||||
}
|
||||
const launchPlatform = resolveSourceControlLaunchPlatform({
|
||||
connectionId: targetConnectionId,
|
||||
worktreePath: targetWorktree.path,
|
||||
projectRuntime: targetConnectionId
|
||||
? undefined
|
||||
: getLocalProjectExecutionRuntimeContext(store, targetWorktreeId, CLIENT_PLATFORM)
|
||||
})
|
||||
// Why: the agent runs on the workspace's execution host, so its OS decides quoting (#27971).
|
||||
const latestStore = useAppStore.getState()
|
||||
const targetRepo =
|
||||
latestStore.repos.find((candidate) => candidate.id === targetWorktree.repoId) ?? repo
|
||||
const launchPlatform = targetRepo
|
||||
? resolveRepoWorktreeLaunchPlatform(latestStore, targetRepo, targetWorktree)
|
||||
: undefined
|
||||
if (!launchPlatform) {
|
||||
toast.error(
|
||||
translate(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { toast } from 'sonner'
|
||||
import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import {
|
||||
refineLocalLaunchPlatform,
|
||||
resolveExecutionHostAgentStartupShell,
|
||||
resolveWorktreeExecutionHostPlatform
|
||||
} from '@/lib/execution-host-facts'
|
||||
@@ -37,15 +38,13 @@ export function resolveAgentLaunchExecutionContext(
|
||||
toast.error(fact.reason)
|
||||
return null
|
||||
}
|
||||
// Why: a caller's platform only refines a local launch (e.g. WSL); a remote host's own OS wins.
|
||||
const resolvedLaunchPlatform =
|
||||
fact.hostKind === 'local' && args.launchPlatform ? args.launchPlatform : fact.platform
|
||||
const launchHost = refineLocalLaunchPlatform(fact, args.launchPlatform)
|
||||
return {
|
||||
worktreeSshConnectionId,
|
||||
resolvedLaunchPlatform,
|
||||
resolvedLaunchPlatform: launchHost.platform,
|
||||
isRemote,
|
||||
queuedShell: resolveExecutionHostAgentStartupShell(
|
||||
{ ...fact, platform: resolvedLaunchPlatform },
|
||||
launchHost,
|
||||
store.settings?.terminalWindowsShell
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import { isTuiAgentEnabled, pickTuiAgent } from '../../../shared/tui-agent-selection'
|
||||
import { buildDirectWorkItemAgentStartupPlan } from '@/lib/launch-work-item-direct-agent'
|
||||
import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan'
|
||||
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
|
||||
import {
|
||||
resolveExecutionHostAgentStartupShell,
|
||||
type KnownExecutionHostPlatform
|
||||
} from '@/lib/execution-host-facts'
|
||||
import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab'
|
||||
import type { DeclinedStructuredLaunchTerminalOptions } from '@/lib/structured-agent-session-launch-admission'
|
||||
import {
|
||||
@@ -18,25 +21,21 @@ export function buildDirectWorkItemStartup(args: {
|
||||
draftContent: string
|
||||
promptDelivery: PromptDelivery
|
||||
settings: AppState['settings']
|
||||
launchPlatform?: NodeJS.Platform
|
||||
/** The workspace's execution host; its OS and shell decide quoting, not this client's. */
|
||||
launchHost: KnownExecutionHostPlatform
|
||||
/** This client's Windows shell, which only shapes a local launch. */
|
||||
terminalWindowsShell: string | null | undefined
|
||||
launchConnectionId: string | null
|
||||
worktreePath: string
|
||||
repoProjectRuntime?: Parameters<typeof resolveSourceControlLaunchPlatform>[0]['projectRuntime']
|
||||
}): ReturnType<typeof buildDirectWorkItemAgentStartupPlan> {
|
||||
const launchPlatform =
|
||||
args.launchPlatform ??
|
||||
resolveSourceControlLaunchPlatform({
|
||||
connectionId: args.launchConnectionId,
|
||||
worktreePath: args.worktreePath,
|
||||
projectRuntime: args.repoProjectRuntime
|
||||
})
|
||||
const shell = resolveExecutionHostAgentStartupShell(args.launchHost, args.terminalWindowsShell)
|
||||
return buildDirectWorkItemAgentStartupPlan({
|
||||
agent: args.agent,
|
||||
agentArgs: args.agentArgs,
|
||||
draftContent: args.draftContent,
|
||||
promptDelivery: args.promptDelivery,
|
||||
settings: args.settings,
|
||||
launchPlatform,
|
||||
launchPlatform: args.launchHost.platform,
|
||||
...(shell ? { shell } : {}),
|
||||
// Why: SSH hosts run the plain `orca` shim, so the Linux-only `orca-ide` rename is not applied.
|
||||
isRemote: typeof args.launchConnectionId === 'string'
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-re
|
||||
import type { LaunchSource } from '../../../shared/telemetry-events'
|
||||
import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
@@ -30,6 +31,8 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
|
||||
| null
|
||||
| undefined
|
||||
launchPlatform: NodeJS.Platform
|
||||
/** Unset keeps the platform default: PowerShell on win32, POSIX elsewhere. */
|
||||
shell?: AgentStartupShell
|
||||
/** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only
|
||||
* `orca-ide` rename must not be applied for remote launches. */
|
||||
isRemote?: boolean
|
||||
@@ -55,6 +58,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
|
||||
draft: args.draftContent,
|
||||
cmdOverrides: args.settings?.agentCmdOverrides ?? {},
|
||||
platform: args.launchPlatform,
|
||||
...(args.shell ? { shell: args.shell } : {}),
|
||||
isRemote: args.isRemote,
|
||||
agentArgs: effectiveAgentArgs,
|
||||
agentEnv: effectiveAgentEnv
|
||||
@@ -83,6 +87,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
|
||||
prompt: '',
|
||||
cmdOverrides: args.settings?.agentCmdOverrides ?? {},
|
||||
platform: args.launchPlatform,
|
||||
...(args.shell ? { shell: args.shell } : {}),
|
||||
isRemote: args.isRemote,
|
||||
agentArgs: effectiveAgentArgs,
|
||||
agentEnv: effectiveAgentEnv,
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status'
|
||||
import type { SshConnectionState } from '../../../shared/ssh-types'
|
||||
import type * as TuiAgentStartupModule from '@/lib/tui-agent-startup'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
// Why declared: an inferred literal `'win32'` would reject the other client platforms.
|
||||
const mutable: { clientPlatform: NodeJS.Platform; store: Record<string, unknown> } = {
|
||||
clientPlatform: 'win32',
|
||||
store: {}
|
||||
}
|
||||
return {
|
||||
...mutable,
|
||||
toastError: vi.fn(),
|
||||
createWorktree: vi.fn(),
|
||||
activateAndRevealWorktree: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.store } }))
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } }))
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: mocks.activateAndRevealWorktree
|
||||
}))
|
||||
vi.mock('@/lib/ensure-hooks-confirmed', () => ({
|
||||
ensureHooksConfirmed: vi.fn().mockResolvedValue('run')
|
||||
}))
|
||||
vi.mock('@/lib/launch-work-item-direct-preflight', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
resolveDirectSetupDecision: vi.fn().mockResolvedValue({ kind: 'resolved', decision: 'inherit' })
|
||||
}))
|
||||
vi.mock('@/lib/agent-detection-target-inventory', () => ({
|
||||
ensureDetectedAgentsForRepo: vi.fn().mockResolvedValue(['claude']),
|
||||
ensureDetectedAgentsForNewWorktree: vi.fn().mockResolvedValue(['claude'])
|
||||
}))
|
||||
vi.mock('@/lib/agent-session-launch-plan', () => ({
|
||||
planAgentSessionLaunch: vi.fn(() => ({ route: 'terminal' }))
|
||||
}))
|
||||
vi.mock('@/lib/agent-launch-prompt-delivery', () => ({
|
||||
deliverLaunchPromptToAgentTab: vi.fn(),
|
||||
seedNativeChatLaunchDraftForAgentTab: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/telemetry', () => ({ track: vi.fn(), tuiAgentToAgentKind: (a: string) => a }))
|
||||
vi.mock('@/lib/new-workspace', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
get CLIENT_PLATFORM() {
|
||||
return mocks.clientPlatform
|
||||
}
|
||||
}))
|
||||
vi.mock('@/lib/tui-agent-startup', async () => {
|
||||
const actual = await vi.importActual<typeof TuiAgentStartupModule>('@/lib/tui-agent-startup')
|
||||
return { ...actual, buildAgentDraftLaunchPlan: vi.fn(actual.buildAgentDraftLaunchPlan) }
|
||||
})
|
||||
|
||||
import { launchWorkItemDirect } from './launch-work-item-direct'
|
||||
import { buildAgentDraftLaunchPlan } from '@/lib/tui-agent-startup'
|
||||
|
||||
function runtimeStatus(hostPlatform?: NodeJS.Platform): RuntimeEnvironmentStatus {
|
||||
return {
|
||||
checkedAt: 1,
|
||||
status: {
|
||||
runtimeId: 'rt',
|
||||
rendererGraphEpoch: 0,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: null,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
...(hostPlatform ? { hostPlatform } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sshState(remotePlatform: 'linux' | 'win32'): SshConnectionState {
|
||||
return { targetId: 'box', status: 'connected', error: null, reconnectAttempt: 0, remotePlatform }
|
||||
}
|
||||
|
||||
type HostCase = {
|
||||
name: string
|
||||
client: NodeJS.Platform
|
||||
repo: { path: string; connectionId?: string; executionHostId?: string }
|
||||
ssh?: Map<string, SshConnectionState>
|
||||
runtime?: Map<string, RuntimeEnvironmentStatus>
|
||||
expected: NodeJS.Platform
|
||||
}
|
||||
|
||||
function setStore(hostCase: HostCase): void {
|
||||
mocks.clientPlatform = hostCase.client
|
||||
mocks.store = {
|
||||
repos: [{ id: 'repo-1', displayName: 'Repo', addedAt: 1, ...hostCase.repo }],
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
projects: [],
|
||||
projectGroups: [],
|
||||
folderWorkspaces: [],
|
||||
worktreesByRepo: {},
|
||||
detectedWorktreesByRepo: {},
|
||||
settings: { defaultTuiAgent: 'claude', disabledTuiAgents: [], agentCmdOverrides: {} },
|
||||
sshConnectionStates: hostCase.ssh ?? new Map(),
|
||||
sshStateByEnvironment: new Map(),
|
||||
runtimeStatusByEnvironmentId: hostCase.runtime ?? new Map(),
|
||||
createWorktree: mocks.createWorktree,
|
||||
updateWorktreeMeta: vi.fn().mockResolvedValue(undefined),
|
||||
setSidebarOpen: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function startLinearIssue(): Promise<boolean> {
|
||||
return launchWorkItemDirect({
|
||||
repoId: 'repo-1',
|
||||
launchSource: 'task_page',
|
||||
openModalFallback: vi.fn(),
|
||||
agentOverride: 'claude',
|
||||
item: {
|
||||
type: 'issue',
|
||||
number: null,
|
||||
title: 'Ship it',
|
||||
url: 'https://linear.app/acme/issue/ENG-1/ship-it',
|
||||
linearIdentifier: 'ENG-1'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const HOST_CASES: HostCase[] = [
|
||||
{
|
||||
name: 'Windows client -> paired Linux Orca server',
|
||||
client: 'win32',
|
||||
repo: { path: '/srv/repo', executionHostId: 'runtime:linux-box' },
|
||||
runtime: new Map([['linux-box', runtimeStatus('linux')]]),
|
||||
expected: 'linux'
|
||||
},
|
||||
{
|
||||
name: 'Windows client -> Linux SSH host',
|
||||
client: 'win32',
|
||||
repo: { path: '/home/alice/repo', connectionId: 'box' },
|
||||
ssh: new Map([['box', sshState('linux')]]),
|
||||
expected: 'linux'
|
||||
},
|
||||
{
|
||||
name: 'Linux client -> paired Windows Orca server',
|
||||
client: 'linux',
|
||||
repo: { path: 'D:\\repo', executionHostId: 'runtime:win-box' },
|
||||
runtime: new Map([['win-box', runtimeStatus('win32')]]),
|
||||
expected: 'win32'
|
||||
},
|
||||
{
|
||||
name: 'macOS client -> Windows SSH host',
|
||||
client: 'darwin',
|
||||
repo: { path: 'C:\\Users\\alice\\repo', connectionId: 'box' },
|
||||
ssh: new Map([['box', sshState('win32')]]),
|
||||
expected: 'win32'
|
||||
},
|
||||
{
|
||||
name: 'Linux client -> paired Linux Orca server',
|
||||
client: 'linux',
|
||||
repo: { path: '/srv/repo', executionHostId: 'runtime:linux-box' },
|
||||
runtime: new Map([['linux-box', runtimeStatus('linux')]]),
|
||||
expected: 'linux'
|
||||
},
|
||||
{
|
||||
name: 'Windows client -> paired Windows Orca server',
|
||||
client: 'win32',
|
||||
repo: { path: 'D:\\repo', executionHostId: 'runtime:win-box' },
|
||||
runtime: new Map([['win-box', runtimeStatus('win32')]]),
|
||||
expected: 'win32'
|
||||
},
|
||||
{ name: 'local Linux', client: 'linux', repo: { path: '/repo' }, expected: 'linux' },
|
||||
{ name: 'local Windows', client: 'win32', repo: { path: 'C:\\repo' }, expected: 'win32' }
|
||||
]
|
||||
|
||||
describe('launchWorkItemDirect quotes for the execution host (#27971)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.createWorktree.mockResolvedValue({
|
||||
worktree: { id: 'repo-1::/wt', path: '/wt' },
|
||||
setup: undefined
|
||||
})
|
||||
mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'tab-1' })
|
||||
})
|
||||
|
||||
it.each(HOST_CASES)('$name', async (hostCase) => {
|
||||
setStore(hostCase)
|
||||
|
||||
await expect(startLinearIssue()).resolves.toBe(true)
|
||||
|
||||
expect(buildAgentDraftLaunchPlan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ platform: hostCase.expected })
|
||||
)
|
||||
const command: string = mocks.activateAndRevealWorktree.mock.calls[0]?.[1]?.startup?.command
|
||||
expect(command).toContain('Linked Linear issue: ENG-1')
|
||||
if (hostCase.expected === 'win32') {
|
||||
expect(command).toContain('`n')
|
||||
} else {
|
||||
// Why: a PowerShell "`n" draft leaves bash waiting at its continuation prompt.
|
||||
expect(command).not.toContain('`n')
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps the server's OS when the created worktree is not hydrated yet", async () => {
|
||||
setStore(HOST_CASES[0])
|
||||
mocks.createWorktree.mockResolvedValue({ worktree: { id: 'wt-new', path: '/wt' } })
|
||||
|
||||
await expect(startLinearIssue()).resolves.toBe(true)
|
||||
|
||||
expect(buildAgentDraftLaunchPlan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ platform: 'linux' })
|
||||
)
|
||||
})
|
||||
|
||||
it("quotes a Windows server's new WSL worktree for that distro", async () => {
|
||||
setStore(HOST_CASES[5])
|
||||
mocks.createWorktree.mockResolvedValue({
|
||||
worktree: { id: 'wt-wsl', path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\wt' }
|
||||
})
|
||||
|
||||
await expect(startLinearIssue()).resolves.toBe(true)
|
||||
|
||||
expect(buildAgentDraftLaunchPlan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ platform: 'linux' })
|
||||
)
|
||||
})
|
||||
|
||||
it("quotes a local Windows project's WSL runtime for that distro", async () => {
|
||||
setStore({ name: 'wsl', client: 'win32', repo: { path: 'C:\\repo' }, expected: 'linux' })
|
||||
mocks.store.projects = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000000',
|
||||
sourceRepoIds: ['repo-1'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
}
|
||||
]
|
||||
mocks.createWorktree.mockResolvedValue({
|
||||
worktree: { id: 'repo-1::C:\\repo-wt', path: 'C:\\repo-wt' }
|
||||
})
|
||||
|
||||
await expect(startLinearIssue()).resolves.toBe(true)
|
||||
|
||||
expect(buildAgentDraftLaunchPlan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ platform: 'linux' })
|
||||
)
|
||||
})
|
||||
|
||||
it("applies this client's Windows shell only to a local launch", async () => {
|
||||
setStore({ name: 'local', client: 'win32', repo: { path: 'C:\\repo' }, expected: 'win32' })
|
||||
mocks.store.settings = {
|
||||
defaultTuiAgent: 'claude',
|
||||
agentCmdOverrides: {},
|
||||
terminalWindowsShell: 'cmd.exe'
|
||||
}
|
||||
|
||||
await expect(startLinearIssue()).resolves.toBe(true)
|
||||
|
||||
expect(buildAgentDraftLaunchPlan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ platform: 'win32', shell: 'cmd' })
|
||||
)
|
||||
})
|
||||
|
||||
it('stops before creating a workspace when the server has not reported its OS', async () => {
|
||||
setStore({
|
||||
name: 'unreported',
|
||||
client: 'win32',
|
||||
repo: { path: '/srv/repo', executionHostId: 'runtime:linux-box' },
|
||||
runtime: new Map([['linux-box', runtimeStatus()]]),
|
||||
expected: 'linux'
|
||||
})
|
||||
|
||||
await expect(startLinearIssue()).resolves.toBe(false)
|
||||
|
||||
expect(mocks.createWorktree).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(
|
||||
'This Orca server does not report its operating system. Update the server to launch agents on it.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -40,6 +40,12 @@ describe('launchWorkItemDirect owner row', () => {
|
||||
mocks.store = {
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
sshConnectionStates: new Map(),
|
||||
sshStateByEnvironment: new Map(),
|
||||
// Why: a launch stops before creating anything on a server that has not reported its OS.
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['env-b', { checkedAt: 1, status: { hostPlatform: 'linux' } }]
|
||||
]),
|
||||
settings: {},
|
||||
ensureDetectedAgents: vi.fn().mockResolvedValue([]),
|
||||
ensureRemoteDetectedAgents: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -2,8 +2,16 @@ import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import type { RepoHostFields } from '@/lib/agent-detection-target-inventory'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
|
||||
import {
|
||||
refineLocalLaunchPlatform,
|
||||
resolveRepoExecutionHostPlatform,
|
||||
type KnownExecutionHostPlatform
|
||||
} from '@/lib/execution-host-facts'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../shared/local-windows-terminal-runtime'
|
||||
import { isWslUncPath } from '../../../shared/wsl-paths'
|
||||
import type {
|
||||
AgentSessionLaunchPlan,
|
||||
planAgentSessionLaunch
|
||||
@@ -26,6 +34,50 @@ export type DirectWorkItemAgentLaunchPreparation = {
|
||||
plan: AgentSessionLaunchPlan | null
|
||||
}
|
||||
|
||||
type DirectLaunchHostArgs = {
|
||||
latestStore: AppState
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
repoHost: RepoHostFields & { path: string }
|
||||
launchPlatform?: NodeJS.Platform
|
||||
repoLaunchHost: KnownExecutionHostPlatform
|
||||
repoProjectRuntime?: ProjectExecutionRuntimeResolution
|
||||
}
|
||||
|
||||
function directLaunchProjectRuntime(
|
||||
args: DirectLaunchHostArgs
|
||||
): ProjectExecutionRuntimeResolution | undefined {
|
||||
return (
|
||||
getLocalProjectExecutionRuntimeContext(args.latestStore, args.worktreeId, CLIENT_PLATFORM) ??
|
||||
args.repoProjectRuntime
|
||||
)
|
||||
}
|
||||
|
||||
function resolveDirectWorkItemLaunchHost(args: DirectLaunchHostArgs): KnownExecutionHostPlatform {
|
||||
// Why the repo's owner: the worktree was created on that host and may not be hydrated yet; its
|
||||
// own path and runtime still refine the OS (a WSL worktree on a Windows host runs in Linux).
|
||||
const fact = resolveRepoExecutionHostPlatform(
|
||||
args.latestStore,
|
||||
args.repoHost,
|
||||
() => directLaunchProjectRuntime(args),
|
||||
args.worktreePath
|
||||
)
|
||||
return refineLocalLaunchPlatform(
|
||||
fact.kind === 'known' ? fact : args.repoLaunchHost,
|
||||
args.launchPlatform
|
||||
)
|
||||
}
|
||||
|
||||
/** The shell the local tab actually spawns, which a project runtime can override (e.g. WSL setting, Windows runtime). */
|
||||
function resolveDirectWorkItemTabShell(args: DirectLaunchHostArgs): string | undefined {
|
||||
return resolveLocalWindowsTerminalShellOverrideForTab({
|
||||
explicitShellOverride: undefined,
|
||||
defaultWindowsShell: args.latestStore.settings?.terminalWindowsShell ?? undefined,
|
||||
isWslWorktree: isWslUncPath(args.worktreePath),
|
||||
projectRuntime: directLaunchProjectRuntime(args)
|
||||
})
|
||||
}
|
||||
|
||||
export async function prepareDirectWorkItemAgentLaunch(args: {
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
@@ -33,14 +85,16 @@ export async function prepareDirectWorkItemAgentLaunch(args: {
|
||||
agentOverride?: TuiAgent
|
||||
agentArgs?: string | null
|
||||
repoConnectionId: string | null
|
||||
repoHost: RepoHostFields
|
||||
repoHost: RepoHostFields & { path: string }
|
||||
detectedAgentsPromise: Promise<string[]> | null
|
||||
latestStore: AppState
|
||||
settings: AppState['settings']
|
||||
draftContent: string
|
||||
promptDelivery: 'draft' | 'submit-after-ready'
|
||||
launchPlatform?: NodeJS.Platform
|
||||
repoProjectRuntime?: Parameters<typeof buildDirectWorkItemStartup>[0]['repoProjectRuntime']
|
||||
/** Host the repo creates worktrees on; stands in while the new worktree is not hydrated. */
|
||||
repoLaunchHost: KnownExecutionHostPlatform
|
||||
repoProjectRuntime?: ProjectExecutionRuntimeResolution
|
||||
planLaunch: typeof planAgentSessionLaunch
|
||||
/** The start action this launch serves. */
|
||||
requestId: AgentLaunchRequestId
|
||||
@@ -83,17 +137,9 @@ export async function prepareDirectWorkItemAgentLaunch(args: {
|
||||
draftContent: args.draftContent,
|
||||
promptDelivery: args.promptDelivery,
|
||||
settings: args.settings,
|
||||
launchPlatform: args.launchPlatform,
|
||||
launchConnectionId,
|
||||
worktreePath: args.worktreePath,
|
||||
repoProjectRuntime:
|
||||
launchConnectionId === null
|
||||
? (getLocalProjectExecutionRuntimeContext(
|
||||
args.latestStore,
|
||||
args.worktreeId,
|
||||
CLIENT_PLATFORM
|
||||
) ?? args.repoProjectRuntime)
|
||||
: undefined
|
||||
launchHost: resolveDirectWorkItemLaunchHost(args),
|
||||
terminalWindowsShell: resolveDirectWorkItemTabShell(args),
|
||||
launchConnectionId
|
||||
})
|
||||
|
||||
const plan =
|
||||
|
||||
@@ -207,6 +207,21 @@ describe('launchWorkItemDirect', () => {
|
||||
seedNativeChatLaunchDraft: mocks.seedNativeChatLaunchDraft,
|
||||
markNativeChatLaunchPromptFailed: mocks.markNativeChatLaunchPromptFailed
|
||||
} as typeof mocks.store
|
||||
// Why: SSH fixtures use target `ssh-1`, which reports a Linux host once connected.
|
||||
mocks.store.sshConnectionStates = new Map([
|
||||
[
|
||||
'ssh-1',
|
||||
{
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
remotePlatform: 'linux'
|
||||
}
|
||||
]
|
||||
])
|
||||
mocks.store.sshStateByEnvironment = new Map()
|
||||
mocks.store.runtimeStatusByEnvironmentId = new Map()
|
||||
// @ts-expect-error -- test shim
|
||||
globalThis.window = { api: mockApi }
|
||||
})
|
||||
@@ -418,6 +433,7 @@ describe('launchWorkItemDirect', () => {
|
||||
agentArgs: '--dangerously-skip-permissions',
|
||||
agentEnv: {},
|
||||
platform: 'win32',
|
||||
shell: 'powershell',
|
||||
isRemote: false
|
||||
})
|
||||
expect(buildAgentStartupPlan).not.toHaveBeenCalledWith(
|
||||
@@ -812,57 +828,4 @@ describe('launchWorkItemDirect', () => {
|
||||
`command test -n "$fish_pid" && set --erase -g ORCA_PI_PREFILL; command test -z "$fish_pid" && unset ORCA_PI_PREFILL; true`
|
||||
)
|
||||
})
|
||||
|
||||
it('plans direct local Windows-path launches with POSIX startup for WSL project runtime', async () => {
|
||||
mocks.store.repos = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: 'C:\\Users\\alice\\repo',
|
||||
displayName: 'Repo',
|
||||
addedAt: 1
|
||||
}
|
||||
]
|
||||
mocks.store.projects = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000000',
|
||||
sourceRepoIds: ['repo-1'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
}
|
||||
]
|
||||
mocks.store.createWorktree.mockResolvedValue({
|
||||
worktree: {
|
||||
id: 'repo-1::C:\\Users\\alice\\repo-worktree',
|
||||
path: 'C:\\Users\\alice\\repo-worktree'
|
||||
}
|
||||
})
|
||||
const { launchWorkItemDirect } = await import('./launch-work-item-direct')
|
||||
|
||||
await expect(
|
||||
launchWorkItemDirect({
|
||||
item: {
|
||||
title: 'Fix failing checks',
|
||||
url: 'https://github.com/acme/repo/pull/1',
|
||||
type: 'issue',
|
||||
number: 1,
|
||||
pasteContent: 'Fix the failing checks.'
|
||||
},
|
||||
repoId: 'repo-1',
|
||||
openModalFallback: mocks.openModalFallback,
|
||||
launchSource: 'task_page',
|
||||
agentOverride: 'codex',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(buildAgentStartupPlan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agent: 'codex',
|
||||
platform: 'linux'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
resolveDirectSetupDecision
|
||||
} from '@/lib/launch-work-item-direct-preflight'
|
||||
import type { LaunchWorkItemDirectArgs } from '@/lib/launch-work-item-direct-types'
|
||||
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
|
||||
import {
|
||||
refineLocalLaunchPlatform,
|
||||
resolveRepoExecutionHostPlatform
|
||||
} from '@/lib/execution-host-facts'
|
||||
import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import { beginDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing'
|
||||
import { prepareDirectWorkItemAgentLaunch } from '@/lib/launch-work-item-direct-route-preparation'
|
||||
@@ -90,14 +93,14 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
|
||||
const repoProjectRuntime = repoConnectionId
|
||||
? undefined
|
||||
: getLocalRepoProjectExecutionRuntimeContext(store, repoId, CLIENT_PLATFORM)
|
||||
const preflightLaunchPlatform =
|
||||
args.launchPlatform ??
|
||||
resolveSourceControlLaunchPlatform({
|
||||
connectionId: repoConnectionId,
|
||||
worktreePath: repo.path,
|
||||
projectRuntime: repoProjectRuntime
|
||||
})
|
||||
const shell = preflightLaunchPlatform === 'win32' ? 'powershell' : 'posix'
|
||||
// Why: the agent runs on the repo's execution host, so its OS decides quoting, not this client's (#27971).
|
||||
const repoLaunchFact = resolveRepoExecutionHostPlatform(store, repo, () => repoProjectRuntime)
|
||||
if (repoLaunchFact.kind === 'withheld') {
|
||||
toast.error(repoLaunchFact.reason)
|
||||
return false
|
||||
}
|
||||
const repoLaunchHost = refineLocalLaunchPlatform(repoLaunchFact, args.launchPlatform)
|
||||
const shell = repoLaunchHost.platform === 'win32' ? 'powershell' : 'posix'
|
||||
const agentArgsPlan = planAgentCliArgsSuffix(agentArgs, shell)
|
||||
if (!agentArgsPlan.ok) {
|
||||
// Why: direct launches may create a worktree before the agent startup plan
|
||||
@@ -216,6 +219,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
|
||||
draftContent,
|
||||
promptDelivery,
|
||||
launchPlatform: args.launchPlatform,
|
||||
repoLaunchHost,
|
||||
repoProjectRuntime,
|
||||
planLaunch: planAgentSessionLaunch,
|
||||
requestId: newAgentLaunchRequestId()
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./new-workspace', () => ({ CLIENT_PLATFORM: 'darwin' }))
|
||||
|
||||
describe('resolveSourceControlLaunchPlatform', () => {
|
||||
it('uses linux shell planning for ssh POSIX paths and local WSL paths', async () => {
|
||||
const { resolveSourceControlLaunchPlatform } = await import('./source-control-launch-platform')
|
||||
|
||||
expect(
|
||||
resolveSourceControlLaunchPlatform({
|
||||
connectionId: 'ssh-1',
|
||||
worktreePath: '/home/alice/repo'
|
||||
})
|
||||
).toBe('linux')
|
||||
expect(
|
||||
resolveSourceControlLaunchPlatform({
|
||||
connectionId: null,
|
||||
worktreePath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo`
|
||||
})
|
||||
).toBe('linux')
|
||||
})
|
||||
|
||||
it('uses windows shell planning for ssh Windows paths', async () => {
|
||||
const { resolveSourceControlLaunchPlatform } = await import('./source-control-launch-platform')
|
||||
|
||||
expect(
|
||||
resolveSourceControlLaunchPlatform({
|
||||
connectionId: 'ssh-1',
|
||||
worktreePath: String.raw`C:\Users\alice\repo`
|
||||
})
|
||||
).toBe('win32')
|
||||
})
|
||||
|
||||
it('uses linux shell planning for Windows paths forced to local WSL runtime', async () => {
|
||||
const { resolveSourceControlLaunchPlatform } = await import('./source-control-launch-platform')
|
||||
|
||||
expect(
|
||||
resolveSourceControlLaunchPlatform({
|
||||
connectionId: null,
|
||||
worktreePath: String.raw`C:\Users\alice\repo`,
|
||||
projectRuntime: {
|
||||
status: 'resolved',
|
||||
runtime: {
|
||||
kind: 'wsl',
|
||||
hostPlatform: 'wsl',
|
||||
distro: 'Ubuntu',
|
||||
reason: 'project-override',
|
||||
projectId: 'project-1',
|
||||
cacheKey: 'project-1:wsl:Ubuntu'
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBe('linux')
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
|
||||
import { isWslUncPath } from '../../../shared/wsl-paths'
|
||||
import { CLIENT_PLATFORM } from './new-workspace'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
|
||||
|
||||
export function resolveSourceControlLaunchPlatform(args: {
|
||||
connectionId?: string | null
|
||||
worktreePath?: string | null
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution
|
||||
}): NodeJS.Platform {
|
||||
const path = args.worktreePath?.trim() ?? ''
|
||||
if (typeof args.connectionId === 'string') {
|
||||
return path && isWindowsAbsolutePathLike(path) && !isWslUncPath(path) ? 'win32' : 'linux'
|
||||
}
|
||||
if (args.projectRuntime?.status === 'repair-required') {
|
||||
return args.projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM
|
||||
}
|
||||
if (args.projectRuntime?.status === 'resolved' && args.projectRuntime.runtime.kind === 'wsl') {
|
||||
return 'linux'
|
||||
}
|
||||
if (path && isWslUncPath(path)) {
|
||||
return 'linux'
|
||||
}
|
||||
return CLIENT_PLATFORM
|
||||
}
|
||||
@@ -69,16 +69,15 @@ export const WORKSPACE_START_LAUNCH_CASES: WindowLaunchCase[] = [
|
||||
{ command: `${POSIX_CLAUDE} --prefill 'fix Bob'"'"'s bug'`, agentCommand: POSIX_CLAUDE },
|
||||
ZSH
|
||||
),
|
||||
// main today: the work item quotes for PowerShell on Windows even when cmd.exe is the shell
|
||||
// (C4 keeps it; C4-fix flips this row to cmd.exe quoting).
|
||||
// The work item quotes for the cmd.exe tab it spawns (#27971 passes the local shell).
|
||||
start(
|
||||
'work item, Windows C: repo, cmd.exe set',
|
||||
'win32',
|
||||
repo(WIN_PATH),
|
||||
draft,
|
||||
{
|
||||
command: `${POSIX_CLAUDE} --prefill 'fix Bob''s bug'`,
|
||||
agentCommand: POSIX_CLAUDE,
|
||||
command: `${CMD_CLAUDE} --prefill "fix Bob's bug"`,
|
||||
agentCommand: CMD_CLAUDE,
|
||||
...IN_CMD
|
||||
},
|
||||
CMD_EXE,
|
||||
|
||||
@@ -147,4 +147,14 @@ describe('one Unix startup dialect', () => {
|
||||
)
|
||||
expect(plan?.env?.ORCA_PI_PREFILL).toBe('hello')
|
||||
})
|
||||
|
||||
it('leaves a multi-line argv draft to the paste fallback in cmd.exe', () => {
|
||||
const draft = 'Linked Linear issue: ENG-1\nhttps://linear.app/acme/issue/ENG-1'
|
||||
const args = { agent: 'claude' as const, draft, cmdOverrides: {}, platform: 'win32' as const }
|
||||
|
||||
expect(buildAgentDraftLaunchPlan({ ...args, shell: 'cmd' })).toBeNull()
|
||||
expect(buildAgentDraftLaunchPlan({ ...args, shell: 'powershell' })?.launchCommand).toContain(
|
||||
'`n'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -253,6 +253,10 @@ export function buildAgentDraftLaunchPlan(args: {
|
||||
agentCommand: baseCommand.commandWithoutSessionOptions
|
||||
})
|
||||
let plan: AgentDraftLaunchPlan | null = null
|
||||
if (config.draftPromptFlag && shell === 'cmd' && /[\r\n]/.test(trimmed)) {
|
||||
// Why: cmd.exe cannot quote a newline, so each one would submit the line early; paste instead.
|
||||
return null
|
||||
}
|
||||
if (config.draftPromptFlag) {
|
||||
const quoted = quoteStartupArg(trimmed, shell)
|
||||
plan = {
|
||||
|
||||
Reference in New Issue
Block a user