From 6fe25271b8a77d24ebe3efeab524787acc74a272 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:36:50 -0700 Subject: [PATCH] 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. --- .../agent-terminal-startup-prompt.test.ts | 6 ++-- .../runtime-worktree-agent-startup.test.ts | 7 ++-- ...ent-launch-caller-prompt-transport.test.ts | 35 +++++++++++++++---- .../src/lib/launch-agent-in-new-tab.test.ts | 17 ++++----- .../lib/launch-work-item-direct-agent.test.ts | 18 ++++++---- .../source-control-agent-action-plan.test.ts | 26 +++++++++++--- src/shared/launch-prompt-carrier.test.ts | 5 +-- src/shared/launch-prompt-carry.test.ts | 31 ++++++++++------ src/shared/launch-prompt-carry.ts | 21 +++++------ src/shared/launch-prompt-file.ts | 5 +-- .../startup-command-staging.node-pty.test.ts | 22 ++++++++++-- 11 files changed, 132 insertions(+), 61 deletions(-) diff --git a/src/main/runtime/agent-terminal-startup-prompt.test.ts b/src/main/runtime/agent-terminal-startup-prompt.test.ts index 492a289f10c..6ca5742b90d 100644 --- a/src/main/runtime/agent-terminal-startup-prompt.test.ts +++ b/src/main/runtime/agent-terminal-startup-prompt.test.ts @@ -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 }) diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index 0812841832b..f30d587cf8c 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -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 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 6155a7e51ab..5a65d159b98 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 @@ -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') diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 4a18aa3a379..904926237d0 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -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. 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 1b90b6f6e8c..b12f61c652f 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 @@ -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', () => { diff --git a/src/renderer/src/lib/source-control-agent-action-plan.test.ts b/src/renderer/src/lib/source-control-agent-action-plan.test.ts index ebd6bd5aa7a..f71ae310a18 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.test.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.test.ts @@ -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 diff --git a/src/shared/launch-prompt-carrier.test.ts b/src/shared/launch-prompt-carrier.test.ts index 6714581d266..784c6ba3164 100644 --- a/src/shared/launch-prompt-carrier.test.ts +++ b/src/shared/launch-prompt-carrier.test.ts @@ -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() diff --git a/src/shared/launch-prompt-carry.test.ts b/src/shared/launch-prompt-carry.test.ts index aad6793ede1..4454e4854ff 100644 --- a/src/shared/launch-prompt-carry.test.ts +++ b/src/shared/launch-prompt-carry.test.ts @@ -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(['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', () => { diff --git a/src/shared/launch-prompt-carry.ts b/src/shared/launch-prompt-carry.ts index a54f386a051..5e9fad2c95e 100644 --- a/src/shared/launch-prompt-carry.ts +++ b/src/shared/launch-prompt-carry.ts @@ -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( args: A, @@ -152,10 +154,9 @@ export function carryLaunchPrompt | 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 MAX_INLINE_LAUNCH_PROMPT_CHARS) { + if (utf8.encode(text).byteLength > MAX_LINE_PROMPT_BYTES) { return viaLaunchFile() } const plan = buildLine(args) diff --git a/src/shared/launch-prompt-file.ts b/src/shared/launch-prompt-file.ts index 62c9e2ebc62..63adc1f0087 100644 --- a/src/shared/launch-prompt-file.ts +++ b/src/shared/launch-prompt-file.ts @@ -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 = { diff --git a/src/shared/startup-command-staging.node-pty.test.ts b/src/shared/startup-command-staging.node-pty.test.ts index f0e202b4393..e5a8fced24f 100644 --- a/src/shared/startup-command-staging.node-pty.test.ts +++ b/src/shared/startup-command-staging.node-pty.test.ts @@ -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 }