mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
refactor(pty): one spawn preflight step for both launch lanes (#29101)
* test(launch): pin where the two spawn preflights differ, on main Row 3 (one spawn preflight for both launch lanes) first pins main's behaviour: - Row 8, agents started with their workspace (from the earlier row-8 work): a work item's "start agent" and the folder-workspace composer through the real producer and pane and pty:spawn: draft and note on argv, a long multi-line note, PowerShell quoting under cmd.exe, project WSL, terminal shell WSL, and a prompt-less Codex start with no shell wait. - A deleted folder: the window starts in the workspace root and says so; the host passes the folder on and the provider refuses it. - A Windows plain terminal in a folder containing a WSL-project repo, and in a \\wsl$ repo with no saved runtime: the window uses the runtime the renderer sends; the host reads only the repo's project. - A managed SSH Windows host: only the host lane opens the OpenSSH login shell. - A pane's own agent launch drops the root fallback (TEMPORARY parity). * refactor(pty): one spawn preflight step for both launch lanes The window's pty:spawn and the host's runtime controller each kept their own copy of the first spawn step: resolve the folder a new terminal starts in and the shell/runtime its PTY uses. resolvePtySpawnStartupTarget now does both for both lanes. Each lane passes the two inputs it still differs on, so nothing changes for users: - missingCwdFallback (TEMPORARY, row 13a): only window starts fall back to the workspace root, with a notice, when the requested folder is gone (including the WSL cwd probe). - projectRuntimeSource (TEMPORARY, row 13b): the window uses the runtime its renderer sent; the host reads the repo's project from the store. The OpenSSH login shell (#9327) now resolves for both lanes; only an SSH-launched orcad turns it on, and that process has no window, so the window lane never meets it. Both lanes resolve after fresh-spawn recovery and session-id mint (the WSL distro reads the session id). Admission, adoption, hidden mark, recovery and mint stay where they were.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentPaneLaunchRequest } from '../../shared/agent-pane-launch-request'
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import { statSyncMock } from './pty-ipc-mock-registry'
|
||||
import { registerPtyHandlers } from './pty/register-handlers'
|
||||
import {
|
||||
markAgentLaunchesClosedByUser,
|
||||
@@ -108,7 +109,6 @@ describe('a pane that starts its own agent launch', () => {
|
||||
): Promise<unknown> {
|
||||
return Promise.resolve(
|
||||
handlers.get('pty:spawn')!(null, {
|
||||
...pane,
|
||||
cols: 132,
|
||||
rows: 40,
|
||||
cwd: '/tmp/pane-launch',
|
||||
@@ -116,7 +116,8 @@ describe('a pane that starts its own agent launch', () => {
|
||||
tabId,
|
||||
leafId,
|
||||
command: '',
|
||||
agentLaunch
|
||||
agentLaunch,
|
||||
...pane
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -181,6 +182,25 @@ describe('a pane that starts its own agent launch', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// TEMPORARY parity (row 3 pin; spawn-agent-launch.ts drops cwdFallback): a launch into a deleted
|
||||
// folder fails there, as the host's launch does, instead of starting in the workspace root.
|
||||
it('starts in the requested folder even when it is gone, without the root fallback', async () => {
|
||||
const missing = '/tmp/pane-launch/deleted'
|
||||
statSyncMock.mockImplementation((target: string) => {
|
||||
if (target === missing) {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
return { isDirectory: () => true, mode: 0o755 }
|
||||
})
|
||||
const providerSpawn = vi.fn(async (_options: { cwd?: string }) => ({ id: 'pty-agent' }))
|
||||
register(providerSpawn)
|
||||
|
||||
const result = await spawnPane(AI_BUTTON, { cwd: missing, cwdFallback: 'worktree' })
|
||||
|
||||
expect(providerSpawn.mock.calls[0]?.[0].cwd).toBe(missing)
|
||||
expect(result).not.toHaveProperty('startupCwdFallback')
|
||||
})
|
||||
|
||||
it('starts one agent when the pane spawns twice at once', async () => {
|
||||
const providerSpawn = vi.fn(async () => ({ id: 'pty-agent' }))
|
||||
register(providerSpawn)
|
||||
|
||||
@@ -146,7 +146,11 @@ export function startParityLanes(
|
||||
): ParityLanes {
|
||||
const settings = parityMainSettings(args.settings)
|
||||
const persistPtyBinding = vi.fn(async () => true)
|
||||
const { repo, folderWorkspace } = launchScope(args.workspace)
|
||||
const { repo: scopeRepo, folderWorkspace } = launchScope(args.workspace)
|
||||
// A folder's contained repo is saved too, so the host can borrow its runtime if it would.
|
||||
const repo =
|
||||
scopeRepo ??
|
||||
(args.workspace.repoPath ? launchScope({ kind: 'repo', path: args.workspace.repoPath }).repo : null)
|
||||
const store = {
|
||||
getSettings: () => settings,
|
||||
getProjects: () => [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import { classifyErrorMock, trackMock } from './pty-ipc-mock-registry'
|
||||
import { classifyErrorMock, statSyncMock, trackMock } from './pty-ipc-mock-registry'
|
||||
import {
|
||||
providerFacts,
|
||||
recordOf,
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
} from './pty-launch-parity.test-fixture'
|
||||
import {
|
||||
POSIX_PATH,
|
||||
WINDOW_LAUNCH_CASES
|
||||
WIN_PATH,
|
||||
WINDOW_LAUNCH_CASES,
|
||||
WSL_PATH
|
||||
} from '../../shared/launch-parity-window-cases.test-fixture'
|
||||
import { PLUS_LAUNCH_CASES } from '../../shared/launch-parity-plus-cases.test-fixture'
|
||||
import { WORKSPACE_START_LAUNCH_CASES } from '../../shared/launch-parity-workspace-start-cases.test-fixture'
|
||||
import {
|
||||
expectedWindowProvider,
|
||||
launchWorkspaceId,
|
||||
@@ -53,6 +56,7 @@ import {
|
||||
resolveOpenCodeDirectModelExecutable
|
||||
} from '../opencode/opencode-model-availability'
|
||||
import { probeOpenCodeLaunchModelContext } from '../opencode/opencode-launch-model-context'
|
||||
import { readOpenSshDefaultShell } from '../../shared/openssh-default-shell'
|
||||
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
@@ -120,6 +124,8 @@ vi.mock('../antigravity/native-account-launch', () => ({
|
||||
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
|
||||
)
|
||||
// Why: the OpenSSH DefaultShell registry read; only a managed SSH host's case answers it.
|
||||
vi.mock('../../shared/openssh-default-shell', () => ({ readOpenSshDefaultShell: vi.fn(() => '') }))
|
||||
|
||||
type WindowLaneCase = {
|
||||
label: string
|
||||
@@ -133,7 +139,7 @@ type WindowLaneCase = {
|
||||
}
|
||||
|
||||
const WINDOW_LANE_CASES: WindowLaneCase[] = [
|
||||
...[...WINDOW_LAUNCH_CASES, ...PLUS_LAUNCH_CASES].map((c) => ({
|
||||
...[...WINDOW_LAUNCH_CASES, ...PLUS_LAUNCH_CASES, ...WORKSPACE_START_LAUNCH_CASES].map((c) => ({
|
||||
...c,
|
||||
label: `row ${c.row}: ${c.name}`,
|
||||
request: windowSpawnRequest(c),
|
||||
@@ -147,7 +153,7 @@ const WINDOW_LANE_CASES: WindowLaneCase[] = [
|
||||
}))
|
||||
]
|
||||
|
||||
// Pins main's current launch behaviour as the convergence parity baseline (rows 1, 2, 5 and 6, main
|
||||
// Pins main's current launch behaviour as the convergence parity baseline (rows 1, 2, 5, 6 and 8, main
|
||||
// half): each table request, fed to pty:spawn, reaches the provider with these facts (the pane's
|
||||
// measured size, never hidden), one agent_started, and a pane binding.
|
||||
// C2 (row 1), C5a-e and C6 check the merged spawn pipeline against it.
|
||||
@@ -451,3 +457,112 @@ describe('launch facts outside the tables', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const WSL_UBUNTU = { kind: 'wsl', distro: 'Ubuntu' } as const
|
||||
// What the renderer sends for a WSL project (it reads a never-saved \\wsl$ path the same way).
|
||||
const WSL_PROJECT_SENT = {
|
||||
status: 'resolved',
|
||||
runtime: {
|
||||
kind: 'wsl',
|
||||
hostPlatform: 'wsl',
|
||||
projectId: 'repo-1',
|
||||
distro: 'Ubuntu',
|
||||
reason: 'project-override',
|
||||
cacheKey: 'repo-1:wsl:Ubuntu'
|
||||
}
|
||||
}
|
||||
const LOGIN_SHELL = String.raw`C:\Program Files\PowerShell\7\pwsh.exe`
|
||||
|
||||
// Pins main's current spawn preflight (row 3, C5b): where the two lanes resolve a plain terminal's
|
||||
// start differently today. Row 13a removes the missing-folder split; row 13b the runtime split.
|
||||
describe('spawn preflight: where the lanes differ today', () => {
|
||||
const suite = setupPtyIpcSuite()
|
||||
afterEach(() => {
|
||||
restoreMainPlatform()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
async function spawnBoth(
|
||||
lanes: ParityLanes,
|
||||
workspace: LaunchWorkspace,
|
||||
window: Record<string, unknown>,
|
||||
host: Record<string, unknown> = {}
|
||||
): Promise<Record<'window' | 'host', Record<string, unknown>>> {
|
||||
const worktreeId = launchWorkspaceId(workspace)
|
||||
const result = recordOf(
|
||||
await lanes.spawnWindow({ cols: 80, rows: 24, cwd: workspace.path, worktreeId, ...window })
|
||||
)
|
||||
const windowFacts = { ...(await providerFacts(lanes.provider)), result }
|
||||
await lanes.runtime.createTerminal(`id:${worktreeId}`, host)
|
||||
return { window: windowFacts, host: await providerFacts(lanes.provider) }
|
||||
}
|
||||
|
||||
// The host hands the provider the missing folder, and the provider's own check then refuses it
|
||||
// ("Working directory ... does not exist").
|
||||
it('a deleted folder: the window starts in the workspace root and says so; the host does not', async () => {
|
||||
setMainPlatform('darwin')
|
||||
const missing = `${POSIX_PATH}/deleted`
|
||||
statSyncMock.mockImplementation((target: string) => {
|
||||
if (target === missing) {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
return { isDirectory: () => true, mode: 0o755 }
|
||||
})
|
||||
const lanes = startParityLanes(suite, { workspace: POSIX_REPO })
|
||||
const facts = await spawnBoth(
|
||||
lanes,
|
||||
POSIX_REPO,
|
||||
{ cwd: missing, cwdFallback: 'worktree' },
|
||||
{ cwd: missing }
|
||||
)
|
||||
|
||||
expect(facts.window).toMatchObject({
|
||||
cwd: POSIX_PATH,
|
||||
result: { startupCwdFallback: { kind: 'worktree', cwd: POSIX_PATH } }
|
||||
})
|
||||
expect(facts.host).toMatchObject({ cwd: missing })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'a folder containing a WSL-project repo',
|
||||
workspace: {
|
||||
kind: 'folder',
|
||||
path: String.raw`C:\Users\alice`,
|
||||
repoPath: WIN_PATH,
|
||||
projectRuntime: WSL_UBUNTU
|
||||
} satisfies LaunchWorkspace,
|
||||
host: { shellOverride: 'powershell.exe', terminalWindowsWslDistro: null }
|
||||
},
|
||||
{
|
||||
name: 'a \\wsl$ repo with no saved runtime',
|
||||
workspace: { kind: 'repo', path: WSL_PATH } satisfies LaunchWorkspace,
|
||||
// The distro comes from the path, but the shell stays the settings' PowerShell.
|
||||
host: { shellOverride: 'powershell.exe', terminalWindowsWslDistro: 'Ubuntu' }
|
||||
}
|
||||
])('Windows plain terminal, $name: the window uses the sent runtime', async (c) => {
|
||||
setMainPlatform('win32')
|
||||
const lanes = startParityLanes(suite, { workspace: c.workspace })
|
||||
const facts = await spawnBoth(lanes, c.workspace, { projectRuntime: WSL_PROJECT_SENT })
|
||||
|
||||
expect(facts.window).toMatchObject({
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(facts.host).toMatchObject(c.host)
|
||||
})
|
||||
|
||||
// One resolution reads it for both lanes. Only an SSH-launched orcad has this env, and it has no
|
||||
// window (no pty:spawn), so the window lane never meets it in practice.
|
||||
it('Windows managed SSH host: both lanes open the OpenSSH login shell', async () => {
|
||||
setMainPlatform('win32')
|
||||
vi.stubEnv('ORCA_ORCAD_MANAGED_ACTIVATION_ROOT', '/activation')
|
||||
vi.mocked(readOpenSshDefaultShell).mockReturnValue(LOGIN_SHELL)
|
||||
const workspace = { kind: 'repo', path: WIN_PATH } satisfies LaunchWorkspace
|
||||
const lanes = startParityLanes(suite, { workspace })
|
||||
const facts = await spawnBoth(lanes, workspace, {})
|
||||
|
||||
expect(facts.window).toMatchObject({ shellOverride: LOGIN_SHELL })
|
||||
expect(facts.host).toMatchObject({ shellOverride: LOGIN_SHELL })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-
|
||||
import { buildPtyHostEnv } from '../host-env/assembly'
|
||||
import {
|
||||
getCompatibleSelectedCodexHomePath,
|
||||
getCodexSelectionTargetForPty,
|
||||
resolveCodexHomeAfterManagedAuthReadiness,
|
||||
shouldSkipCodexHomeEnvForWindowsShell,
|
||||
shouldStripInheritedOrcaCodexHome,
|
||||
@@ -29,11 +28,6 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis
|
||||
cwd: args.cwd,
|
||||
shellOverride: ctx.effectiveShellOverride
|
||||
})
|
||||
ctx.codexSelectionTarget = getCodexSelectionTargetForPty(
|
||||
ctx.effectiveShellOverride,
|
||||
ctx.cwd,
|
||||
ctx.expectedWslDistro
|
||||
)
|
||||
const codexResumePreparation = ctx.preAdoptedStablePane
|
||||
? null
|
||||
: ctx.deps.prepareCodexResumeHome({
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import { withClaudeProfileTerminalEnv } from '../../../claude-accounts/claude-profile-installed-router'
|
||||
import {
|
||||
isWslShellName,
|
||||
resolveLocalWindowsTerminalRuntimeOptions
|
||||
} from '../../../../shared/local-windows-terminal-runtime'
|
||||
import { isWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths'
|
||||
import { mintPtySessionId } from '../../../daemon/pty-session-id'
|
||||
import { resolveWslSessionContext } from '../../../daemon/wsl-session-context'
|
||||
import { LocalPtyProvider } from '../../../providers/local-pty-provider'
|
||||
import { normalizeWindowsTerminalCwd } from '../../../providers/windows-shell-args'
|
||||
import { wslUncDirectoryExistsAsync } from '../../../wsl'
|
||||
import { getCodexSelectionTargetForPty } from '../host-env/codex-home'
|
||||
import {
|
||||
isClaudeLaunchCommand,
|
||||
recoverFreshSpawnProviderRouting,
|
||||
routesFreshSpawnsToLocalProvider
|
||||
} from '../host-env/fresh-spawn-routing'
|
||||
import { getProvider } from '../provider/registry'
|
||||
import { resolvePtySpawnStartupTarget } from '../spawn-startup-target'
|
||||
import type { PtyIpcSpawnState } from './spawn-state'
|
||||
import { getConnectionExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
@@ -65,89 +57,6 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis
|
||||
await admission
|
||||
}
|
||||
}
|
||||
if (!ctx.preAdoptedStablePane) {
|
||||
// Why: reattach needs exact cwd, and successful stable-pane adoption needs no launch preflight.
|
||||
const requestedMissingCwdFallback = !args.sessionId && args.cwdFallback === 'worktree'
|
||||
const isPosixStartupCwd = args.cwd?.startsWith('/') === true
|
||||
const startupWorkspaceCwd =
|
||||
requestedMissingCwdFallback && isPosixStartupCwd
|
||||
? ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, '.')
|
||||
: undefined
|
||||
const initiallyResolvedStartupCwd =
|
||||
requestedMissingCwdFallback && isPosixStartupCwd ? ctx.cwd : undefined
|
||||
const startupTerminalRuntimeOptions =
|
||||
requestedMissingCwdFallback && process.platform === 'win32'
|
||||
? resolveLocalWindowsTerminalRuntimeOptions({
|
||||
requestedShellOverride: args.shellOverride,
|
||||
settings: ctx.deps.getSettings?.(),
|
||||
projectRuntime: args.projectRuntime,
|
||||
fallbackHostShell: process.env.COMSPEC || 'powershell.exe'
|
||||
})
|
||||
: undefined
|
||||
const wslRuntimeOwnsStartupCwd =
|
||||
requestedMissingCwdFallback &&
|
||||
isPosixStartupCwd &&
|
||||
(isWslShellName(startupTerminalRuntimeOptions?.shellOverride) ||
|
||||
isWslUncPath(startupWorkspaceCwd ?? ''))
|
||||
const startupWslContext = wslRuntimeOwnsStartupCwd
|
||||
? resolveWslSessionContext({
|
||||
cwd: startupWorkspaceCwd,
|
||||
shellOverride: startupTerminalRuntimeOptions?.shellOverride,
|
||||
terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro
|
||||
})
|
||||
: undefined
|
||||
let wslStartupCwdExists: boolean | null = null
|
||||
let wslWorkspaceCwdExists: boolean | null = null
|
||||
if (startupWslContext && initiallyResolvedStartupCwd) {
|
||||
const validationCwd = toWindowsWslPath(initiallyResolvedStartupCwd, startupWslContext.distro)
|
||||
wslStartupCwdExists = isWslUncPath(validationCwd)
|
||||
? await wslUncDirectoryExistsAsync(validationCwd)
|
||||
: ctx.deps.localStartupCwdDirectoryExists(validationCwd)
|
||||
if (wslStartupCwdExists === true) {
|
||||
ctx.prevalidatedCwd = validationCwd
|
||||
}
|
||||
if (wslStartupCwdExists === false && startupWorkspaceCwd) {
|
||||
wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd)
|
||||
? await wslUncDirectoryExistsAsync(startupWorkspaceCwd)
|
||||
: ctx.deps.localStartupCwdDirectoryExists(startupWorkspaceCwd)
|
||||
if (wslWorkspaceCwdExists === true) {
|
||||
ctx.prevalidatedCwd = startupWorkspaceCwd
|
||||
}
|
||||
}
|
||||
}
|
||||
const allowMissingCwdFallback =
|
||||
requestedMissingCwdFallback &&
|
||||
(!wslRuntimeOwnsStartupCwd ||
|
||||
(wslStartupCwdExists === false && wslWorkspaceCwdExists === true))
|
||||
let didFallbackToWorkspaceRootCwd = false
|
||||
ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(
|
||||
args.worktreeId,
|
||||
args.cwd,
|
||||
allowMissingCwdFallback
|
||||
? {
|
||||
directoryExists: (path) =>
|
||||
startupWslContext &&
|
||||
wslStartupCwdExists === false &&
|
||||
path === initiallyResolvedStartupCwd
|
||||
? false
|
||||
: startupWslContext && path === startupWorkspaceCwd
|
||||
? wslWorkspaceCwdExists === true
|
||||
: ctx.deps.localStartupCwdDirectoryExists(
|
||||
process.platform === 'win32' ? normalizeWindowsTerminalCwd(path) : path
|
||||
),
|
||||
onFallbackToWorkspaceRoot: () => {
|
||||
didFallbackToWorkspaceRootCwd = true
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
if (didFallbackToWorkspaceRootCwd && wslWorkspaceCwdExists === true && ctx.cwd) {
|
||||
ctx.prevalidatedCwd = ctx.cwd
|
||||
}
|
||||
ctx.startupCwdFallback =
|
||||
didFallbackToWorkspaceRootCwd && ctx.cwd ? { kind: 'worktree', cwd: ctx.cwd } : undefined
|
||||
}
|
||||
ctx.spawnTiming.mark('preflight')
|
||||
const freshSpawnRecovery = ctx.preAdoptedStablePane
|
||||
? undefined
|
||||
: recoverFreshSpawnProviderRouting(ctx.provider, args.sessionId)
|
||||
@@ -174,40 +83,35 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis
|
||||
}
|
||||
}
|
||||
ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && isClaudeLaunchCommand(args.command)
|
||||
ctx.terminalRuntimeOptions =
|
||||
ctx.paneAgentLaunch?.launchRuntime?.pty ??
|
||||
(process.platform === 'win32'
|
||||
? resolveLocalWindowsTerminalRuntimeOptions({
|
||||
requestedShellOverride: args.shellOverride,
|
||||
settings: ctx.deps.getSettings?.(),
|
||||
projectRuntime: args.projectRuntime,
|
||||
fallbackHostShell: process.env.COMSPEC || 'powershell.exe'
|
||||
})
|
||||
: {
|
||||
shellOverride:
|
||||
args.shellOverride ?? (ctx.deps.getSettings?.()?.terminalDefaultShell || undefined),
|
||||
terminalWindowsWslDistro: null
|
||||
})
|
||||
const initialShellOverride = ctx.terminalRuntimeOptions.shellOverride
|
||||
// Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint().
|
||||
// Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state.
|
||||
// Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber.
|
||||
ctx.expectedWslDistro =
|
||||
resolveWslSessionContext({
|
||||
cwd: ctx.cwd,
|
||||
sessionId: ctx.effectiveSessionId,
|
||||
shellOverride: ctx.terminalRuntimeOptions.shellOverride,
|
||||
terminalWindowsWslDistro: ctx.terminalRuntimeOptions.terminalWindowsWslDistro
|
||||
})?.distro ?? null
|
||||
const initialSelectionTarget = getCodexSelectionTargetForPty(
|
||||
initialShellOverride,
|
||||
ctx.cwd,
|
||||
ctx.expectedWslDistro
|
||||
)
|
||||
args.env = withClaudeProfileTerminalEnv(args.env, initialSelectionTarget)
|
||||
const target = await resolvePtySpawnStartupTarget({
|
||||
worktreeId: args.worktreeId,
|
||||
cwd: args.cwd,
|
||||
sessionId: ctx.effectiveSessionId,
|
||||
shellOverride: args.shellOverride,
|
||||
plannedPty: ctx.paneAgentLaunch?.launchRuntime?.pty,
|
||||
projectRuntimeSource: { kind: 'sent', projectRuntime: args.projectRuntime },
|
||||
// Why: reattach needs exact cwd, and successful stable-pane adoption needs no launch preflight.
|
||||
missingCwdFallback:
|
||||
!ctx.preAdoptedStablePane && !args.sessionId && args.cwdFallback === 'worktree'
|
||||
? { directoryExists: ctx.deps.localStartupCwdDirectoryExists }
|
||||
: undefined,
|
||||
getSettings: ctx.deps.getSettings,
|
||||
resolveStartupCwd: ctx.deps.resolvePtySpawnStartupCwd
|
||||
})
|
||||
ctx.cwd = target.cwd
|
||||
ctx.prevalidatedCwd = target.prevalidatedCwd
|
||||
ctx.startupCwdFallback = target.startupCwdFallback
|
||||
ctx.terminalRuntimeOptions = target.terminalRuntimeOptions
|
||||
ctx.expectedWslDistro = target.expectedWslDistro
|
||||
ctx.codexSelectionTarget = target.codexSelectionTarget
|
||||
ctx.spawnTiming.mark('preflight')
|
||||
args.env = withClaudeProfileTerminalEnv(args.env, ctx.codexSelectionTarget)
|
||||
ctx.claudeAuth =
|
||||
ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth
|
||||
? await ctx.deps.prepareClaudeAuth(initialSelectionTarget)
|
||||
? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget)
|
||||
: null
|
||||
ctx.spawnTiming.mark('auth')
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { getProvider } from '../provider/registry'
|
||||
import { buildPtyHostEnv } from '../host-env/assembly'
|
||||
import {
|
||||
getCompatibleSelectedCodexHomePath,
|
||||
getCodexSelectionTargetForPty,
|
||||
resolveCodexHomeAfterManagedAuthReadiness,
|
||||
shouldSkipCodexHomeEnvForWindowsShell,
|
||||
shouldStripInheritedOrcaCodexHome,
|
||||
@@ -27,9 +26,7 @@ import {
|
||||
mintPtySessionId,
|
||||
ptySessionIdForAgentCreateOperation
|
||||
} from '../../../daemon/pty-session-id'
|
||||
import { resolveWslSessionContext } from '../../../daemon/wsl-session-context'
|
||||
import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls'
|
||||
import { resolvePlainTerminalPtyOptions } from '../../../local-project-runtime-resolution'
|
||||
import { resolvePathEnvKey } from '../../../pty/windows-environment-path'
|
||||
import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env'
|
||||
import { stampRuntimeSourceEnv } from '../../../../shared/runtime-source-env'
|
||||
@@ -38,6 +35,7 @@ import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-
|
||||
import { clearProviderPtyState } from '../provider/state-cleanup'
|
||||
import { awaitExplicitPiOmpGuestReadiness } from '../../../agent-hooks/wsl-pi-omp-guest-readiness'
|
||||
import type { RuntimePtySpawnState } from './spawn-state'
|
||||
import { resolvePtySpawnStartupTarget } from '../spawn-startup-target'
|
||||
import { getConnectionExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
export async function prepareRuntimePtySpawn(
|
||||
@@ -50,7 +48,6 @@ export async function prepareRuntimePtySpawn(
|
||||
await admission
|
||||
}
|
||||
}
|
||||
ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd)
|
||||
// Only this machine spawns here: an `ssh:` connection throws, so every line below is local.
|
||||
ctx.provider = getProvider(getConnectionExecutionHostId(args.connectionId))
|
||||
const freshSpawnRecovery = ctx.preAdoptedStablePane
|
||||
@@ -60,17 +57,6 @@ export async function prepareRuntimePtySpawn(
|
||||
await freshSpawnRecovery
|
||||
}
|
||||
ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && isClaudeLaunchCommand(args.command)
|
||||
// Why: runtime-created terminals carry no renderer projectRuntime. An agent launch brings the PTY
|
||||
// its command was quoted for; anything else takes the project's runtime and `--shell` pick.
|
||||
ctx.terminalRuntimeOptions =
|
||||
args.launchRuntime?.pty ??
|
||||
resolvePlainTerminalPtyOptions(
|
||||
ctx.deps.store,
|
||||
ctx.deps.getSettings?.(),
|
||||
args.worktreeId,
|
||||
args.shellOverride
|
||||
)
|
||||
ctx.daemonShellOverride = ctx.terminalRuntimeOptions.shellOverride
|
||||
ctx.isDaemonHostSpawn =
|
||||
!(ctx.provider instanceof LocalPtyProvider) && !routesFreshSpawnsToLocalProvider(ctx.provider)
|
||||
ctx.callerRequestedSessionId = args.sessionId?.trim()
|
||||
@@ -87,18 +73,23 @@ export async function prepareRuntimePtySpawn(
|
||||
!ctx.preAdoptedStablePane &&
|
||||
ctx.isDaemonHostSpawn &&
|
||||
(ctx.callerRequestedSessionId === undefined || args.isNewSession === true)
|
||||
ctx.expectedWslDistro =
|
||||
resolveWslSessionContext({
|
||||
cwd: ctx.cwd,
|
||||
sessionId: ctx.sessionId,
|
||||
shellOverride: ctx.terminalRuntimeOptions.shellOverride,
|
||||
terminalWindowsWslDistro: ctx.terminalRuntimeOptions.terminalWindowsWslDistro
|
||||
})?.distro ?? null
|
||||
ctx.codexSelectionTarget = getCodexSelectionTargetForPty(
|
||||
ctx.daemonShellOverride,
|
||||
ctx.cwd,
|
||||
ctx.expectedWslDistro
|
||||
)
|
||||
// Why: runtime-created terminals carry no renderer projectRuntime. An agent launch brings the PTY
|
||||
// its command was quoted for; anything else takes the project's runtime and `--shell` pick.
|
||||
const target = await resolvePtySpawnStartupTarget({
|
||||
worktreeId: args.worktreeId,
|
||||
cwd: args.cwd,
|
||||
sessionId: ctx.sessionId,
|
||||
shellOverride: args.shellOverride,
|
||||
plannedPty: args.launchRuntime?.pty,
|
||||
projectRuntimeSource: { kind: 'store', store: ctx.deps.store },
|
||||
getSettings: ctx.deps.getSettings,
|
||||
resolveStartupCwd: ctx.deps.resolvePtySpawnStartupCwd
|
||||
})
|
||||
ctx.cwd = target.cwd
|
||||
ctx.terminalRuntimeOptions = target.terminalRuntimeOptions
|
||||
ctx.daemonShellOverride = ctx.terminalRuntimeOptions.shellOverride
|
||||
ctx.expectedWslDistro = target.expectedWslDistro
|
||||
ctx.codexSelectionTarget = target.codexSelectionTarget
|
||||
const codexResumePreparation = ctx.preAdoptedStablePane
|
||||
? null
|
||||
: ctx.deps.prepareCodexResumeHome({
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Where a new PTY starts and in which shell/runtime, for both the window and the host spawn lane.
|
||||
|
||||
import {
|
||||
isWslShellName,
|
||||
type LocalWindowsTerminalRuntimeOptions
|
||||
} from '../../../shared/local-windows-terminal-runtime'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
|
||||
import type { TerminalStartupCwdMissingDirFallback } from '../../../shared/terminal-startup-cwd'
|
||||
import { isWslUncPath, toWindowsWslPath } from '../../../shared/wsl-paths'
|
||||
import type { CodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection'
|
||||
import { resolveWslSessionContext } from '../../daemon/wsl-session-context'
|
||||
import {
|
||||
resolveLocalProjectRuntimeForWorktreeId,
|
||||
resolveLocalPtyOptions,
|
||||
type ProjectRuntimeResolutionStore
|
||||
} from '../../local-project-runtime-resolution'
|
||||
import { normalizeWindowsTerminalCwd } from '../../providers/windows-shell-args'
|
||||
import { wslUncDirectoryExistsAsync } from '../../wsl'
|
||||
import { getCodexSelectionTargetForPty } from './host-env/codex-home'
|
||||
|
||||
// TEMPORARY (row 13b removes it): the window uses the renderer's runtime; the host reads the store.
|
||||
export type PtySpawnProjectRuntimeSource =
|
||||
| { kind: 'sent'; projectRuntime: ProjectExecutionRuntimeResolution | undefined }
|
||||
| { kind: 'store'; store: ProjectRuntimeResolutionStore | undefined }
|
||||
|
||||
export type PtySpawnStartupTarget = {
|
||||
cwd: string | undefined
|
||||
/** Already proven to exist by the WSL probe. */
|
||||
prevalidatedCwd?: string
|
||||
startupCwdFallback?: { kind: 'worktree'; cwd: string }
|
||||
terminalRuntimeOptions: LocalWindowsTerminalRuntimeOptions
|
||||
expectedWslDistro: string | null
|
||||
codexSelectionTarget: CodexAccountSelectionTarget
|
||||
}
|
||||
|
||||
type PtySpawnStartupArgs = {
|
||||
worktreeId: string | undefined
|
||||
cwd: string | undefined
|
||||
/** Its worktree can imply the WSL distro. */
|
||||
sessionId: string | undefined
|
||||
shellOverride: string | undefined
|
||||
/** The PTY an agent launch's command was quoted for. */
|
||||
plannedPty?: LocalWindowsTerminalRuntimeOptions
|
||||
projectRuntimeSource: PtySpawnProjectRuntimeSource
|
||||
/** TEMPORARY (row 13a removes it): only window starts fall back to the root of a missing cwd. */
|
||||
missingCwdFallback?: { directoryExists: (path: string) => boolean }
|
||||
getSettings: (() => Parameters<typeof resolveLocalPtyOptions>[0]) | undefined
|
||||
resolveStartupCwd: (
|
||||
worktreeId: string | undefined,
|
||||
cwd: string | undefined,
|
||||
missingDirFallback?: TerminalStartupCwdMissingDirFallback
|
||||
) => string | undefined
|
||||
}
|
||||
|
||||
export async function resolvePtySpawnStartupTarget(
|
||||
args: PtySpawnStartupArgs
|
||||
): Promise<PtySpawnStartupTarget> {
|
||||
const source = args.projectRuntimeSource
|
||||
const terminalRuntimeOptions =
|
||||
args.plannedPty ??
|
||||
resolveLocalPtyOptions(
|
||||
args.getSettings?.(),
|
||||
source.kind === 'sent'
|
||||
? source.projectRuntime
|
||||
: resolveLocalProjectRuntimeForWorktreeId(source.store, args.worktreeId),
|
||||
args.shellOverride,
|
||||
process.env.COMSPEC || 'powershell.exe'
|
||||
)
|
||||
const startup = args.missingCwdFallback
|
||||
? await resolveStartupCwdWithFallback(args, args.missingCwdFallback, terminalRuntimeOptions)
|
||||
: { cwd: args.resolveStartupCwd(args.worktreeId, args.cwd) }
|
||||
const expectedWslDistro =
|
||||
resolveWslSessionContext({
|
||||
cwd: startup.cwd,
|
||||
sessionId: args.sessionId,
|
||||
shellOverride: terminalRuntimeOptions.shellOverride,
|
||||
terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro
|
||||
})?.distro ?? null
|
||||
return {
|
||||
...startup,
|
||||
terminalRuntimeOptions,
|
||||
expectedWslDistro,
|
||||
codexSelectionTarget: getCodexSelectionTargetForPty(
|
||||
terminalRuntimeOptions.shellOverride,
|
||||
startup.cwd,
|
||||
expectedWslDistro
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveStartupCwdWithFallback(
|
||||
{ worktreeId, resolveStartupCwd, ...args }: PtySpawnStartupArgs,
|
||||
fallback: { directoryExists: (path: string) => boolean },
|
||||
runtimeOptions: LocalWindowsTerminalRuntimeOptions
|
||||
): Promise<Pick<PtySpawnStartupTarget, 'cwd' | 'prevalidatedCwd' | 'startupCwdFallback'>> {
|
||||
const isPosixStartupCwd = args.cwd?.startsWith('/') === true
|
||||
const startupWorkspaceCwd = isPosixStartupCwd ? resolveStartupCwd(worktreeId, '.') : undefined
|
||||
const initiallyResolvedStartupCwd = isPosixStartupCwd
|
||||
? resolveStartupCwd(worktreeId, args.cwd)
|
||||
: undefined
|
||||
// Only a Windows runtime's shell can put a POSIX cwd inside WSL.
|
||||
const startupRuntime = process.platform === 'win32' ? runtimeOptions : undefined
|
||||
const wslRuntimeOwnsStartupCwd =
|
||||
isPosixStartupCwd &&
|
||||
(isWslShellName(startupRuntime?.shellOverride) || isWslUncPath(startupWorkspaceCwd ?? ''))
|
||||
const startupWslContext = wslRuntimeOwnsStartupCwd
|
||||
? resolveWslSessionContext({
|
||||
cwd: startupWorkspaceCwd,
|
||||
shellOverride: startupRuntime?.shellOverride,
|
||||
terminalWindowsWslDistro: startupRuntime?.terminalWindowsWslDistro
|
||||
})
|
||||
: undefined
|
||||
let prevalidatedCwd: string | undefined
|
||||
let wslStartupCwdExists: boolean | null = null
|
||||
let wslWorkspaceCwdExists: boolean | null = null
|
||||
if (startupWslContext && initiallyResolvedStartupCwd) {
|
||||
const validationCwd = toWindowsWslPath(initiallyResolvedStartupCwd, startupWslContext.distro)
|
||||
wslStartupCwdExists = isWslUncPath(validationCwd)
|
||||
? await wslUncDirectoryExistsAsync(validationCwd)
|
||||
: fallback.directoryExists(validationCwd)
|
||||
if (wslStartupCwdExists === true) {
|
||||
prevalidatedCwd = validationCwd
|
||||
}
|
||||
if (wslStartupCwdExists === false && startupWorkspaceCwd) {
|
||||
wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd)
|
||||
? await wslUncDirectoryExistsAsync(startupWorkspaceCwd)
|
||||
: fallback.directoryExists(startupWorkspaceCwd)
|
||||
if (wslWorkspaceCwdExists === true) {
|
||||
prevalidatedCwd = startupWorkspaceCwd
|
||||
}
|
||||
}
|
||||
}
|
||||
const allowMissingCwdFallback =
|
||||
!wslRuntimeOwnsStartupCwd || (wslStartupCwdExists === false && wslWorkspaceCwdExists === true)
|
||||
let didFallbackToWorkspaceRootCwd = false
|
||||
const cwd = resolveStartupCwd(
|
||||
worktreeId,
|
||||
args.cwd,
|
||||
allowMissingCwdFallback
|
||||
? {
|
||||
directoryExists: (path) =>
|
||||
startupWslContext &&
|
||||
wslStartupCwdExists === false &&
|
||||
path === initiallyResolvedStartupCwd
|
||||
? false
|
||||
: startupWslContext && path === startupWorkspaceCwd
|
||||
? wslWorkspaceCwdExists === true
|
||||
: fallback.directoryExists(
|
||||
process.platform === 'win32' ? normalizeWindowsTerminalCwd(path) : path
|
||||
),
|
||||
onFallbackToWorkspaceRoot: () => {
|
||||
didFallbackToWorkspaceRootCwd = true
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
if (didFallbackToWorkspaceRootCwd && wslWorkspaceCwdExists === true && cwd) {
|
||||
prevalidatedCwd = cwd
|
||||
}
|
||||
return {
|
||||
cwd,
|
||||
prevalidatedCwd,
|
||||
startupCwdFallback: didFallbackToWorkspaceRootCwd && cwd ? { kind: 'worktree', cwd } : undefined
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { WINDOW_LAUNCH_CASES } from '../../../../shared/launch-parity-window-cases.test-fixture'
|
||||
import { PLUS_LAUNCH_CASES } from '../../../../shared/launch-parity-plus-cases.test-fixture'
|
||||
import { WORKSPACE_START_LAUNCH_CASES } from '../../../../shared/launch-parity-workspace-start-cases.test-fixture'
|
||||
import {
|
||||
loadWorkspaceStartModules,
|
||||
startWorkspaceAgent
|
||||
} from '@/lib/launch-parity-workspace-start.test-fixture'
|
||||
import {
|
||||
LAUNCH_TAB_ID,
|
||||
LAUNCH_TOKEN,
|
||||
@@ -40,6 +45,12 @@ vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof React>()
|
||||
return { ...actual, useCallback: <T>(fn: T): T => fn }
|
||||
})
|
||||
// Row 8: what precedes the launch (setup policy and hooks trust) is decided, not asked.
|
||||
vi.mock('@/lib/launch-work-item-direct-preflight', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
resolveDirectSetupDecision: async () => ({ kind: 'decided', decision: 'inherit' })
|
||||
}))
|
||||
vi.mock('@/lib/ensure-hooks-confirmed', () => ({ ensureHooksConfirmed: async () => 'run' }))
|
||||
vi.mock('@/lib/windows-terminal-capabilities', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
hasCachedWindowsTerminalCapabilities: () => true,
|
||||
@@ -60,7 +71,8 @@ const load = perClientLoader(async () => ({
|
||||
pane: await import('./pty-connection'),
|
||||
paneOptions: await import('./terminal-pane-manager-options'),
|
||||
defaults: await import('@/lib/pane-manager/pane-terminal-options'),
|
||||
lifecycle: await import('./terminal-pane-lifecycle-primitives')
|
||||
lifecycle: await import('./terminal-pane-lifecycle-primitives'),
|
||||
workspaceStart: await loadWorkspaceStartModules()
|
||||
}))
|
||||
|
||||
/** Runs the case's real producer against a real store; returns the tab it opened. */
|
||||
@@ -75,7 +87,9 @@ async function openTab(c: WindowLaunchCase) {
|
||||
)
|
||||
const worktreeId = launchWorkspaceId(c.workspace)
|
||||
const producer = c.producer
|
||||
if (producer.kind === 'quick-command') {
|
||||
if (producer.kind === 'work-item' || producer.kind === 'folder-composer') {
|
||||
await startWorkspaceAgent(modules.workspaceStart, store, { ...c, producer })
|
||||
} else if (producer.kind === 'quick-command') {
|
||||
modules.quick.runQuickCommandInNewTab({
|
||||
command: { id: 'qc-1', label: 'Fix', action: 'agent-prompt', ...producer },
|
||||
worktreeId
|
||||
@@ -205,7 +219,10 @@ describe('window-tab producers: the pty:spawn request a real pane sends', () =>
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it.each([...WINDOW_LAUNCH_CASES, ...PLUS_LAUNCH_CASES])('row $row: $name', async (c) => {
|
||||
expect(await paneSpawnRequest(c)).toEqual(windowSpawnRequest(c))
|
||||
})
|
||||
it.each([...WINDOW_LAUNCH_CASES, ...PLUS_LAUNCH_CASES, ...WORKSPACE_START_LAUNCH_CASES])(
|
||||
'row $row: $name',
|
||||
async (c) => {
|
||||
expect(await paneSpawnRequest(c)).toEqual(windowSpawnRequest(c))
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Test-only: runs the row-8 producers (an agent started with a workspace) against the parity
|
||||
// harness's real store. Only what precedes the launch is stubbed: setup policy, hooks trust, agent
|
||||
// detection and the create itself.
|
||||
import type { LaunchTestStore } from './launch-parity-renderer.test-fixture'
|
||||
import type * as WorkItemLaunch from '@/lib/launch-work-item-direct'
|
||||
import type * as FolderComposerSubmit from '@/components/sidebar/folder-workspace-composer-submit'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../../shared/tui-agent-launch-defaults'
|
||||
import {
|
||||
LAUNCH_FOLDER_ID,
|
||||
type WindowLaunchCase
|
||||
} from '../../../shared/launch-parity-window-request.test-fixture'
|
||||
|
||||
type WorkspaceStartProducer = Extract<
|
||||
WindowLaunchCase['producer'],
|
||||
{ kind: 'work-item' | 'folder-composer' }
|
||||
>
|
||||
|
||||
export type WorkspaceStartModules = {
|
||||
workItem: typeof WorkItemLaunch
|
||||
folder: typeof FolderComposerSubmit
|
||||
}
|
||||
|
||||
export const loadWorkspaceStartModules = async (): Promise<WorkspaceStartModules> => ({
|
||||
workItem: await import('@/lib/launch-work-item-direct'),
|
||||
folder: await import('@/components/sidebar/folder-workspace-composer-submit')
|
||||
})
|
||||
|
||||
export async function startWorkspaceAgent(
|
||||
modules: WorkspaceStartModules,
|
||||
store: LaunchTestStore,
|
||||
c: WindowLaunchCase & { producer: WorkspaceStartProducer }
|
||||
): Promise<void> {
|
||||
const { producer } = c
|
||||
const state = store.getState()
|
||||
// Activating a workspace asks for a PR refresh, which this harness has no bridge for; a prompt
|
||||
// pasted after start waits on timers.
|
||||
Object.assign(window.api, { gh: {} })
|
||||
Object.assign(window, { setTimeout, clearTimeout })
|
||||
store.setState({
|
||||
ensureDetectedAgents: async () => [producer.agent],
|
||||
updateWorktreeMeta: async () => ({ ok: true })
|
||||
})
|
||||
if (producer.kind === 'work-item') {
|
||||
// The workspace the create returns is the one the harness seeded.
|
||||
const worktree = state.worktreesByRepo['repo-1']?.[0]
|
||||
store.setState({ createWorktree: async () => ({ worktree }) })
|
||||
const started = await modules.workItem.launchWorkItemDirect({
|
||||
item: {
|
||||
title: 'Fix the bug',
|
||||
url: 'https://github.com/acme/repo/issues/7',
|
||||
type: 'issue',
|
||||
number: 7,
|
||||
pasteContent: producer.text
|
||||
},
|
||||
repoId: 'repo-1',
|
||||
launchSource: 'task_page',
|
||||
telemetrySource: 'sidebar',
|
||||
agentOverride: producer.agent,
|
||||
promptDelivery: producer.delivery,
|
||||
openModalFallback: () => {
|
||||
throw new Error(`${c.name}: fell back to the modal`)
|
||||
}
|
||||
})
|
||||
if (!started) {
|
||||
throw new Error(`${c.name}: the work item started no agent`)
|
||||
}
|
||||
return
|
||||
}
|
||||
const folderWorkspace = state.folderWorkspaces.find(
|
||||
(workspace) => `folder:${workspace.id}` === LAUNCH_FOLDER_ID
|
||||
)
|
||||
const settings = state.settings
|
||||
await modules.folder.submitFolderWorkspaceCreate({
|
||||
projectGroup: {
|
||||
id: 'pg-1',
|
||||
name: 'group',
|
||||
parentPath: c.workspace.path,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
},
|
||||
name: 'folder',
|
||||
lastAutoName: '',
|
||||
linkedWorkItem: null,
|
||||
note: producer.note,
|
||||
quickAgent: producer.agent,
|
||||
autoRenameBranchFromWork: false,
|
||||
// As the composer's folder submit passes them.
|
||||
agentCmdOverrides: settings?.agentCmdOverrides,
|
||||
agentArgs: resolveTuiAgentLaunchArgs(producer.agent, settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(producer.agent, settings?.agentDefaultEnv),
|
||||
terminalWindowsShell: settings?.terminalWindowsShell,
|
||||
launchSource: 'new_workspace_composer',
|
||||
createFolderWorkspace: async () => folderWorkspace ?? null,
|
||||
onOpenChange: () => {}
|
||||
})
|
||||
}
|
||||
@@ -33,9 +33,24 @@ export type WindowLaunchProducer =
|
||||
| { kind: 'transcript-continue'; transcript: string; cwd: string | null }
|
||||
/** A "+" launch with no prompt, as each "+" entry point calls launchAgentInNewTab. */
|
||||
| { kind: 'plus'; agent: 'claude'; via: PlusEntry }
|
||||
/** A work item's "start agent" (launch-work-item-direct.ts): the workspace and its agent at once. */
|
||||
| {
|
||||
kind: 'work-item'
|
||||
agent: 'claude' | 'codex'
|
||||
delivery: 'draft' | 'submit-after-ready'
|
||||
text: string
|
||||
}
|
||||
/** The folder-workspace composer's create with an agent; the note is its prompt. */
|
||||
| { kind: 'folder-composer'; agent: 'claude' | 'codex'; note: string }
|
||||
|
||||
/** "+" entry points. Quick Launch items omit launchSource and get `menu`'s source by default. */
|
||||
export type PlusEntry = 'menu' | 'shortcut' | 'dashboard'
|
||||
const PRODUCER_LAUNCH_SOURCE = {
|
||||
'quick-command': 'quick_command',
|
||||
continuation: 'terminal_context_menu',
|
||||
'work-item': 'task_page',
|
||||
'folder-composer': 'new_workspace_composer'
|
||||
} as const
|
||||
const PLUS_LAUNCH_SOURCE: Record<PlusEntry, string> = {
|
||||
menu: 'tab_bar_quick_launch',
|
||||
shortcut: 'shortcut',
|
||||
@@ -57,10 +72,14 @@ export type WindowRequestFacts = {
|
||||
agentEnv?: Record<string, string>
|
||||
/** The agent's saved default args; absent = the settings default. */
|
||||
agentArgs?: string
|
||||
/** Whether the command waits for the shell to be ready; absent = only Codex does. */
|
||||
shellReady?: boolean
|
||||
}
|
||||
|
||||
/** What reaches the provider for that request (main half). */
|
||||
export type WindowProviderFacts = {
|
||||
/** Default: the request's command. */
|
||||
command?: string
|
||||
shellOverride: string | undefined
|
||||
terminalWindowsWslDistro?: string | null
|
||||
}
|
||||
@@ -68,7 +87,7 @@ export type WindowProviderFacts = {
|
||||
export type WindowLaunchCase = {
|
||||
name: string
|
||||
/** The launch-convergence producer row. */
|
||||
row: 1 | 2 | 6
|
||||
row: 1 | 2 | 6 | 8
|
||||
client: LaunchClient
|
||||
workspace: LaunchWorkspace
|
||||
settings?: Record<string, unknown>
|
||||
@@ -138,7 +157,7 @@ export function windowSpawnRequest(c: WindowLaunchCase): Record<string, unknown>
|
||||
},
|
||||
launchToken: LAUNCH_TOKEN,
|
||||
launchAgent: agent,
|
||||
...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' } : {}),
|
||||
...((request.shellReady ?? agent === 'codex') ? { startupCommandDelivery: 'shell-ready' } : {}),
|
||||
worktreeId,
|
||||
tabId: LAUNCH_TAB_ID,
|
||||
leafId: LAUNCH_LEAF_ID,
|
||||
@@ -153,9 +172,7 @@ export function windowSpawnRequest(c: WindowLaunchCase): Record<string, unknown>
|
||||
launch_source:
|
||||
producer.kind === 'plus'
|
||||
? PLUS_LAUNCH_SOURCE[producer.via]
|
||||
: producer.kind === 'quick-command'
|
||||
? 'quick_command'
|
||||
: 'terminal_context_menu',
|
||||
: PRODUCER_LAUNCH_SOURCE[producer.kind],
|
||||
request_kind: 'new'
|
||||
}
|
||||
}
|
||||
@@ -182,7 +199,7 @@ export function expectedWindowProvider(
|
||||
// main adds the terminal handle to the pane's env.
|
||||
const env = { ...(typeof request.env === 'object' ? request.env : {}), ORCA_TERMINAL_HANDLE: '' }
|
||||
return {
|
||||
command: request.command,
|
||||
command: provider.command ?? request.command,
|
||||
// main normalizes the cwd to forward slashes (resolveRuntimePath).
|
||||
cwd: String(request.cwd).replaceAll('\\', '/'),
|
||||
// The pane's measured size, and never marked hidden before its pane shows.
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Test-only (never imported by production code): the window-lane launch parity table for row 8
|
||||
// (an agent started together with its workspace: a work item's "start agent", the folder-workspace
|
||||
// composer). Renderer suites assert the real producer + pane send `request`; main suites feed it to
|
||||
// pty:spawn and assert `provider`. C4 moves these launchers onto the pane trigger: the request then
|
||||
// carries the launch instead of a command, and every provider fact here must hold unchanged.
|
||||
import type {
|
||||
LaunchClient,
|
||||
LaunchWorkspace,
|
||||
WindowLaunchCase,
|
||||
WindowLaunchProducer,
|
||||
WindowProviderFacts,
|
||||
WindowRequestFacts
|
||||
} from './launch-parity-window-request.test-fixture'
|
||||
import { POSIX_PATH, WIN_PATH } from './launch-parity-window-cases.test-fixture'
|
||||
|
||||
const SKIP = '--dangerously-skip-permissions'
|
||||
const POSIX_CLAUDE = `claude '${SKIP}'`
|
||||
const CMD_CLAUDE = `claude "${SKIP}"`
|
||||
const POSIX_CODEX = "codex '--dangerously-bypass-approvals-and-sandbox'"
|
||||
const BOB = "fix Bob's bug"
|
||||
// Longer than one typed line can safely hold, and multi-line: main's window still puts it on argv.
|
||||
const LONG_NOTE = `${'Read the failing test output and fix the cause. '.repeat(12)}\nThen run it again.`
|
||||
|
||||
const ZSH = { shellOverride: '/bin/zsh' }
|
||||
const IN_CMD = {
|
||||
shellOverride: 'cmd.exe',
|
||||
projectRuntime: { kind: 'windows-host', reason: 'global-default' }
|
||||
} as const
|
||||
const CMD_EXE = { shellOverride: 'cmd.exe', terminalWindowsWslDistro: null }
|
||||
const IN_WSL = {
|
||||
shellOverride: 'wsl.exe',
|
||||
projectRuntime: { kind: 'wsl', reason: 'project-override', distro: 'Ubuntu' }
|
||||
} as const
|
||||
const WSL_EXE = { shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Ubuntu' }
|
||||
const CMD = { terminalWindowsShell: 'cmd.exe' }
|
||||
|
||||
const repo = (path: string, extra: Partial<LaunchWorkspace> = {}): LaunchWorkspace => ({
|
||||
kind: 'repo',
|
||||
path,
|
||||
...extra
|
||||
})
|
||||
const folder = (path: string): LaunchWorkspace => ({ kind: 'folder', path })
|
||||
const draft = { kind: 'work-item', agent: 'claude', delivery: 'draft', text: BOB } as const
|
||||
const note = (text: string): WindowLaunchProducer => ({
|
||||
kind: 'folder-composer',
|
||||
agent: 'claude',
|
||||
note: text
|
||||
})
|
||||
|
||||
function start(
|
||||
name: string,
|
||||
client: LaunchClient,
|
||||
workspace: LaunchWorkspace,
|
||||
producer: WindowLaunchProducer,
|
||||
request: WindowRequestFacts,
|
||||
provider: WindowProviderFacts,
|
||||
settings?: Record<string, unknown>
|
||||
): WindowLaunchCase {
|
||||
return { name, row: 8, client, workspace, producer, request, provider, settings }
|
||||
}
|
||||
|
||||
export const WORKSPACE_START_LAUNCH_CASES: WindowLaunchCase[] = [
|
||||
// A work item's context is a draft: Claude takes it on argv.
|
||||
start(
|
||||
'work item, macOS repo, draft rides argv',
|
||||
'darwin',
|
||||
repo(POSIX_PATH),
|
||||
draft,
|
||||
{ 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).
|
||||
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,
|
||||
...IN_CMD
|
||||
},
|
||||
CMD_EXE,
|
||||
CMD
|
||||
),
|
||||
start(
|
||||
'work item, Windows C: repo, project WSL',
|
||||
'win32',
|
||||
repo(WIN_PATH, { projectRuntime: { kind: 'wsl', distro: 'Ubuntu' } }),
|
||||
draft,
|
||||
{
|
||||
command: `${POSIX_CLAUDE} --prefill 'fix Bob'"'"'s bug'`,
|
||||
agentCommand: POSIX_CLAUDE,
|
||||
...IN_WSL
|
||||
},
|
||||
WSL_EXE,
|
||||
CMD
|
||||
),
|
||||
// A C: project never set to a runtime, with WSL as the terminal shell: the work item follows the
|
||||
// Windows runtime default.
|
||||
start(
|
||||
'work item, Windows C: repo, terminal shell WSL',
|
||||
'win32',
|
||||
repo(WIN_PATH),
|
||||
draft,
|
||||
{
|
||||
command: `${POSIX_CLAUDE} --prefill 'fix Bob''s bug'`,
|
||||
agentCommand: POSIX_CLAUDE,
|
||||
shellOverride: 'powershell.exe',
|
||||
projectRuntime: { kind: 'windows-host', reason: 'global-default' }
|
||||
},
|
||||
{ shellOverride: 'powershell.exe', terminalWindowsWslDistro: null },
|
||||
{ terminalWindowsShell: 'wsl.exe' }
|
||||
),
|
||||
// Fix checks submit after the agent is ready: the command carries no prompt, the window pastes it,
|
||||
// and main's prompt-less Codex start is typed without waiting for the shell to be ready.
|
||||
start(
|
||||
'work item, submit after ready',
|
||||
'darwin',
|
||||
repo(POSIX_PATH),
|
||||
{ kind: 'work-item', agent: 'codex', delivery: 'submit-after-ready', text: BOB },
|
||||
{ command: POSIX_CODEX, agentCommand: POSIX_CODEX, shellReady: false },
|
||||
ZSH
|
||||
),
|
||||
// The composer's note is an auto-submitted prompt on argv.
|
||||
start(
|
||||
'folder composer, macOS folder, note rides argv',
|
||||
'darwin',
|
||||
folder(POSIX_PATH),
|
||||
note(BOB),
|
||||
{ command: `${POSIX_CLAUDE} 'fix Bob'"'"'s bug'`, agentCommand: POSIX_CLAUDE },
|
||||
ZSH
|
||||
),
|
||||
// Unlike the work item, the composer quotes for the Windows shell it runs in.
|
||||
start(
|
||||
'folder composer, Windows C: folder, cmd.exe set',
|
||||
'win32',
|
||||
folder(WIN_PATH),
|
||||
note(BOB),
|
||||
// A folder has no project, so no project runtime is sent.
|
||||
{
|
||||
command: `${CMD_CLAUDE} "fix Bob's bug"`,
|
||||
agentCommand: CMD_CLAUDE,
|
||||
shellOverride: 'cmd.exe'
|
||||
},
|
||||
CMD_EXE,
|
||||
CMD
|
||||
),
|
||||
// A folder keeps WSL when it is the terminal shell (the host's wslShellRule).
|
||||
start(
|
||||
'folder composer, Windows C: folder, terminal shell WSL',
|
||||
'win32',
|
||||
folder(WIN_PATH),
|
||||
note(BOB),
|
||||
{
|
||||
command: `${POSIX_CLAUDE} 'fix Bob'"'"'s bug'`,
|
||||
agentCommand: POSIX_CLAUDE,
|
||||
shellOverride: 'wsl.exe'
|
||||
},
|
||||
// main today: the terminal's default distro, none named.
|
||||
{ shellOverride: 'wsl.exe', terminalWindowsWslDistro: null },
|
||||
{ terminalWindowsShell: 'wsl.exe' }
|
||||
),
|
||||
// main's window carries a prompt on argv at any length, where the host would paste a long one.
|
||||
start(
|
||||
'folder composer, long multi-line note rides argv',
|
||||
'darwin',
|
||||
folder(POSIX_PATH),
|
||||
note(LONG_NOTE),
|
||||
{
|
||||
command: `${POSIX_CLAUDE} '${LONG_NOTE}'`,
|
||||
agentCommand: POSIX_CLAUDE
|
||||
},
|
||||
ZSH
|
||||
)
|
||||
]
|
||||
Reference in New Issue
Block a user