mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
fix(launch-prompt): judge a Windows line per shell from measured damage
Stack QA on Windows found Claude/Codex given a launch-file pointer where main's typed line delivered the exact text: Git Bash multi-line and 8,191-character lines, PowerShell 5-line, 20 KB and 8,191-character lines, %NAME% pairs, and on pwsh 7 `"` and a trailing `\`. The rule treated every Windows shell like cmd. windowsLaunchLineVerdict now judges the typed line per shell from the measured rows: cmd (8,191-character cap, no line break), PowerShell (CreateProcess's cap; multi-line within the typed budget; 9 KB measured damaged), Git Bash (9,089 characters measured whole, 20,551 never started). A spelled-out .cmd/.bat shim adds its cmd hop's damage. Its verdict is exact (ride the line), damaged (main's paste on that path, else a launch file) or uncertain (what main does on that path: its paste, or the line it typed), so a launch file appears only where main's line was measured to fail. PowerShell `"` and a trailing `\` are uncertain, because 5.1 breaks them and 7 does not.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<A extends CarriedPlanArgs, P extends { launchC
|
||||
if (readsEnv) {
|
||||
return { carry: 'on-line', plan }
|
||||
}
|
||||
if (args.platform === 'win32' && !windowsLineCarriesExactly(text, plan.launchCommand, shell)) {
|
||||
const windowsLine =
|
||||
args.platform === 'win32' ? windowsLaunchLineVerdict(text, plan.launchCommand, shell) : 'exact'
|
||||
// Why #24257's typed budget: an older paired host may type the line raw, truncated past it.
|
||||
if (
|
||||
windowsLine === 'damaged' ||
|
||||
(args.host.paired && !typedStartupLineFits(plan.launchCommand))
|
||||
) {
|
||||
return viaLaunchFile()
|
||||
}
|
||||
// Why #24257's typed budget: an older paired host may type the line raw, truncated past it.
|
||||
if (args.host.paired && !typedStartupLineFits(plan.launchCommand)) {
|
||||
return viaLaunchFile()
|
||||
// Why: unmeasured, so this path does what main does: its paste, or the line it typed.
|
||||
if (windowsLine === 'uncertain' && args.paste === 'once-agent-runs') {
|
||||
return pasteAfterReady()
|
||||
}
|
||||
return { carry: 'on-line', plan }
|
||||
}
|
||||
|
||||
@@ -137,7 +137,8 @@ describe('tui agent startup plans', () => {
|
||||
})
|
||||
|
||||
// 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', () => {
|
||||
|
||||
@@ -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<AgentStartupShell>(['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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "<b>" 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'"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user