diff --git a/src/main/pwsh.ts b/src/main/pwsh.ts index 14f77f4d826..04001c85b12 100644 --- a/src/main/pwsh.ts +++ b/src/main/pwsh.ts @@ -120,6 +120,15 @@ export function isPwshAvailableAsync(): Promise { return pwshProbeInFlight } +/** The probe's answer when it has one, without blocking; null starts a probe for a later caller. */ +export function cachedPwshAvailability(): boolean | null { + if (pwshAvailableCache && isCacheFresh(pwshAvailableCache)) { + return pwshAvailableCache.available + } + void isPwshAvailableAsync() + return null +} + export function warmPwshAvailabilityCache(): Promise { if (pwshAvailableCache?.available) { return Promise.resolve(true) diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 34f2a96e4ce..403d649516c 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -186,7 +186,8 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe prompt: request.prompt ?? '', host: thisOrcaLaunchHost({ launchPlatform: startupArgs.platform, - isRemote: Boolean(workspace.connectionId) + isRemote: Boolean(workspace.connectionId), + settings }), // Why: this create returns before the agent is ready, so nothing pastes after it. paste: 'never' diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index 50b05b07e03..358f9991b48 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -61,7 +61,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt isRemote }), prompt: opts.agentPrompt ?? '', - host: thisOrcaLaunchHost({ launchPlatform, isRemote }), + host: thisOrcaLaunchHost({ launchPlatform, isRemote, settings }), // Why: a quick command has no paste after ready. paste: 'never' }) diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 2617268d4c1..2ddc02021d0 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -271,7 +271,12 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith ...planInputs, prompt: opts.startupPrompt ?? '', ...(opts.launchFile ? { launchFile: opts.launchFile } : {}), - host: thisOrcaLaunchHost({ launchPlatform: platform, isRemote }), + host: thisOrcaLaunchHost({ + launchPlatform: platform, + isRemote, + settings, + windowsShellOverride: opts.shellOverride + }), paste: opts.onStartupPromptCarry ? 'when-host-proves-agent' : 'never' }) if (!planned) { diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 33e65cd8489..d09734a518f 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -160,7 +160,8 @@ export function buildWorktreeStartupForAgent( prompt: environment.prompt ?? '', host: thisOrcaLaunchHost({ isRemote: repoIsRemote(repo), - launchPlatform: environment.getLaunchPlatform() + launchPlatform: environment.getLaunchPlatform(), + settings }), // The caller, or else the host's follow-up, pastes what the line leaves once the agent runs. paste: 'when-host-proves-agent' diff --git a/src/main/runtime/this-orca-launch-host.test.ts b/src/main/runtime/this-orca-launch-host.test.ts new file mode 100644 index 00000000000..dce8b2be36e --- /dev/null +++ b/src/main/runtime/this-orca-launch-host.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cachedPwshAvailability } from '../pwsh' +import { thisOrcaLaunchHost } from './this-orca-launch-host' + +vi.mock('../pwsh', () => ({ cachedPwshAvailability: vi.fn(() => null) })) + +const realPlatform = process.platform + +function onPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +// Why: this Orca spawns its own Windows panes, so it knows the PowerShell that gets the line. +describe('the PowerShell a launch this Orca runs lands in', () => { + afterEach(() => { + onPlatform(realPlatform) + vi.mocked(cachedPwshAvailability).mockReturnValue(null) + }) + + it('follows the requested shell, then the settings and the pwsh probe, on this Windows machine', () => { + onPlatform('win32') + const settings = { + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'auto' as const + } + const host = (windowsShellOverride?: string) => + thisOrcaLaunchHost({ + launchPlatform: 'win32', + isRemote: false, + settings, + windowsShellOverride + }) + expect(host().windowsPowerShell).toBeNull() + vi.mocked(cachedPwshAvailability).mockReturnValue(false) + expect(host().windowsPowerShell).toBe('powershell.exe') + expect(host('pwsh.exe').windowsPowerShell).toBe('pwsh.exe') + expect( + thisOrcaLaunchHost({ launchPlatform: 'win32', isRemote: true, settings }).windowsPowerShell + ).toBeNull() + }) + + it('knows none for a launch on another platform', () => { + onPlatform('darwin') + vi.mocked(cachedPwshAvailability).mockReturnValue(true) + expect( + thisOrcaLaunchHost({ + launchPlatform: 'win32', + isRemote: false, + settings: { terminalWindowsShell: 'pwsh.exe' } + }).windowsPowerShell + ).toBeNull() + }) +}) diff --git a/src/main/runtime/this-orca-launch-host.ts b/src/main/runtime/this-orca-launch-host.ts index 3a2d28f3f99..187b93e242e 100644 --- a/src/main/runtime/this-orca-launch-host.ts +++ b/src/main/runtime/this-orca-launch-host.ts @@ -1,9 +1,31 @@ -import { describeLaunchHost, type LaunchHost } from '../../shared/launch-host' +import { cachedPwshAvailability } from '../pwsh' +import { + describeLaunchHost, + spawnedWindowsPowerShell, + type LaunchHost, + type WindowsShellSettings +} from '../../shared/launch-host' /** The host facts for a launch this Orca runs itself, locally or over its own SSH connection. */ export function thisOrcaLaunchHost(args: { launchPlatform: NodeJS.Platform isRemote: boolean + settings: WindowsShellSettings + /** The shell this launch asked for (`--shell`), which outranks the setting. */ + windowsShellOverride?: string | null }): LaunchHost { - return describeLaunchHost({ ...args, hostPlatform: process.platform, paired: false }) + const local = !args.isRemote && args.launchPlatform === 'win32' && process.platform === 'win32' + return describeLaunchHost({ + launchPlatform: args.launchPlatform, + isRemote: args.isRemote, + hostPlatform: process.platform, + paired: false, + windowsPowerShell: local + ? spawnedWindowsPowerShell({ + settings: args.settings, + windowsShellOverride: args.windowsShellOverride, + pwshAvailable: cachedPwshAvailability() + }) + : null + }) } diff --git a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts index b377610f81d..6d5db20674d 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts @@ -16,7 +16,12 @@ describe('the full composer’s renderer-spawned startup', () => { cmdOverrides: {}, platform: 'linux', isRemote: true, - host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPowerShell: null + }, paste: 'when-host-proves-agent' }), prompt diff --git a/src/renderer/src/lib/launch-file-host.test.ts b/src/renderer/src/lib/launch-file-host.test.ts index 53256c7ca09..167a04742b1 100644 --- a/src/renderer/src/lib/launch-file-host.test.ts +++ b/src/renderer/src/lib/launch-file-host.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { launchHostIsPaired } from './launch-file-host' +import { useAppStore } from '@/store' +import { clientLaunchHost, launchHostIsPaired } from './launch-file-host' +import { localPwshAvailability } from './local-pwsh-availability' + +vi.mock('./local-pwsh-availability', () => ({ localPwshAvailability: vi.fn(() => null) })) import { buildQuickComposerStartup } from '@/hooks/composer-state/quick-startup-plan' describe('whether a launch lands on a paired host', () => { @@ -26,7 +30,12 @@ describe('the quick composer on a paired host', () => { platform: 'linux', shell: null, isRemote: false, - host: { paired: true, provesAgentInFront: true, takesLaunchFile: false }, + host: { + paired: true, + provesAgentInFront: true, + takesLaunchFile: false, + windowsPowerShell: null + }, telemetrySource: 'sidebar' }) expect(startup.backendStartup).toBeUndefined() @@ -34,3 +43,31 @@ describe('the quick composer on a paired host', () => { expect(startup.startupPlan?.pastePromptAfterReady).toBe('fix the build\nthen run the tests') }) }) + +// Why: this client spawns its own Windows panes, so it knows the PowerShell that gets the line. +describe('the PowerShell a local Windows launch from this client lands in', () => { + afterEach(() => { + vi.mocked(localPwshAvailability).mockReturnValue(null) + }) + + const host = (isRemote = false) => + clientLaunchHost({ runtimeEnvironmentId: null, launchPlatform: 'win32', isRemote }) + + it('follows this client’s shell settings and the pwsh probe', () => { + const settings = useAppStore.getState().settings + useAppStore.setState({ + settings: { + ...settings!, + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'auto' + } + }) + expect(host().windowsPowerShell).toBeNull() + vi.mocked(localPwshAvailability).mockReturnValue(false) + expect(host().windowsPowerShell).toBe('powershell.exe') + vi.mocked(localPwshAvailability).mockReturnValue(true) + expect(host().windowsPowerShell).toBe('pwsh.exe') + expect(host(true).windowsPowerShell).toBeNull() + useAppStore.setState({ settings }) + }) +}) diff --git a/src/renderer/src/lib/launch-file-host.ts b/src/renderer/src/lib/launch-file-host.ts index c1b7e5ed77f..59830ce5ac9 100644 --- a/src/renderer/src/lib/launch-file-host.ts +++ b/src/renderer/src/lib/launch-file-host.ts @@ -1,7 +1,13 @@ import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment' import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { describeLaunchHost, type LaunchHost } from '../../../shared/launch-host' +import { useAppStore } from '@/store' +import { + describeLaunchHost, + spawnedWindowsPowerShell, + type LaunchHost +} from '../../../shared/launch-host' import { isWebClientLocation } from './web-client-location' +import { localPwshAvailability } from './local-pwsh-availability' /** * Whether a launch lands on a paired Orca: another Orca this client drives, which may be an older @@ -18,10 +24,19 @@ export function clientLaunchHost(args: { launchPlatform: NodeJS.Platform isRemote: boolean }): LaunchHost { + const paired = launchHostIsPaired(args.runtimeEnvironmentId) + const local = !paired && !args.isRemote && args.launchPlatform === 'win32' return describeLaunchHost({ launchPlatform: args.launchPlatform, isRemote: args.isRemote, hostPlatform: CLIENT_PLATFORM, - paired: launchHostIsPaired(args.runtimeEnvironmentId) + paired, + // Why this client's settings: a local pane is spawned from them, as its shell is. + windowsPowerShell: local + ? spawnedWindowsPowerShell({ + settings: useAppStore.getState().settings, + pwshAvailable: localPwshAvailability() + }) + : null }) } diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts index a834b38c49d..ef9e68760ed 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts @@ -84,7 +84,12 @@ const settings = { describe('buildDirectWorkItemAgentStartupPlan', () => { it('omits native-chat preferences when the new workspace opens in terminal mode', () => { const result = buildDirectWorkItemAgentStartupPlan({ - host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPowerShell: null + }, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -99,7 +104,12 @@ describe('buildDirectWorkItemAgentStartupPlan', () => { it('applies native-chat preferences when the new workspace opens in chat', () => { const result = buildDirectWorkItemAgentStartupPlan({ - host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPowerShell: null + }, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -148,7 +158,12 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('resolves the global Agents arguments when the launch names none', () => { const result = buildDirectWorkItemAgentStartupPlan({ - host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPowerShell: null + }, agent: 'codex', draftContent: 'Fix the broken checks', promptDelivery: 'draft', @@ -162,7 +177,12 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('lets an explicit per-action value win over the global one', () => { const result = buildDirectWorkItemAgentStartupPlan({ - host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPowerShell: null + }, agent: 'codex', agentArgs: '--model gpt-5', draftContent: 'Fix the broken checks', @@ -180,7 +200,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => describe('buildDirectWorkItemAgentStartupPlan submitted prompts', () => { const submit = (agent: 'claude' | 'gemini', draftContent: string, paired = false) => buildDirectWorkItemAgentStartupPlan({ - host: { paired, provesAgentInFront: true, takesLaunchFile: !paired }, + host: { paired, provesAgentInFront: true, takesLaunchFile: !paired, windowsPowerShell: null }, agent, draftContent, promptDelivery: 'submit-after-ready', diff --git a/src/renderer/src/lib/local-pwsh-availability.ts b/src/renderer/src/lib/local-pwsh-availability.ts new file mode 100644 index 00000000000..3761f38b080 --- /dev/null +++ b/src/renderer/src/lib/local-pwsh-availability.ts @@ -0,0 +1,22 @@ +let known: boolean | null = null +let reading = false + +/** + * Whether pwsh.exe is installed on this machine, from main's shared probe; null until it answers. + * The first call starts the read, so a launch before then is planned without it. + */ +export function localPwshAvailability(): boolean | null { + if (known === null && !reading && typeof window !== 'undefined' && window.api?.pwsh) { + reading = true + void window.api.pwsh + .isAvailable() + .then((available) => { + known = available + }) + .catch(() => {}) + .finally(() => { + reading = false + }) + } + return known +} diff --git a/src/shared/launch-host.test.ts b/src/shared/launch-host.test.ts index d7679cef75b..2c34b50232b 100644 --- a/src/shared/launch-host.test.ts +++ b/src/shared/launch-host.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { describeLaunchHost } from './launch-host' +import { describeLaunchHost, spawnedWindowsPowerShell } from './launch-host' describe('what a launch host can do with its prompt', () => { it.each([ @@ -19,8 +19,76 @@ describe('what a launch host can do with its prompt', () => { expect(describeLaunchHost({ isRemote, paired, launchPlatform, hostPlatform })).toEqual({ paired, provesAgentInFront: proves, - takesLaunchFile + takesLaunchFile, + windowsPowerShell: null }) } ) }) + +// Why: the PowerShell decides how `"` and a trailing `\` reach the agent, and this Orca picks it. +describe('the PowerShell a local Windows pane is spawned as', () => { + it.each([ + ['the pwsh shell', { terminalWindowsShell: 'pwsh.exe' }, undefined, null, 'pwsh.exe'], + [ + 'a requested pwsh over the setting', + { terminalWindowsShell: 'cmd.exe' }, + 'pwsh', + null, + 'pwsh.exe' + ], + [ + 'Windows PowerShell chosen explicitly', + { + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'powershell.exe' + }, + undefined, + true, + 'powershell.exe' + ], + [ + 'auto with pwsh installed', + { terminalWindowsShell: '', terminalWindowsPowerShellImplementation: 'auto' }, + undefined, + true, + 'pwsh.exe' + ], + [ + 'auto without pwsh', + { terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'auto' }, + undefined, + false, + 'powershell.exe' + ], + [ + 'auto before the probe answers', + { terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'auto' }, + undefined, + null, + null + ], + ['cmd', { terminalWindowsShell: 'cmd.exe' }, undefined, true, null], + ['Git Bash', { terminalWindowsShell: 'git-bash' }, undefined, true, null] + ] as const)('%s', (_label, settings, windowsShellOverride, pwshAvailable, expected) => { + expect(spawnedWindowsPowerShell({ settings, windowsShellOverride, pwshAvailable })).toBe( + expected + ) + }) + + it('is not this machine’s to know for an SSH or paired host', () => { + for (const where of [ + { isRemote: true, paired: false }, + { isRemote: false, paired: true } + ]) { + expect( + describeLaunchHost({ + ...where, + launchPlatform: 'win32', + hostPlatform: 'win32', + windowsPowerShell: 'powershell.exe' + }).windowsPowerShell + ).toBeNull() + } + }) +}) diff --git a/src/shared/launch-host.ts b/src/shared/launch-host.ts index 20beb713b6e..7440e5e273e 100644 --- a/src/shared/launch-host.ts +++ b/src/shared/launch-host.ts @@ -1,3 +1,14 @@ +import type { GlobalSettings } from './global-settings-types' + +export type WindowsPowerShell = 'powershell.exe' | 'pwsh.exe' + +export type WindowsShellSettings = + | Partial< + Pick + > + | null + | undefined + /** What the host a launch runs on can do with its prompt, derived in one place from where it runs. */ export type LaunchHost = { /** Another Orca this client drives, possibly an older one: it is sent a command it may neither @@ -10,6 +21,36 @@ export type LaunchHost = { * SSH Windows host's relay may run its panes in WSL (its OpenSSH default shell, which this client * cannot see), where it writes none. Such a host gets the line or the paste instead. */ takesLaunchFile: boolean + /** The PowerShell a local Windows pane is spawned as, which decides how it hands `"` and a + * trailing `\` to the agent. Null where this Orca does not choose it (an SSH or paired host) or + * has not yet learned whether pwsh is installed. */ + windowsPowerShell: WindowsPowerShell | null +} + +/** + * The PowerShell this host spawns for a pane, resolved as the spawn resolves it: the requested + * shell, then the implementation setting, then whether pwsh.exe is installed. Null for a shell that + * is not PowerShell, or when that probe has not answered. + */ +export function spawnedWindowsPowerShell(args: { + settings: WindowsShellSettings + /** The shell this launch asked for, which outranks the setting. */ + windowsShellOverride?: string | null + pwshAvailable: boolean | null +}): WindowsPowerShell | null { + const shell = (args.windowsShellOverride ?? args.settings?.terminalWindowsShell ?? '').trim() + const name = shell.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? '' + if (name === 'pwsh.exe' || name === 'pwsh') { + return 'pwsh.exe' + } + if (name !== '' && name !== 'powershell.exe' && name !== 'powershell') { + return null + } + const implementation = args.settings?.terminalWindowsPowerShellImplementation + if (implementation === 'powershell.exe' || implementation === 'pwsh.exe') { + return implementation + } + return args.pwshAvailable === null ? null : args.pwshAvailable ? 'pwsh.exe' : 'powershell.exe' } /** @@ -23,11 +64,15 @@ export function describeLaunchHost(args: { /** The platform of the machine this Orca runs on. */ hostPlatform: NodeJS.Platform paired: boolean + /** `spawnedWindowsPowerShell` on this machine, which a launch elsewhere does not use. */ + windowsPowerShell?: WindowsPowerShell | null }): LaunchHost { const runsElsewhere = args.isRemote || args.paired return { paired: args.paired, provesAgentInFront: (runsElsewhere ? args.launchPlatform : args.hostPlatform) !== 'win32', - takesLaunchFile: !args.paired && !(args.isRemote && args.launchPlatform === 'win32') + takesLaunchFile: !args.paired && !(args.isRemote && args.launchPlatform === 'win32'), + windowsPowerShell: + runsElsewhere || args.launchPlatform !== 'win32' ? null : (args.windowsPowerShell ?? null) } } diff --git a/src/shared/launch-prompt-carrier.test.ts b/src/shared/launch-prompt-carrier.test.ts index ff714410b18..02bbcc43d90 100644 --- a/src/shared/launch-prompt-carrier.test.ts +++ b/src/shared/launch-prompt-carrier.test.ts @@ -25,14 +25,24 @@ describe('the one launch-prompt decision every launch path builds through', () = ...base, prompt: 'fix it', platform: 'linux', - host: { paired: true, provesAgentInFront: true, takesLaunchFile: false } + host: { + paired: true, + provesAgentInFront: true, + takesLaunchFile: false, + windowsPowerShell: null + } }) expect(short?.carry).toBe('on-line') const long = planLaunchForTest({ ...base, prompt: 'fix it\nthen run the tests', platform: 'linux', - host: { paired: true, provesAgentInFront: true, takesLaunchFile: false } + host: { + paired: true, + provesAgentInFront: true, + takesLaunchFile: false, + windowsPowerShell: null + } }) expect(long?.carry).toBe('paste-after-ready') expect(long?.pasteAfterReady).toBe('fix it\nthen run the tests') diff --git a/src/shared/launch-prompt-carry.test.ts b/src/shared/launch-prompt-carry.test.ts index 1a0ba01551a..48dd1b18e65 100644 --- a/src/shared/launch-prompt-carry.test.ts +++ b/src/shared/launch-prompt-carry.test.ts @@ -181,7 +181,12 @@ describe('a host that types the line raw', () => { // A local WSL pane: a Linux line, on a Windows host that cannot read what holds it. const extra = { platform: 'linux' as const, - host: { paired: false, provesAgentInFront: false, takesLaunchFile: true } + host: { + paired: false, + provesAgentInFront: false, + takesLaunchFile: true, + windowsPowerShell: null + } } const prompt = 'fix the build\nthen run the tests' expect(plan('gemini', prompt, extra)?.carry).toBe('on-line') @@ -216,7 +221,12 @@ describe('a host that types the line raw', () => { it('pastes on a paired host what its line cannot carry typed, even for Claude', () => { const extra = { platform: 'linux' as const, - host: { paired: true, provesAgentInFront: true, takesLaunchFile: false } + host: { + paired: true, + provesAgentInFront: true, + takesLaunchFile: false, + windowsPowerShell: null + } } expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line') expect(plan('claude', 'first line\nsecond line', extra)?.carry).toBe('paste-after-ready') diff --git a/src/shared/launch-prompt-carry.ts b/src/shared/launch-prompt-carry.ts index 1322acdc873..f5816cda0dc 100644 --- a/src/shared/launch-prompt-carry.ts +++ b/src/shared/launch-prompt-carry.ts @@ -170,7 +170,9 @@ export function carryLaunchPrompt { ['powershell', 'ml5', 'exact on 5.1 and 7', 'on-line'], ['powershell', 'ml9k', 'damaged on 5.1, lines run as commands on 7', 'launch-file'], ['powershell', 'e8191', 'exact on 5.1 and 7', 'on-line'], - ['powershell', 'pq', 'exact on 7, damaged on 5.1 (version unknown here)', 'on-line'], + ['powershell', 'pq', 'exact on 7, damaged on 5.1 (PowerShell unknown here)', 'on-line'], ['powershell', 'ppct', 'exact on 5.1 and 7', 'on-line'], - ['powershell', 'pbs', 'exact on 7, damaged on 5.1 (version unknown here)', 'on-line'], + ['powershell', 'pbs', 'exact on 7, damaged on 5.1 (PowerShell unknown here)', 'on-line'], ['posix', 'p2k', 'exact in Git Bash', 'on-line'], ['posix', 'p20k', 'never started in Git Bash', 'launch-file'], ['posix', 'ml5', 'exact in Git Bash', 'on-line'], @@ -88,6 +94,25 @@ describe('an agent.launch prompt on a Windows host, per measured shell', () => { expect(carry('codex', row, shell)).toBe(expected) }) + // Why: this Orca spawns the pane, so it knows which PowerShell gets the line. 5.1's measured + // damage gets a launch file, better than main; 7 keeps the line it carried exactly. + it.each<[Row, WindowsPowerShell, string, 'on-line' | 'launch-file']>([ + ['pq', 'powershell.exe', 'damaged on 5.1', 'launch-file'], + ['pbs', 'powershell.exe', 'damaged on 5.1', 'launch-file'], + ['ppct', 'powershell.exe', 'exact on 5.1', 'on-line'], + ['ml5', 'powershell.exe', 'exact on 5.1', 'on-line'], + ['p20k', 'powershell.exe', 'exact on 5.1', 'on-line'], + ['pq', 'pwsh.exe', 'exact on 7', 'on-line'], + ['pbs', 'pwsh.exe', 'exact on 7', 'on-line'], + ['ppct', 'pwsh.exe', 'exact on 7', 'on-line'], + ['ml9k', 'pwsh.exe', 'lines run as commands on 7', 'launch-file'] + ])('powershell %s spawned as %s (main: %s) rides %s', (row, ps, _main, expected) => { + const command = (agent: TuiAgent) => + `node C:/Users/neil/orca-qa/stack-final/win/bin/stub.js --qa-as=${agent}` + expect(carry('claude', row, 'powershell', command('claude'), ps)).toBe(expected) + expect(carry('codex', row, 'powershell', command('codex'), ps)).toBe(expected) + }) + // Why: a shim's cmd.exe re-reads the line, so cmd's cap, its `%NAME%` expansion and a cut at a // line break apply, and PowerShell hands a shim `"` and a trailing `\` the legacy way. it.each<[AgentStartupShell, Row, 'on-line' | 'launch-file']>([ diff --git a/src/shared/windows-launch-line.ts b/src/shared/windows-launch-line.ts index 787df4643a6..528a57c18ab 100644 --- a/src/shared/windows-launch-line.ts +++ b/src/shared/windows-launch-line.ts @@ -4,6 +4,7 @@ * `exact` arrived byte for byte, `damaged` was measured lost or changed, and `uncertain` was not * measured, so the caller does there what main does on its path. */ +import type { WindowsPowerShell } from './launch-host' import type { AgentStartupShell } from './tui-agent-startup-shell' import { TYPED_STARTUP_LINE_BUDGET_BYTES } from './typed-startup-line' @@ -72,7 +73,9 @@ function shellVerdict(line: string, shell: AgentStartupShell): WindowsLineVerdic export function windowsLaunchLineVerdict( prompt: string, line: string, - shell: AgentStartupShell + shell: AgentStartupShell, + /** The PowerShell the pane is spawned as (`LaunchHost.windowsPowerShell`), when known. */ + windowsPowerShell: WindowsPowerShell | null ): WindowsLineVerdict { if (hasKeyByte(line)) { return 'damaged' @@ -80,9 +83,16 @@ export function windowsLaunchLineVerdict( const verdict = shellVerdict(line, shell) const legacyArgsDamage = prompt.includes('"') || prompt.endsWith('\\') if (shell === 'cmd' || !launchesThroughCmdShim(line)) { - // Why: 5.1 hands every native command `"` and a trailing `\` the legacy way, 7 only a shim; - // this client cannot tell which PowerShell runs. - return shell === 'powershell' && verdict === 'exact' && legacyArgsDamage ? 'uncertain' : verdict + if (shell !== 'powershell' || verdict !== 'exact' || !legacyArgsDamage) { + return verdict + } + // Measured: 5.1 hands every native command `"` and a trailing `\` the legacy way and breaks + // them; 7 carried them exactly. Where the PowerShell is not known, main's delivery decides. + return windowsPowerShell === 'powershell.exe' + ? 'damaged' + : windowsPowerShell === 'pwsh.exe' + ? 'exact' + : 'uncertain' } // Measured (#23962 W-1): PowerShell hands a shim `"` and a trailing `\` the legacy way, and the // shim's cmd.exe expands `%NAME%`. diff --git a/src/shared/windows-shell-prompt-damage.test.ts b/src/shared/windows-shell-prompt-damage.test.ts index 5e96e657433..0ad862939a8 100644 --- a/src/shared/windows-shell-prompt-damage.test.ts +++ b/src/shared/windows-shell-prompt-damage.test.ts @@ -95,7 +95,12 @@ describe('a prompt a Windows shell would damage on the launch line', () => { cmdOverrides: {}, platform: 'win32', shell: 'powershell', - host: { paired: true, provesAgentInFront: false, takesLaunchFile: false } + host: { + paired: true, + provesAgentInFront: false, + takesLaunchFile: false, + windowsPowerShell: null + } }) expect(startup?.launchFile).toBeUndefined() expect(startup?.launchCommand).toContain('fix the build')