fix(launch-prompt): one carry order on every platform; the line holds 100,000 bytes

A 20 KB Fix-checks prompt on macOS became a launch-file pointer, so the agent's
history showed "The full task is in the file ..." where main pasted the user's
text. The 16,384-character ceiling predated staging: a staged line now reaches
the agent's argv byte-exact at 100,000 UTF-8 bytes in bash, zsh, dash, sh, ksh,
tcsh and fish (measured in real shells), under Linux's 131,072-byte cap on one
argument.

The order is now the same everywhere: the line when it carries the exact text;
otherwise the paste main uses on that path ('once-agent-runs' for desktop
submit-after-ready prompts); the launch file only where neither exists.
This commit is contained in:
Brennan Benson
2026-10-02 03:36:50 -07:00
parent f8288f8a0f
commit 6fe25271b8
11 changed files with 132 additions and 61 deletions
@@ -9,7 +9,7 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { carryInLaunchFile } from '../../shared/launch-prompt-file'
import { MAX_LINE_PROMPT_BYTES, carryInLaunchFile } from '../../shared/launch-prompt-file'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
@@ -79,7 +79,7 @@ describe('a terminal create that is handed a launch prompt', () => {
it('hands a prompt past the argv ceiling to the host as a launch file', async () => {
const { runtime, spawn } = runtimeWithAgentLaunch()
const prompt = 'x'.repeat(20_000)
const prompt = 'x'.repeat(MAX_LINE_PROMPT_BYTES + 1)
await runtime.createTerminal('id:wt-1', { startupAgent: 'claude', startupPrompt: prompt })
@@ -108,7 +108,7 @@ describe('a terminal create that is handed a launch prompt', () => {
await runtime.createTerminal('id:wt-1', {
startupAgent: 'gemini',
startupPrompt: 'x'.repeat(30_000),
startupPrompt: 'x'.repeat(MAX_LINE_PROMPT_BYTES + 1),
onStartupPromptCarry
})
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../shared/repo-types'
import { tuiAgentToAgentKind } from '../../shared/agent-kind'
import { MAX_LINE_PROMPT_BYTES } from '../../shared/launch-prompt-file'
const mocks = vi.hoisted(() => ({
detectRemoteAgents: vi.fn(),
@@ -127,7 +128,7 @@ describe('buildWorktreeStartupForAgent prompt carry', () => {
})
it('hands a prompt past the argv ceiling to the host as a launch file', () => {
const prompt = 'x'.repeat(20_000)
const prompt = 'x'.repeat(MAX_LINE_PROMPT_BYTES + 1)
const result = build(prompt)
expect(result.startup.launchFile?.content).toBe(prompt)
expect(result.startup.command).toContain(result.startup.launchFile?.placeholder)
@@ -141,7 +142,7 @@ describe('buildWorktreeStartupForAgent prompt carry', () => {
})
it('leaves a prompt needing a file the agent is not known to read as the follow-up paste', () => {
const prompt = 'x'.repeat(20_000)
const prompt = 'x'.repeat(MAX_LINE_PROMPT_BYTES + 1)
const result = buildWorktreeStartupForAgent({
repo: makeRepo({}),
settings,
@@ -160,7 +161,7 @@ describe('buildWorktreeStartupForAgent prompt carry', () => {
repo: makeRepo({}),
settings,
agent: 'gemini',
prompt: 'x'.repeat(20_000),
prompt: 'x'.repeat(MAX_LINE_PROMPT_BYTES + 1),
getLaunchPlatform: () => 'linux',
toSessionOptions: () => undefined,
onPromptCarry
@@ -4,6 +4,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TuiAgent } from '../../../shared/tui-agent'
import { MAX_LINE_PROMPT_BYTES } from '../../../shared/launch-prompt-file'
import {
callerProfileCases,
type AgentLaunchCallerProfile
@@ -222,17 +223,20 @@ describe('agent launch caller prompt transport', () => {
})
// The bug class the explicit carry outcome removes: a plan that exists but does not carry the
// prompt was treated as delivered, and the prompt was dropped.
// prompt was treated as delivered, and the prompt was dropped. Why Claude and Codex too: main
// pastes an AI button's prompt, so the agent gets the user's text, never a launch-file pointer.
it.each([
['past the argv ceiling', 'x'.repeat(20_000), 'darwin' as const],
['a Windows-damaged prompt', 'say "hi"', 'win32' as const]
])(
'pastes %s for an agent not measured reading a launch file, and waits for that paste',
async (_label, prompt, launchPlatform) => {
['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']
] as const)(
'pastes %s for %s, and waits for that paste',
async (_label, agent, prompt, launchPlatform) => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'gemini',
agent,
worktreeId: 'wt-1',
prompt,
promptDelivery: 'submit-after-ready',
@@ -253,6 +257,23 @@ describe('agent launch caller prompt transport', () => {
}
)
it('stages a 20 KB AI-button prompt on the agent’s own line on a POSIX host', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const prompt = `Session context:\n${'w'.repeat(20_000)}`
launchAgentInNewTab({
agent: 'codex',
worktreeId: 'wt-1',
prompt,
promptDelivery: 'submit-after-ready',
launchPlatform: 'darwin'
})
expect(queuedStartupCommand(store)).toContain('w'.repeat(100))
expect(queuedStartupPayload(store)?.launchFile).toBeUndefined()
expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled()
})
it('mirrors an argv-carried draft into the chat composer', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { MAX_LINE_PROMPT_BYTES } from '../../../shared/launch-prompt-file'
const mockCreateTab = vi.fn()
const mockQueueTabStartupCommand = vi.fn()
@@ -310,8 +311,8 @@ describe('launchAgentInNewTab', () => {
launchAgentInNewTab({
agent: 'codex',
worktreeId: 'wt-1',
prompt: 'y'.repeat(20_000),
promptDelivery: 'submit-after-ready'
prompt: 'y'.repeat(MAX_LINE_PROMPT_BYTES + 1),
promptDelivery: 'auto-submit'
})
expect(mockQueueTabStartupCommand.mock.calls[0]?.[1]?.launchFile).toBeDefined()
@@ -841,21 +842,17 @@ describe('launchAgentInNewTab', () => {
expect(mockToastMessage).not.toHaveBeenCalled()
})
it('hands a prompt past the argv ceiling to the host as a launch file the command points at', async () => {
it('hands a typed prompt past the argv ceiling to the host as a launch file the command points at', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const prompt = `Session context:\n${'x'.repeat(20_000)}`
const prompt = `Session context:\n${'x'.repeat(MAX_LINE_PROMPT_BYTES)}`
const result = launchAgentInNewTab({
launchAgentInNewTab({
agent: 'codex',
worktreeId: 'wt-1',
prompt,
promptDelivery: 'submit-after-ready'
promptDelivery: 'auto-submit'
})
await expect(result?.promptDeliveryResult).resolves.toEqual({
delivered: true,
failureNotified: false
})
expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled()
const queued = mockQueueTabStartupCommand.mock.calls[0]?.[1]
// Handed back to copy if the host refuses to write the file.
@@ -19,6 +19,7 @@ import {
notifyDirectWorkItemAgentStartTimeout
} from './launch-work-item-direct-agent'
import type { AgentStartupPlan } from './tui-agent-startup'
import { MAX_LINE_PROMPT_BYTES } from '../../../shared/launch-prompt-file'
describe('buildDirectWorkItemStartupOpts', () => {
it('preserves Codex startup command delivery for linked work-item launches', () => {
@@ -191,18 +192,21 @@ describe('buildDirectWorkItemAgentStartupPlan submitted prompts', () => {
// The bug class the explicit carry outcome removes: a plan that exists but does not carry the
// prompt was reported as on the launch command, so no paste ran and the prompt was dropped.
it('leaves a file-sized prompt for the paste for an agent not measured reading the file', () => {
const result = submit('gemini', 'g'.repeat(20_000))
const result = submit('gemini', 'g'.repeat(MAX_LINE_PROMPT_BYTES + 1))
expect(result.promptOnLaunchCommand).toBe(false)
expect(result.startupPlan?.launchCommand).not.toContain('gggg')
expect(result.launchFile).toBeUndefined()
})
it('hands Claude the same prompt in a launch file, with the prompt to copy if refused', () => {
const prompt = 'c'.repeat(20_000)
const result = submit('claude', prompt)
expect(result.promptOnLaunchCommand).toBe(true)
expect(result.launchFile?.content).toBe(prompt)
expect(result.launchPrompt).toBe(prompt)
// Why: main pastes a work item's prompt, so past the argv ceiling Claude gets the user's text.
it('stages a long prompt on Claude’s line and pastes one past the argv ceiling', () => {
const long = submit('claude', 'c'.repeat(20_000))
expect(long.promptOnLaunchCommand).toBe(true)
expect(long.launchFile).toBeUndefined()
const huge = submit('claude', 'c'.repeat(MAX_LINE_PROMPT_BYTES + 1))
expect(huge.promptOnLaunchCommand).toBe(false)
expect(huge.launchFile).toBeUndefined()
expect(huge.startupPlan?.launchCommand).not.toContain('cccc')
})
it('pastes on a paired host what its line cannot carry typed', () => {
@@ -42,17 +42,33 @@ describe('planSourceControlAgentActionLaunch', () => {
expect(result.ok && result.caveat).toContain('PATH')
})
it('says an input past the argv ceiling goes in a private file, not on the command line', () => {
it('says a typed input past the argv ceiling goes in a private file, and an AI button pastes it', () => {
const plan = (promptDelivery: 'submit-after-ready' | 'auto-submit') =>
planSourceControlAgentActionLaunch({
agent: 'codex',
commandInput: 'z'.repeat(200_000),
promptDelivery,
detectedAgents: ['codex'],
platform: 'linux'
})
const typed = plan('auto-submit')
expect(typed.ok && typed.summary).toContain('private file')
expect(typed.ok && typed.summary).not.toContain('included in the launch command')
const button = plan('submit-after-ready')
expect(button.ok && button.delivery).toBe('paste-submit')
})
it('carries a 20 KB AI-button input on the command line of a POSIX host', () => {
const result = planSourceControlAgentActionLaunch({
agent: 'codex',
commandInput: 'z'.repeat(200_000),
commandInput: 'z'.repeat(20_000),
promptDelivery: 'submit-after-ready',
detectedAgents: ['codex'],
platform: 'linux'
platform: 'darwin'
})
expect(result.ok && result.summary).toContain('private file')
expect(result.ok && result.summary).not.toContain('included in the launch command')
expect(result.ok && result.delivery).toBe('argv')
})
// Why: main pastes an AI button's prompt on Windows, so the agent's history shows the user's
+3 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { planLaunchForTest } from './launch-prompt-plan.test-fixture'
import { MAX_LINE_PROMPT_BYTES } from './launch-prompt-file'
const base = { agent: 'claude' as const, cmdOverrides: {} }
@@ -13,7 +14,7 @@ describe('the one launch-prompt decision every launch path builds through', () =
})
it('points a prompt past the argv ceiling at a launch file on a POSIX host too', () => {
const prompt = 'z'.repeat(20_000)
const prompt = 'z'.repeat(MAX_LINE_PROMPT_BYTES + 1)
expect(planLaunchForTest({ ...base, prompt, platform: 'linux' })?.launchFile?.content).toBe(
prompt
)
@@ -43,7 +44,7 @@ describe('the one launch-prompt decision every launch path builds through', () =
const typed = planLaunchForTest({ ...base, platform: 'linux', prompt: 'one\ntwo' })
expect(typed?.launchCommand).toContain('two')
expect(typed?.pasteAfterReady).toBeNull()
const huge = 'c'.repeat(20_000)
const huge = 'c'.repeat(MAX_LINE_PROMPT_BYTES + 1)
const pointed = planLaunchForTest({ ...base, platform: 'linux', prompt: huge })
expect(pointed?.launchFile?.content).toBe(huge)
expect(pointed?.pasteAfterReady).toBeNull()
+21 -10
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { MAX_INLINE_LAUNCH_PROMPT_CHARS, carryInLaunchFile } from './launch-prompt-file'
import { MAX_LINE_PROMPT_BYTES, carryInLaunchFile } from './launch-prompt-file'
import { planLaunchPrompt, type AgentLaunchPromptArgs } from './tui-agent-startup'
import type { TuiAgent } from './tui-agent'
import { RUNTIME_CAPABILITIES } from './protocol-version'
@@ -58,7 +58,7 @@ describe('where a launch prompt rides', () => {
it.each<TuiAgent>(['claude', 'codex'])(
'points %s at a launch file past the argv ceiling, with the full text in the file',
(agent) => {
const prompt = `${'y'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS)}z`
const prompt = `${'y'.repeat(MAX_LINE_PROMPT_BYTES)}z`
const planned = plan(agent, prompt)
if (planned?.carry !== 'launch-file') {
throw new Error(`expected a launch file, got ${planned?.carry}`)
@@ -71,7 +71,7 @@ describe('where a launch prompt rides', () => {
// The bug class this outcome removes: a plan that exists but does not carry the prompt.
it('leaves a file-sized prompt for the paste for an agent not measured reading the file', () => {
const prompt = 'g'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS + 1)
const prompt = 'g'.repeat(MAX_LINE_PROMPT_BYTES + 1)
const planned = plan('gemini', prompt)
if (planned?.carry !== 'paste-after-ready') {
throw new Error(`expected the paste, got ${planned?.carry}`)
@@ -83,7 +83,7 @@ describe('where a launch prompt rides', () => {
// Why: main started such an agent with the whole prompt on its line; a caller with no paste
// (agentSession.create, a phone quick command) must not refuse it now.
it('keeps a file-sized prompt on the line for a caller that cannot paste', () => {
const prompt = 'g'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS + 1)
const prompt = 'g'.repeat(MAX_LINE_PROMPT_BYTES + 1)
const planned = plan('gemini', prompt, { paste: 'never' })
expect(planned?.carry).toBe('on-line')
expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toContain('gggg')
@@ -169,9 +169,19 @@ describe('a host that types the line raw', () => {
expect(plan('gemini', 'say "hi"', extra)?.carry).toBe('on-line')
})
it('still points Claude at a file past the argv ceiling on a POSIX host, whatever the paste', () => {
const planned = plan('claude', 'y'.repeat(20_000), { paste: 'once-agent-runs' })
expect(planned?.carry).toBe('launch-file')
// Why: a 20 KB Fix-checks prompt on macOS arrives as the user's text, never a pointer.
it('stages a long POSIX prompt on the line, and past the ceiling pastes where main pasted', () => {
expect(plan('claude', 'y'.repeat(20_000), { paste: 'once-agent-runs' })?.carry).toBe('on-line')
const huge = 'y'.repeat(MAX_LINE_PROMPT_BYTES + 1)
expect(plan('claude', huge, { paste: 'once-agent-runs' })?.carry).toBe('paste-after-ready')
expect(plan('claude', huge)?.carry).toBe('launch-file')
})
it('measures the argv ceiling in UTF-8 bytes, not characters', () => {
// 99,999 bytes in 33,333 three-byte characters.
const underCeiling = '日'.repeat(Math.floor(MAX_LINE_PROMPT_BYTES / 3))
expect(plan('claude', underCeiling)?.carry).toBe('on-line')
expect(plan('claude', '日'.repeat(MAX_LINE_PROMPT_BYTES / 3 + 1))?.carry).toBe('launch-file')
})
// Why: a host that cannot prove the agent holds its terminal refuses a paste (#24257), so the
@@ -184,12 +194,13 @@ describe('a host that types the line raw', () => {
}
const prompt = 'fix the build\nthen run the tests'
expect(plan('gemini', prompt, extra)?.carry).toBe('on-line')
expect(plan('gemini', 'y'.repeat(20_000), extra)?.carry).toBe('on-line')
expect(plan('claude', 'y'.repeat(20_000), extra)?.carry).toBe('launch-file')
const huge = 'y'.repeat(MAX_LINE_PROMPT_BYTES + 1)
expect(plan('gemini', huge, extra)?.carry).toBe('on-line')
expect(plan('claude', huge, extra)?.carry).toBe('launch-file')
expect(plan('aider', prompt, extra)?.carry).toBe('paste-after-ready')
// An AI button's paste is main's, which ran in WSL too.
const button = { ...extra, paste: 'once-agent-runs' as const }
expect(plan('gemini', 'y'.repeat(20_000), button)?.carry).toBe('paste-after-ready')
expect(plan('gemini', huge, button)?.carry).toBe('paste-after-ready')
})
it('pastes on a paired host what its line cannot carry typed, even for Claude', () => {
+11 -10
View File
@@ -1,6 +1,6 @@
/** Where a launch prompt rides: decided once here, for every launch path, and switched on by each. */
import {
MAX_INLINE_LAUNCH_PROMPT_CHARS,
MAX_LINE_PROMPT_BYTES,
carryInLaunchFile,
launchFileDirectoryPlaceholder,
type LaunchFile
@@ -96,6 +96,8 @@ export function agentReadsLaunchFile(agent: TuiAgent): boolean {
return TUI_AGENT_CONFIG[agent].readsLaunchFile === true
}
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
@@ -116,10 +118,10 @@ function windowsLineCarriesExactly(prompt: string, line: string, shell: AgentSta
* 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), it rides a launch file, which goes only to an agent measured reading
* one, on a host that writes it; on Windows, a caller whose paste main used gets that paste first.
* 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.
* 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.
*/
export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchCommand: string }>(
args: A,
@@ -152,10 +154,9 @@ export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchC
return plan ? { carry: 'on-line', plan } : pasteAfterReady()
}
const viaLaunchFile = (): LaunchPromptPlan<P> | null => {
// Why paste first on Windows: main pastes this caller's prompts, so the agent gets the user's
// text; there a pointer would replace nearly every generated (multi-line) prompt. Temporary,
// until the agent's own argv carries multi-line text past cmd without a shim re-parsing it.
if (args.paste === 'once-agent-runs' && args.platform === 'win32') {
// Why paste first: main pastes this caller's prompts, so the agent gets the user's text; a
// pointer replaces only a line main would have typed damaged or cut.
if (args.paste === 'once-agent-runs') {
return pasteAfterReady()
}
// Why: an agent not measured reading the file would stop on an approval or refuse the path.
@@ -168,7 +169,7 @@ export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchC
plan && { carry: 'launch-file', plan, launchFile: withQuoting(pointer.launchFile, shell) }
)
}
if (text.length > MAX_INLINE_LAUNCH_PROMPT_CHARS) {
if (utf8.encode(text).byteLength > MAX_LINE_PROMPT_BYTES) {
return viaLaunchFile()
}
const plan = buildLine(args)
+3 -2
View File
@@ -6,8 +6,9 @@
import type { AgentStartupShell } from './tui-agent-startup-shell'
/** Keeps agent argv comfortably below the lowest practical OS command-line limit. */
export const MAX_INLINE_LAUNCH_PROMPT_CHARS = 16_384
/** UTF-8 bytes a launch line carries to the agent's argv exactly (measured through staging in real
* shells); under Linux's 131,072-byte cap on one argument. */
export const MAX_LINE_PROMPT_BYTES = 100_000
/** Content the host that owns the PTY writes to a private file before anything names it. */
export type LaunchFile = {
@@ -23,7 +23,11 @@ import * as pty from 'node-pty'
import { afterAll, describe, expect, it } from 'vitest'
import { resolveFishBinary } from './fish-binary-requirement'
import { writeLaunchFile, type WrittenLaunchFile } from './launch-file-writing'
import { buildLaunchFilePointer, carryInLaunchFile } from './launch-prompt-file'
import {
MAX_LINE_PROMPT_BYTES,
buildLaunchFilePointer,
carryInLaunchFile
} from './launch-prompt-file'
import { stageStartupCommand } from './startup-command-staging'
import { buildStartupCommandSubmission } from './startup-command-submission'
import { quoteStartupArg } from './tui-agent-startup-shell'
@@ -71,6 +75,18 @@ afterAll(() => {
rmSync(SANDBOX, { recursive: true, force: true })
})
/** Exactly `bytes` of UTF-8: the hostile characters, line breaks, and two-, three- and four-byte
* characters. */
function utf8PromptOfBytes(bytes: number): string {
const encoder = new TextEncoder()
const unit = `${HOSTILE}\nčé 日本語 🙂\n`
let prompt = ''
while (encoder.encode(prompt + unit).byteLength <= bytes) {
prompt += unit
}
return prompt + 'x'.repeat(bytes - encoder.encode(prompt).byteLength)
}
const HOSTILE = `it's "quoted" $HOME \`id\` $(id) \\\\server\\share %PATH% !! #`
const PROMPTS: [string, string][] = [
@@ -79,7 +95,9 @@ const PROMPTS: [string, string][] = [
['a 5 KB prompt', `${HOSTILE} ${'y'.repeat(5000)}`],
['a multi-line prompt with a trailing newline', `first line\n${HOSTILE}\n\nlast line\n`],
// Why: typed raw, a line editor reads the TAB as completion and mangles the argument.
['a prompt with a tab', 'before\tafter']
['a prompt with a tab', 'before\tafter'],
// Why: the most a launch line carries before its prompt moves to a launch file or the paste.
['a 100,000-byte multi-line UTF-8 prompt', utf8PromptOfBytes(MAX_LINE_PROMPT_BYTES)]
]
type AgentRun = { argv: string; pgid: number; foregroundPgid: number; shellPid: number }