diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index f30d587cf8c..e369af607b2 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -135,10 +135,15 @@ describe('buildWorktreeStartupForAgent prompt carry', () => { expect(result.startup.command).not.toContain('xxxx') }) - it('points a multi-line prompt at a launch file on a Windows host, which cannot stage', () => { - const result = build('summarize the diff\nthen list the risks', 'win32') - expect(result.startup.launchFile?.content).toBe('summarize the diff\nthen list the risks') - expect(result.startup.command).not.toContain('summarize') + // Why: measured on PowerShell, a short multi-line line arrives and a 9 KB one does not. + it('keeps a short multi-line prompt on a Windows line and points a long one at a file', () => { + expect(build('summarize the diff\nthen list the risks', 'win32').startup.command).toContain( + 'summarize the diff\nthen list the risks' + ) + const long = Array.from({ length: 20 }, (_, i) => `step ${i} `.padEnd(500, 'x')).join('\n') + const result = build(long, 'win32') + expect(result.startup.launchFile?.content).toBe(long) + expect(result.startup.command).not.toContain('step 1 ') }) it('leaves a prompt needing a file the agent is not known to read as the follow-up paste', () => { diff --git a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts index 5a65d159b98..679c68a776b 100644 --- a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts @@ -229,7 +229,7 @@ describe('agent launch caller prompt transport', () => { ['past the argv ceiling', 'gemini', 'x'.repeat(MAX_LINE_PROMPT_BYTES + 1), 'darwin'], ['a Windows-damaged prompt', 'gemini', 'say "hi"', 'win32'], ['past the argv ceiling', 'claude', 'x'.repeat(MAX_LINE_PROMPT_BYTES + 1), 'darwin'], - ['a multi-line Windows prompt', 'codex', 'Fix the checks.\nThen push.', 'win32'] + ['a 9 KB multi-line Windows prompt', 'codex', `Fix the checks.\n${'x'.repeat(9_100)}`, 'win32'] ] as const)( 'pastes %s for %s, and waits for that paste', async (_label, agent, prompt, launchPlatform) => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts index 568229d01e4..bb4b6f7b432 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts @@ -181,9 +181,10 @@ describe('launchAgentInNewTab Windows shell quoting', () => { expect(mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft).not.toHaveBeenCalled() }) - it('pastes a multi-line AI-button prompt on Windows, as main did, instead of a launch file', async () => { + // Why: a 9 KB multi-line PowerShell line was measured losing its line breaks; main pasted it. + it('pastes a long multi-line AI-button prompt on Windows, as main did, instead of a launch file', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - const prompt = 'Fix the failing checks.\nThen push.' + const prompt = `Fix the failing checks.\n${'Then push. '.repeat(900)}Done.` launchAgentInNewTab({ agent: 'codex', diff --git a/src/shared/launch-prompt-carry.test.ts b/src/shared/launch-prompt-carry.test.ts index 68409c1dff4..1a0ba01551a 100644 --- a/src/shared/launch-prompt-carry.test.ts +++ b/src/shared/launch-prompt-carry.test.ts @@ -6,7 +6,10 @@ import { RUNTIME_CAPABILITIES } from './protocol-version' import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability' import { TYPED_STARTUP_LINE_BUDGET_BYTES, typedStartupLineFits } from './typed-startup-line' import { describeLaunchHost } from './launch-host' -import { WINDOWS_TYPED_LINE_MAX_CHARS } from './launch-prompt-carry' +import { + WINDOWS_CMD_LINE_MAX_CHARS, + WINDOWS_POWERSHELL_LINE_MAX_CHARS +} from './windows-launch-line' function plan( agent: TuiAgent, @@ -105,33 +108,21 @@ describe('where a launch prompt rides', () => { }) describe('a host that types the line raw', () => { - // Why: the user's own words stay in the agent's history wherever a Windows line carries them. - it.each<['cmd' | 'powershell']>([['cmd'], ['powershell']])( - 'keeps an exact %s line up to cmd’s cap and points Claude at a file past it', - (shell) => { - expect(plan('claude', 'fix it', { platform: 'win32', shell })?.carry).toBe('on-line') - expect(plan('claude', 'y'.repeat(4_000), { platform: 'win32', shell })?.carry).toBe('on-line') - const past = plan('claude', 'y'.repeat(WINDOWS_TYPED_LINE_MAX_CHARS), { - platform: 'win32', - shell - }) - expect(past?.carry).toBe('launch-file') - } - ) + // Why: the user's own words stay in the agent's history wherever a Windows line carries them; + // the per-shell measurements are in windows-launch-line.test.ts. + it('keeps an exact cmd line up to cmd’s cap and points Claude at a file past it', () => { + const extra = { platform: 'win32' as const, shell: 'cmd' as const } + expect(plan('claude', 'y'.repeat(4_000), extra)?.carry).toBe('on-line') + expect(plan('claude', 'y'.repeat(WINDOWS_CMD_LINE_MAX_CHARS), extra)?.carry).toBe('launch-file') + }) - // The measured matrix (#23962 W-1): PowerShell splits at `"`, turns a trailing `\` into `"`, and - // a .cmd shim expands `%NAME%`; everything else arrives byte for byte. - it.each([ - ['a double quote', 'say "hi"', 'launch-file'], - ['a trailing backslash', 'see C:\\dir\\', 'launch-file'], - ['a %NAME% pair', 'echo %PATH%', 'launch-file'], - ['a lone percent and an apostrophe', "it's 100% done", 'on-line'] - ] as const)( - 'carries a PowerShell prompt with %s by the measured matrix', - (_label, text, carry) => { - expect(plan('claude', text, { platform: 'win32', shell: 'powershell' })?.carry).toBe(carry) - } - ) + it('keeps a PowerShell line to CreateProcess’s cap and points Claude at a file past it', () => { + const extra = { platform: 'win32' as const, shell: 'powershell' as const } + expect(plan('claude', 'y'.repeat(20_000), extra)?.carry).toBe('on-line') + expect(plan('claude', 'y'.repeat(WINDOWS_POWERSHELL_LINE_MAX_CHARS), extra)?.carry).toBe( + 'launch-file' + ) + }) it('points Codex at a file for a Windows-damaged prompt, and keeps it on Gemini’s line', () => { const prompt = 'fix the build\nthen run the tests' @@ -213,7 +204,7 @@ describe('a host that types the line raw', () => { paired: false }) const prompt = 'fix the build\nthen run the tests' - const extra = { platform: 'win32' as const, shell: 'powershell' as const, host } + const extra = { platform: 'win32' as const, shell: 'cmd' as const, host } expect(plan('claude', prompt, extra)?.carry).toBe('on-line') expect(plan('codex', 'y'.repeat(MAX_LINE_PROMPT_BYTES + 1), extra)?.carry).toBe('on-line') expect(plan('claude', prompt, { ...extra, paste: 'once-agent-runs' })?.carry).toBe( diff --git a/src/shared/launch-prompt-carry.ts b/src/shared/launch-prompt-carry.ts index 6fc8325b5b8..1322acdc873 100644 --- a/src/shared/launch-prompt-carry.ts +++ b/src/shared/launch-prompt-carry.ts @@ -15,6 +15,7 @@ import { import { hasControlByte, typedStartupLineFits } from './typed-startup-line' import type { TuiAgent } from './tui-agent' import type { LaunchHost } from './launch-host' +import { windowsLaunchLineVerdict } from './windows-launch-line' /** * Whether a Windows shell would damage `prompt` as one quoted argument. No quoting keeps a control @@ -98,28 +99,13 @@ export function agentReadsLaunchFile(agent: TuiAgent): boolean { const utf8 = new TextEncoder() -/** cmd.exe's documented line cap, the smallest of the Windows shells Orca types into. */ -export const WINDOWS_TYPED_LINE_MAX_CHARS = 8191 - -/** - * Whether a Windows shell carries `prompt` exactly on `line`. Nothing stages a Windows line, so it - * must hold no control byte, fit cmd's cap, and survive the shell's quoting (measured matrix: - * `windowsShellDamagesPrompt`). - */ -function windowsLineCarriesExactly(prompt: string, line: string, shell: AgentStartupShell) { - return ( - !hasControlByte(line) && - line.length <= WINDOWS_TYPED_LINE_MAX_CHARS && - !windowsShellDamagesPrompt(prompt, shell) - ) -} - /** * The one carry rule. The prompt rides the agent's line: a host that stages (POSIX, SSH, WSL) - * stages it when it is long or multi-line, and a Windows host types it when its shell carries the - * text exactly. When the line cannot (past the argv ceiling, damaged by a Windows shell, past a - * paired host's typed budget), a caller whose paste main used gets that paste, so the agent receives - * the user's text. Otherwise it rides a launch file, which goes only to an agent measured reading + * stages it when it is long or multi-line, and a Windows host types it when its shell was measured + * carrying such a line exactly (`windowsLaunchLineVerdict`); an unmeasured one does what main does + * on the caller's path. When the line cannot (past the argv ceiling, measured damaged by a Windows + * shell, past a paired host's typed budget), a caller whose paste main used gets that paste, so the + * agent receives the user's text. Otherwise it rides a launch file, which goes only to an agent measured reading * one, on a host that writes it. Failing a file it is pasted once the agent is ready, unless the * caller's paste cannot reach the agent on this host: then the line carries it, as main typed it. */ @@ -183,12 +169,18 @@ export function carryLaunchPrompt { }) // Why: a Windows host stages nothing, so Git Bash would get the raw line break typed. - it('points Git Bash on a Windows host at a launch file for a multi-line prompt', () => { + // Why: measured, a multi-line line arrives whole in Git Bash, as main typed it. + it('keeps a multi-line prompt on a Git Bash line on a Windows host', () => { const plan = planLaunchForTest({ agent: 'claude', prompt: 'line one\nline two', @@ -146,8 +147,8 @@ describe('tui agent startup plans', () => { shell: 'posix' }) - expect(plan?.launchFile?.content).toBe('line one\nline two') - expect(plan?.launchCommand).not.toContain('line one') + expect(plan?.launchFile).toBeUndefined() + expect(plan?.launchCommand).toContain('line one\nline two') }) it('leaves a multi-line draft for the paste on a Windows shell instead of typing it', () => { diff --git a/src/shared/windows-launch-line.test.ts b/src/shared/windows-launch-line.test.ts new file mode 100644 index 00000000000..9073e7e79b5 --- /dev/null +++ b/src/shared/windows-launch-line.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' +import { describeLaunchHost } from './launch-host' +import { planLaunchPrompt } from './tui-agent-startup' +import type { AgentStartupShell } from './tui-agent-startup-shell' +import type { TuiAgent } from './tui-agent' + +// The stack QA's prompts (Windows lane, 2026-10-02), built the way its rig built them. +function qaLine(tag: string, length: number): string { + let text = `QA-STACK ${tag} start.` + for (let i = 1; text.length < length - 14; i += 1) { + text += ` w${i}` + } + text += ` ${tag} end.` + return text.padEnd(length, '.').slice(0, length) +} + +const PROMPTS = { + p2k: qaLine('P2K', 2048), + p20k: qaLine('P20K', 20480), + p100k: qaLine('P100K', 99000), + ml5: [1, 2, 3, 4, 5] + .map((i) => `QA-STACK ML5 line ${i}: inert text, do not run any command.`) + .join('\n'), + ml9k: Array.from({ length: 18 }, (_, i) => qaLine(`ML9K-L${i + 1}`, 500)).join('\n'), + e8100: qaLine('E8100', 8100), + e8191: qaLine('E8191', 8191), + pq: 'QA-STACK PQ one line with a "double quote" inside, inert, do not run any command.', + ppct: 'QA-STACK PPCT one line with a %QAVAR% pair inside, inert, do not run any command.', + pbs: 'QA-STACK PBS one line ending in a backslash, inert, do not run any command \\' +} as const + +type Row = keyof typeof PROMPTS + +function carry( + agent: TuiAgent, + row: Row, + shell: AgentStartupShell, + command = `node C:/Users/neil/orca-qa/stack-final/win/bin/stub.js --qa-as=${agent}` +) { + return planLaunchPrompt({ + agent, + prompt: PROMPTS[row], + cmdOverrides: { [agent]: command }, + platform: 'win32', + shell, + host: describeLaunchHost({ + launchPlatform: 'win32', + isRemote: false, + hostPlatform: 'win32', + paired: false + }), + paste: 'when-host-proves-agent' + })?.carry +} + +// Each row: what main's typed line did, then the carry it gets. A launch file only where main's +// line was measured to damage or lose the prompt; everywhere else the line, as main typed it. +describe('an agent.launch prompt on a Windows host, per measured shell', () => { + it.each<[AgentStartupShell, Row, string, 'on-line' | 'launch-file']>([ + ['cmd', 'p2k', 'exact', 'on-line'], + ['cmd', 'p20k', 'input line too long', 'launch-file'], + ['cmd', 'ml5', 'damaged', 'launch-file'], + ['cmd', 'ml9k', 'input line too long', 'launch-file'], + ['cmd', 'e8100', 'exact', 'on-line'], + ['cmd', 'e8191', 'input line too long', 'launch-file'], + ['cmd', 'pq', 'damaged by main’s quoting, exact on this line', 'on-line'], + ['cmd', 'ppct', 'damaged by main’s quoting, exact on this line', 'on-line'], + ['cmd', 'pbs', 'damaged by main’s quoting, exact on this line', 'on-line'], + ['powershell', 'p2k', 'exact on 5.1 and 7', 'on-line'], + ['powershell', 'p20k', 'exact on 5.1 and 7', 'on-line'], + ['powershell', 'p100k', 'never started on 5.1 and 7', 'launch-file'], + ['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', 'ppct', 'exact on 5.1 and 7', 'on-line'], + ['powershell', 'pbs', 'exact on 7, damaged on 5.1 (version 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'], + ['posix', 'ml9k', 'exact in Git Bash', 'on-line'], + ['posix', 'e8191', 'exact in Git Bash', 'on-line'], + ['posix', 'pq', 'exact in Git Bash', 'on-line'], + ['posix', 'ppct', 'exact in Git Bash', 'on-line'], + ['posix', 'pbs', 'exact in Git Bash', 'on-line'] + ])('%s %s (main: %s) rides %s', (shell, row, _main, expected) => { + expect(carry('claude', row, shell)).toBe(expected) + expect(carry('codex', row, shell)).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']>([ + ['powershell', 'p2k', 'on-line'], + ['powershell', 'pq', 'launch-file'], + ['powershell', 'ppct', 'launch-file'], + ['powershell', 'pbs', 'launch-file'], + ['posix', 'ppct', 'launch-file'], + ['posix', 'pq', 'on-line'], + // Unmeasured through a shim (its cmd.exe's cap and line-break cut), so typed as main typed it. + ['powershell', 'ml5', 'on-line'], + ['powershell', 'e8191', 'on-line'], + ['posix', 'ml5', 'on-line'] + ])('through a spelled-out .cmd shim, %s %s rides %s', (shell, row, expected) => { + expect(carry('claude', row, shell, 'C:/Users/ada/AppData/Roaming/npm/claude.cmd')).toBe( + expected + ) + }) + + // Why: a typed carriage return is Enter (in cmd the rest runs as commands) and a Tab completes. + it.each(['cmd', 'powershell', 'posix'])( + 'never types a Tab or a carriage return into %s', + (shell) => { + const planned = (paste: 'once-agent-runs' | 'when-host-proves-agent') => + planLaunchPrompt({ + agent: 'claude', + prompt: 'before\tmiddle\r& echo PWNED', + cmdOverrides: {}, + platform: 'win32', + shell, + host: describeLaunchHost({ + launchPlatform: 'win32', + isRemote: false, + hostPlatform: 'win32', + paired: false + }), + paste + })?.carry + expect(planned('once-agent-runs')).toBe('paste-after-ready') + expect(planned('when-host-proves-agent')).toBe('launch-file') + } + ) + + // Why: an AI button's prompt was pasted on main, so only a line measured exact replaces that + // paste; measured damage and the unmeasured both keep main's paste. + it.each<[AgentStartupShell, Row, 'on-line' | 'paste-after-ready']>([ + ['cmd', 'ml5', 'paste-after-ready'], + ['cmd', 'pq', 'on-line'], + ['powershell', 'ml5', 'on-line'], + ['powershell', 'ml9k', 'paste-after-ready'], + ['powershell', 'pq', 'paste-after-ready'], + ['powershell', 'p20k', 'on-line'], + ['posix', 'ml9k', 'on-line'], + ['posix', 'p20k', 'paste-after-ready'] + ])('an AI button on %s with %s gets %s', (shell, row, expected) => { + const planned = planLaunchPrompt({ + agent: 'claude', + prompt: PROMPTS[row], + cmdOverrides: { + claude: 'node C:/Users/neil/orca-qa/stack-final/win/bin/stub.js --qa-as=claude' + }, + platform: 'win32', + shell, + host: describeLaunchHost({ + launchPlatform: 'win32', + isRemote: false, + hostPlatform: 'win32', + paired: false + }), + paste: 'once-agent-runs' + }) + expect(planned?.carry).toBe(expected) + }) +}) diff --git a/src/shared/windows-launch-line.ts b/src/shared/windows-launch-line.ts new file mode 100644 index 00000000000..787df4643a6 --- /dev/null +++ b/src/shared/windows-launch-line.ts @@ -0,0 +1,96 @@ +/** + * What a typed Windows launch line does to its prompt. Nothing stages a Windows line, so this is per + * shell, from lines measured through each one (stack QA, native agent executables, 2026-10-02): + * `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 { AgentStartupShell } from './tui-agent-startup-shell' +import { TYPED_STARTUP_LINE_BUDGET_BYTES } from './typed-startup-line' + +export type WindowsLineVerdict = 'exact' | 'damaged' | 'uncertain' + +/** cmd.exe's line cap: an 8,172-character line ran, an 8,263-character one did not start. */ +export const WINDOWS_CMD_LINE_MAX_CHARS = 8191 + +/** CreateProcess's 32,767-character command line, less room for the resolved executable path. + * Measured: 20,552 characters arrived, 99,072 never started. */ +export const WINDOWS_POWERSHELL_LINE_MAX_CHARS = 32_000 + +/** A multi-line PowerShell line: 356 bytes arrived; 9,088 lost its line breaks (5.1) or ran its + * lines as commands (7). Exact is held to the typed budget, damaged from the measured failure. */ +const WINDOWS_POWERSHELL_MULTI_LINE_DAMAGED_BYTES = 9_088 + +/** Git Bash: 9,089 characters arrived whole, line breaks included (typed in 17 s); 20,551 never + * started. */ +export const WINDOWS_POSIX_LINE_EXACT_MAX_CHARS = 9_089 +const WINDOWS_POSIX_LINE_DAMAGED_CHARS = 20_551 + +const encoder = new TextEncoder() + +/** A control byte other than a line feed: every Windows line editor reads it as a key (a carriage + * return is Enter, which in cmd runs the rest as commands; Tab completes), so the bytes change. */ +function hasKeyByte(line: string): boolean { + for (let i = 0; i < line.length; i += 1) { + const code = line.charCodeAt(i) + if ((code < 0x20 && code !== 0x0a) || code === 0x7f) { + return true + } + } + return false +} + +/** An agent spelled as a `.cmd`/`.bat` shim, whose cmd.exe re-reads its arguments. A bare name + * may be either, so only the spelled-out shim counts. */ +function launchesThroughCmdShim(line: string): boolean { + const target = /^(?:&\s*)?(?:'([^']*)'|"([^"]*)"|(\S+))/.exec(line.trim()) + return /\.(?:cmd|bat)$/i.test(target?.[1] ?? target?.[2] ?? target?.[3] ?? '') +} + +function shellVerdict(line: string, shell: AgentStartupShell): WindowsLineVerdict { + const multiLine = line.includes('\n') + if (shell === 'cmd') { + // A line feed submits early; `"`, `%NAME%` and a trailing `\` arrive. + return multiLine || line.length > WINDOWS_CMD_LINE_MAX_CHARS ? 'damaged' : 'exact' + } + if (shell === 'powershell') { + if (line.length > WINDOWS_POWERSHELL_LINE_MAX_CHARS) { + return 'damaged' + } + const bytes = encoder.encode(line).byteLength + if (!multiLine || bytes <= TYPED_STARTUP_LINE_BUDGET_BYTES) { + return 'exact' + } + return bytes >= WINDOWS_POWERSHELL_MULTI_LINE_DAMAGED_BYTES ? 'damaged' : 'uncertain' + } + // Git Bash (and a WSL shell): POSIX quoting arrived whole. + if (line.length <= WINDOWS_POSIX_LINE_EXACT_MAX_CHARS) { + return 'exact' + } + return line.length >= WINDOWS_POSIX_LINE_DAMAGED_CHARS ? 'damaged' : 'uncertain' +} + +export function windowsLaunchLineVerdict( + prompt: string, + line: string, + shell: AgentStartupShell +): WindowsLineVerdict { + if (hasKeyByte(line)) { + return 'damaged' + } + 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 + } + // Measured (#23962 W-1): PowerShell hands a shim `"` and a trailing `\` the legacy way, and the + // shim's cmd.exe expands `%NAME%`. + if (/%[^%\s]+%/.test(prompt) || (shell === 'powershell' && legacyArgsDamage)) { + return 'damaged' + } + // The shim's cmd.exe also caps the line and cuts at a line break; not measured through a shim. + return verdict === 'exact' && (line.includes('\n') || line.length > WINDOWS_CMD_LINE_MAX_CHARS) + ? 'uncertain' + : verdict +} diff --git a/src/shared/windows-shell-prompt-damage.test.ts b/src/shared/windows-shell-prompt-damage.test.ts index 1e42cd273fd..5e96e657433 100644 --- a/src/shared/windows-shell-prompt-damage.test.ts +++ b/src/shared/windows-shell-prompt-damage.test.ts @@ -5,11 +5,13 @@ import { buildAgentDraftLaunchPlan } from './tui-agent-startup' import type { AgentStartupShell } from './tui-agent-startup-shell' import { windowsDraftRefusal } from './launch-prompt-carry' -function plan(prompt: string, shell: AgentStartupShell) { +const SHIM = 'C:/Users/ada/AppData/Roaming/npm/claude.cmd' + +function plan(prompt: string, shell: AgentStartupShell, command?: string) { return planLaunchForTest({ agent: 'claude', prompt, - cmdOverrides: {}, + cmdOverrides: command ? { claude: command } : {}, platform: shell === 'posix' ? 'darwin' : 'win32', shell }) @@ -18,7 +20,9 @@ function plan(prompt: string, shell: AgentStartupShell) { describe('a prompt a Windows shell would damage on the launch line', () => { // Measured (ps-quoting-matrix.md): PowerShell 5.1, and 7.x through a .cmd shim, split a `"` out of // the plain literal and turn a trailing backslash into `"`; the legacy-passing escape would let a - // .cmd shim's cmd.exe run `&` and `<>` from inside the user's quotes. So these ride a launch file. + // .cmd shim's cmd.exe run `&` and `<>` from inside the user's quotes. A spelled-out shim gets a + // launch file. A bare name may be a native executable, where 7 carried them exactly (stack QA), + // so its line is typed as main typed it. it.each([ ['P1', 'fix the "foo bar" bug'], ['P2', '"leading quote" then text'], @@ -27,12 +31,16 @@ describe('a prompt a Windows shell would damage on the launch line', () => { ['P5', 'back\\slash\\\\ "q" end\\'], ['P7', 'replace "" with "a & b"'], ['a bare path', 'see C:\\dir\\'] - ])('moves %s into a launch file on PowerShell', (_, prompt) => { - const startup = plan(prompt, 'powershell') - expect(startup?.launchFile?.content).toBe(prompt) - expect(startup?.launchCommand).not.toContain('"') - expect(startup?.launchCommand).not.toContain('Legacy') - }) + ])( + 'moves %s into a launch file through a PowerShell shim, and types it for a bare name', + (_, prompt) => { + const startup = plan(prompt, 'powershell', SHIM) + expect(startup?.launchFile?.content).toBe(prompt) + expect(startup?.launchCommand).not.toContain('"') + expect(startup?.launchCommand).not.toContain('Legacy') + expect(plan(prompt, 'powershell')?.launchFile).toBeUndefined() + } + ) // Measured form F-A: a pointer to a spaced path passed in every PowerShell and target. it('types P6, the backtick pointer, and quote-free prompts as a plain literal', () => { @@ -41,9 +49,13 @@ describe('a prompt a Windows shell would damage on the launch line', () => { expect(plan("fix Bob's build", 'powershell')?.launchCommand).toBe("claude 'fix Bob''s build'") }) - // Measured (QA-WIN R0-R2): through a `.cmd` shim, `%PATH%` reached the agent as 1,795 characters. - it('moves a %NAME% pair into a launch file on PowerShell and keeps a lone percent typed', () => { - expect(plan('echo %PATH% for me', 'powershell')?.launchFile?.content).toBe('echo %PATH% for me') + // Measured (QA-WIN R0-R2): through a `.cmd` shim, `%PATH%` reached the agent as 1,795 characters; + // to a native executable it arrived (stack QA). + it('moves a %NAME% pair into a launch file through a shim and keeps a lone percent typed', () => { + expect(plan('echo %PATH% for me', 'powershell', SHIM)?.launchFile?.content).toBe( + 'echo %PATH% for me' + ) + expect(plan('echo %PATH% for me', 'powershell')?.launchFile).toBeUndefined() expect(plan('coverage is 80% now', 'powershell')?.launchCommand).toBe( "claude 'coverage is 80% now'" )