mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user