mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 16:02:49 +00:00
fix(agent-launch): keep a short-lined multi-line prompt on a local zsh launch line, as main did
#24257 moved every multi-line or over-512-byte prompt off the typed launch line and pasted it after readiness. The phone's AI buttons and review notes, whose multi-line prompts main typed whole into zsh, then reached Claude 0.5-3 s later and their RPC reply waited for the paste. The host now names the shell a local macOS or Linux line is typed into, the way the spawn picks it, and a multi-line prompt rides a zsh line when every line is at most 512 bytes and the whole line at most 8 KB. A real-zsh test types such a line through Orca's own ready barrier and startup write, including when a slow user config makes the write land early. Elsewhere the measured unsafe cases keep the paste: bash 3.2 runs multi-line lines piecemeal, fish drops an early multi-line write, and any shell loses a line over 1 KB written early.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Real-zsh proof for the typed launch lines `startup-line-prompt-carry.ts` lets a prompt ride: a
|
||||
* multi-line line whose every line fits the per-line budget, up to the whole-line budget, reaches
|
||||
* zsh intact through Orca's own wrapper, ready marker and startup write.
|
||||
*
|
||||
* Why a slow user config too: the write is released 1.5 s after spawn even without the marker, and
|
||||
* then lands while the terminal is still line-buffered, where macOS keeps at most MAX_CANON bytes of
|
||||
* one line. The per-line budget is what survives that; a single 1.1 KB line did not.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import * as pty from 'node-pty'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createShellStartupOutputScanState,
|
||||
scanShellStartupOutput
|
||||
} from '../shell-startup-output-scanner'
|
||||
import { selectShellStartupFeatures } from '../shell-startup-features'
|
||||
import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission'
|
||||
import {
|
||||
TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES,
|
||||
ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES
|
||||
} from '../../shared/startup-line-prompt-carry'
|
||||
import {
|
||||
restoreUserDataPathAfterEach,
|
||||
setTestUserDataPath
|
||||
} from './local-pty-shell-ready-test-harness'
|
||||
|
||||
const ZSH_PATH =
|
||||
process.platform === 'win32'
|
||||
? ''
|
||||
: (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout ?? '').trim()
|
||||
|
||||
/** Lines of prompt text, each `perLine` bytes. */
|
||||
function promptLines(lineCount: number, perLine: number): string {
|
||||
const words = 'Fix the failing checks on this branch, then explain what changed and why.'
|
||||
return Array.from({ length: lineCount }, (_unused, index) => {
|
||||
let line = `L${index}:`
|
||||
while (line.length < perLine) {
|
||||
line += ` ${words}`
|
||||
}
|
||||
return line.slice(0, perLine)
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
let home = ''
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'orca-typed-line-home-'))
|
||||
setTestUserDataPath(mkdtempSync(join(tmpdir(), 'orca-typed-line-ud-')))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
})
|
||||
|
||||
restoreUserDataPathAfterEach()
|
||||
|
||||
/** Types `printf '%s' '<text>' > out` the way a local pane types a launch line; returns what ran. */
|
||||
async function typeIntoZsh(text: string, userConfigSeconds: number): Promise<string | null> {
|
||||
if (userConfigSeconds > 0) {
|
||||
writeFileSync(join(home, '.zshrc'), `sleep ${userConfigSeconds}\n`)
|
||||
}
|
||||
const out = join(home, 'out.txt')
|
||||
const done = join(home, 'done.txt')
|
||||
const { ensureShellReadyWrappers } = await import('./local-pty-shell-ready-wrapper-generation')
|
||||
const { getShellLaunchConfig } = await import('./local-pty-shell-ready')
|
||||
const { writeStartupCommandWhenShellReady, STARTUP_COMMAND_READY_MAX_WAIT_MS } =
|
||||
await import('./local-pty-shell-ready-startup-command')
|
||||
ensureShellReadyWrappers()
|
||||
const env: Record<string, string> = { PATH: '/usr/bin:/bin', HOME: home }
|
||||
const launch = getShellLaunchConfig(
|
||||
ZSH_PATH,
|
||||
selectShellStartupFeatures({
|
||||
shellPath: ZSH_PATH,
|
||||
env,
|
||||
hasStartupCommand: true,
|
||||
waitsForShellReady: true,
|
||||
emitsStartupIdentity: false
|
||||
})
|
||||
)
|
||||
expect(launch.supportsReadyMarker).toBe(true)
|
||||
const proc = pty.spawn(ZSH_PATH, launch.args ?? [], {
|
||||
name: 'xterm-256color',
|
||||
cols: 200,
|
||||
rows: 40,
|
||||
cwd: home,
|
||||
// Why ORCA_ORIG_ZDOTDIR: the user's config is read from the sandbox home, not the real one.
|
||||
env: { ...env, ...launch.env, ORCA_ORIG_ZDOTDIR: home, TERM: 'xterm-256color' }
|
||||
})
|
||||
const scan = createShellStartupOutputScanState()
|
||||
let resolveReady: ((signal: { postMarkerBytesObserved: boolean }) => void) | null = null
|
||||
const ready = new Promise<{ postMarkerBytesObserved: boolean }>((resolve) => {
|
||||
resolveReady = resolve
|
||||
})
|
||||
// The provider's cap (local-pty-shell-readiness-session.ts): a later marker releases the write.
|
||||
const cap = setTimeout(() => {
|
||||
resolveReady?.({ postMarkerBytesObserved: false })
|
||||
resolveReady = null
|
||||
}, STARTUP_COMMAND_READY_MAX_WAIT_MS)
|
||||
proc.onData((data) => {
|
||||
if (resolveReady && scanShellStartupOutput(scan, data).ready) {
|
||||
resolveReady({ postMarkerBytesObserved: true })
|
||||
resolveReady = null
|
||||
}
|
||||
})
|
||||
writeStartupCommandWhenShellReady(
|
||||
ready,
|
||||
proc,
|
||||
`printf '%s' '${text}' > '${out}'; printf ok > '${done}'`,
|
||||
() => {},
|
||||
{
|
||||
bracketedPasteSafe: isBracketedPasteSafeShell({
|
||||
shellName: 'zsh',
|
||||
waitsForShellReady: launch.supportsReadyMarker === true
|
||||
})
|
||||
}
|
||||
)
|
||||
const deadline = Date.now() + 20_000 + userConfigSeconds * 1000
|
||||
while (!existsSync(done) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
clearTimeout(cap)
|
||||
const ran = existsSync(out) ? readFileSync(out, 'utf8') : null
|
||||
const exited = new Promise<void>((resolve) => proc.onExit(() => resolve()))
|
||||
proc.kill()
|
||||
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 2000))])
|
||||
return ran
|
||||
}
|
||||
|
||||
describe.skipIf(!ZSH_PATH)('a multi-line launch line typed into zsh', () => {
|
||||
// Lines at the per-line budget, as many as the whole-line budget leaves room for.
|
||||
const perLine = TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES - 1
|
||||
const text = promptLines(
|
||||
Math.floor((ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES - 200) / (perLine + 1)),
|
||||
perLine
|
||||
)
|
||||
|
||||
it('arrives whole through the ready barrier', async () => {
|
||||
expect(await typeIntoZsh(text, 0)).toBe(text)
|
||||
}, 60_000)
|
||||
|
||||
it('arrives whole when the user config outlasts the barrier and the write lands early', async () => {
|
||||
expect(await typeIntoZsh(text, 3)).toBe(text)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell'
|
||||
|
||||
describe('naming the shell a local launch line is typed into', () => {
|
||||
it('takes the request’s shell first, then the setting, then SHELL, as the spawn does', () => {
|
||||
const base = { isRemote: false, platform: 'darwin' as const, envShell: '/bin/bash' }
|
||||
expect(
|
||||
nameLocalTypedLineShell({
|
||||
...base,
|
||||
shellOverride: '/opt/homebrew/bin/fish',
|
||||
defaultShellSetting: '/bin/zsh'
|
||||
})
|
||||
).toBe('fish')
|
||||
expect(nameLocalTypedLineShell({ ...base, defaultShellSetting: ' /bin/zsh ' })).toBe('zsh')
|
||||
expect(nameLocalTypedLineShell(base)).toBe('bash')
|
||||
expect(nameLocalTypedLineShell({ ...base, envShell: '' })).toBe('zsh')
|
||||
})
|
||||
|
||||
it('names none for a remote host, whose relay picks its own login shell', () => {
|
||||
expect(
|
||||
nameLocalTypedLineShell({ isRemote: true, platform: 'darwin', envShell: '/bin/zsh' })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('names none on Windows, where the pane may be cmd, PowerShell, Git Bash or WSL', () => {
|
||||
expect(
|
||||
nameLocalTypedLineShell({ isRemote: false, platform: 'win32', envShell: '/bin/zsh' })
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { basename } from 'node:path'
|
||||
|
||||
/**
|
||||
* The shell a local pane's startup line gets typed into, named before the spawn the way the spawn
|
||||
* picks it: the request's shell, the default-shell setting, then `SHELL`
|
||||
* (`ipc/pty/runtime/spawn-preflight.ts`, then the local or daemon launch plan).
|
||||
*
|
||||
* Undefined where the host cannot name it: a remote host, whose relay picks its own login shell,
|
||||
* and Windows, whose pane may be cmd, PowerShell, Git Bash or a WSL distro.
|
||||
*/
|
||||
export function nameLocalTypedLineShell(args: {
|
||||
isRemote: boolean
|
||||
shellOverride?: string
|
||||
defaultShellSetting?: string
|
||||
platform?: NodeJS.Platform
|
||||
envShell?: string
|
||||
}): string | undefined {
|
||||
if (args.isRemote || (args.platform ?? process.platform) === 'win32') {
|
||||
return undefined
|
||||
}
|
||||
const shellPath =
|
||||
args.shellOverride?.trim() ||
|
||||
args.defaultShellSetting?.trim() ||
|
||||
(args.envShell ?? process.env.SHELL) ||
|
||||
'/bin/zsh'
|
||||
return basename(shellPath).toLowerCase()
|
||||
}
|
||||
@@ -17,7 +17,10 @@ vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => '/tmp') }
|
||||
}))
|
||||
|
||||
function runtimeWithAgentLaunch(): {
|
||||
// The shell the host names for a local line; bash takes no multi-line line, zsh does.
|
||||
function runtimeWithAgentLaunch(
|
||||
options: { terminalDefaultShell?: string; connectionId?: string } = {}
|
||||
): {
|
||||
runtime: OrcaRuntimeService
|
||||
spawn: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
@@ -27,11 +30,13 @@ function runtimeWithAgentLaunch(): {
|
||||
store: { getSettings: () => Record<string, unknown> }
|
||||
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<unknown>
|
||||
}
|
||||
internal.store = { getSettings: () => ({}) }
|
||||
internal.store = {
|
||||
getSettings: () => ({ terminalDefaultShell: options.terminalDefaultShell ?? '/bin/bash' })
|
||||
}
|
||||
vi.spyOn(internal, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({
|
||||
id: 'wt-1',
|
||||
path: '/repo/app',
|
||||
connectionId: null,
|
||||
connectionId: options.connectionId ?? null,
|
||||
repo: null,
|
||||
folderWorkspace: null
|
||||
})
|
||||
@@ -83,6 +88,37 @@ describe('a terminal create that is handed a launch prompt', () => {
|
||||
expect(onStartupPromptCarry).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('carries a short-lined multi-line prompt on a local zsh line, so the agent starts with it', async () => {
|
||||
const { runtime, spawn } = runtimeWithAgentLaunch({ terminalDefaultShell: '/bin/zsh' })
|
||||
const onStartupPromptCarry = vi.fn()
|
||||
|
||||
await runtime.createTerminal('id:wt-1', {
|
||||
startupAgent: 'claude',
|
||||
startupPrompt: 'summarize the diff\nthen list the risks',
|
||||
onStartupPromptCarry
|
||||
})
|
||||
|
||||
expect(spawnedCommand(spawn)).toContain('summarize the diff\nthen list the risks')
|
||||
expect(onStartupPromptCarry).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('starts clean on a remote host, whose shell this host cannot name', async () => {
|
||||
const { runtime, spawn } = runtimeWithAgentLaunch({
|
||||
terminalDefaultShell: '/bin/zsh',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
const onStartupPromptCarry = vi.fn()
|
||||
|
||||
await runtime.createTerminal('id:wt-1', {
|
||||
startupAgent: 'claude',
|
||||
startupPrompt: 'summarize the diff\nthen list the risks',
|
||||
onStartupPromptCarry
|
||||
})
|
||||
|
||||
expect(spawnedCommand(spawn)).not.toContain('summarize')
|
||||
expect(onStartupPromptCarry).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('starts Hermes clean instead of refusing when its env budget cannot hold the prompt', async () => {
|
||||
const { runtime, spawn } = runtimeWithAgentLaunch()
|
||||
const onStartupPromptCarry = vi.fn()
|
||||
|
||||
@@ -21,6 +21,7 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtim
|
||||
import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution'
|
||||
import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup'
|
||||
import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry'
|
||||
import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell'
|
||||
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
|
||||
import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry'
|
||||
|
||||
@@ -232,7 +233,14 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
|
||||
windowsShellOverride: opts.shellOverride,
|
||||
sessionOptions: this.toAgentSessionOptions(opts.launchPreferences)
|
||||
}),
|
||||
opts.startupPrompt ?? ''
|
||||
opts.startupPrompt ?? '',
|
||||
nameLocalTypedLineShell({
|
||||
isRemote,
|
||||
...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}),
|
||||
...(settings.terminalDefaultShell
|
||||
? { defaultShellSetting: settings.terminalDefaultShell }
|
||||
: {})
|
||||
})
|
||||
)
|
||||
if (!startupPlan) {
|
||||
// Why: an explicit agent that yields no plan would otherwise spawn a bare
|
||||
|
||||
@@ -86,6 +86,8 @@ export type RuntimeStore = {
|
||||
agentDefaultArgs?: GlobalSettings['agentDefaultArgs']
|
||||
agentDefaultEnv?: GlobalSettings['agentDefaultEnv']
|
||||
terminalWindowsShell?: GlobalSettings['terminalWindowsShell']
|
||||
// Read by the launch-line carry rule to name the shell a local line is typed into.
|
||||
terminalDefaultShell?: GlobalSettings['terminalDefaultShell']
|
||||
floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled']
|
||||
agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled']
|
||||
terminalCopyTrimsGutter?: GlobalSettings['terminalCopyTrimsGutter']
|
||||
|
||||
@@ -109,10 +109,10 @@ describe('buildWorktreeStartupForAgent host resolution', () => {
|
||||
})
|
||||
|
||||
describe('buildWorktreeStartupForAgent prompt carry', () => {
|
||||
const build = (onPromptCarry?: (carried: boolean) => void) =>
|
||||
const build = (onPromptCarry?: (carried: boolean) => void, terminalDefaultShell = '/bin/bash') =>
|
||||
buildWorktreeStartupForAgent({
|
||||
repo: makeRepo({}),
|
||||
settings,
|
||||
settings: Object.assign({}, settings, { terminalDefaultShell }),
|
||||
agent: 'claude',
|
||||
prompt: 'summarize the diff\nthen list the risks',
|
||||
getLaunchPlatform: () => 'linux',
|
||||
@@ -129,6 +129,14 @@ describe('buildWorktreeStartupForAgent prompt carry', () => {
|
||||
expect(onPromptCarry).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('carries a short-lined multi-line prompt on a local zsh line, as main typed it', () => {
|
||||
const onPromptCarry = vi.fn()
|
||||
const result = build(onPromptCarry, '/bin/zsh')
|
||||
|
||||
expect(result.startup.command).toContain('summarize the diff\nthen list the risks')
|
||||
expect(onPromptCarry).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('keeps folding the prompt for a caller that delivers nothing afterwards', () => {
|
||||
// `orca worktree create --prompt` has no post-start paste of its own for an argv agent.
|
||||
expect(build().startup.command).toContain('summarize the diff')
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selectio
|
||||
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
|
||||
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup'
|
||||
import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry'
|
||||
import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell'
|
||||
import {
|
||||
detectInstalledAgentsWithShellPathHydration,
|
||||
detectRemoteAgents
|
||||
@@ -152,7 +153,16 @@ export function buildWorktreeStartupForAgent(
|
||||
const prompt = environment.prompt ?? ''
|
||||
let startupPlan: ReturnType<typeof buildAgentStartupPlan>
|
||||
if (environment.onPromptCarry) {
|
||||
const offered = planStartupWithPromptCandidate(planInputs, prompt)
|
||||
const offered = planStartupWithPromptCandidate(
|
||||
planInputs,
|
||||
prompt,
|
||||
nameLocalTypedLineShell({
|
||||
isRemote: repoIsRemote(repo),
|
||||
...(settings.terminalDefaultShell
|
||||
? { defaultShellSetting: settings.terminalDefaultShell }
|
||||
: {})
|
||||
})
|
||||
)
|
||||
startupPlan = offered.plan
|
||||
if (startupPlan && prompt.trim()) {
|
||||
environment.onPromptCarry(offered.promptCarried)
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasControlByte,
|
||||
planStartupWithPromptCandidate,
|
||||
TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES
|
||||
TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES,
|
||||
ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES
|
||||
} from './startup-line-prompt-carry'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
import {
|
||||
@@ -10,14 +11,26 @@ import {
|
||||
RUNTIME_CAPABILITIES
|
||||
} from './protocol-version'
|
||||
|
||||
function offer(agent: TuiAgent, prompt: string, extra: { cmdOverride?: string } = {}) {
|
||||
function offer(
|
||||
agent: TuiAgent,
|
||||
prompt: string,
|
||||
extra: { cmdOverride?: string; shellName?: string | undefined } = {}
|
||||
) {
|
||||
return planStartupWithPromptCandidate(
|
||||
{
|
||||
agent,
|
||||
cmdOverrides: extra.cmdOverride ? { [agent]: extra.cmdOverride } : {},
|
||||
platform: 'darwin'
|
||||
},
|
||||
prompt
|
||||
prompt,
|
||||
extra.shellName
|
||||
)
|
||||
}
|
||||
|
||||
/** `count` prompt lines of `bytes` each, as a multi-line source-control prompt is. */
|
||||
function linesOf(count: number, bytes: number): string {
|
||||
return Array.from({ length: count }, (_unused, index) => `${index}:`.padEnd(bytes, 'x')).join(
|
||||
'\n'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -119,6 +132,49 @@ describe('whether a launch prompt rides the typed startup line', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Pinned live by startup-line-typed-length.live-shell.test.ts; the refusals below were measured on
|
||||
// macOS through the same write: a 1.1 KB single line lost to zsh's late write, every multi-line line
|
||||
// lost to bash 3.2, and to fish whenever its config outlasted the ready barrier.
|
||||
describe('a multi-line prompt typed into a shell the host names', () => {
|
||||
it('rides a zsh line as main typed it when each line is short, so the agent starts with it', () => {
|
||||
const prompt = linesOf(21, 100)
|
||||
const { plan, promptCarried } = offer('claude', prompt, { shellName: 'zsh' })
|
||||
expect(promptCarried).toBe(true)
|
||||
expect(plan?.launchCommand).toContain(prompt)
|
||||
})
|
||||
|
||||
it('keeps a zsh line off the launch command when any one line exceeds the per-line budget', () => {
|
||||
const prompt = `${linesOf(3, 100)}\n${'y'.repeat(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1)}`
|
||||
expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a zsh line off the launch command past the whole-line budget', () => {
|
||||
const prompt = linesOf(Math.ceil(ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES / 400) + 1, 400)
|
||||
expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a long single line off a zsh launch command, which a late write truncates', () => {
|
||||
const prompt = promptForClaudeLineOf(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1)
|
||||
expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false)
|
||||
})
|
||||
|
||||
it.each([['bash'], ['fish'], ['pwsh'], [undefined]])(
|
||||
'never types a multi-line line into %s',
|
||||
(shellName) => {
|
||||
const { plan, promptCarried } = offer('claude', linesOf(3, 50), { shellName })
|
||||
expect(promptCarried).toBe(false)
|
||||
expect(plan?.launchCommand).not.toContain('0:')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['TAB', 'see\tthis\nnext line'],
|
||||
['CR', 'first\r\nsecond']
|
||||
])('never types a %s-bearing multi-line prompt into zsh', (_label, prompt) => {
|
||||
expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the capability clients gate a prompted launch on', () => {
|
||||
it('is advertised by every host that applies the typed-line rule', () => {
|
||||
// An older host folds any prompt into the typed line, so its absence is the gate.
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
* byte as a key, a canonical-mode write truncates a line
|
||||
* past MAX_CANON (1024 on macOS), and cmd caps a line at 8191. Decided here, where the line exists,
|
||||
* so the answer is a fact about what was built rather than a prediction of it.
|
||||
*
|
||||
* `startup-line-typed-length.live-shell.test.ts` types lines through Orca's own ready barrier and
|
||||
* submission into real shells, including one whose config outlasts the barrier's 1.5 s cap so the
|
||||
* line lands while the terminal is still line-buffered. Only zsh read a multi-line line whole in
|
||||
* both cases, and only while each of its lines stayed short; a long single line was lost there.
|
||||
*/
|
||||
|
||||
import { TUI_AGENT_CONFIG } from './tui-agent-config'
|
||||
@@ -15,9 +20,12 @@ import { buildAgentStartupPlan, type AgentStartupPlan } from './tui-agent-startu
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
|
||||
/** Half of macOS MAX_CANON: a single typed line this long survived every canonical-mode write
|
||||
* measured, and 1 KiB did not. */
|
||||
* measured, and 1 KiB did not. Also the cap on each line of a multi-line one. */
|
||||
export const TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES = 512
|
||||
|
||||
/** A multi-line line typed into zsh: the largest measured intact behind a late write (8 KiB). */
|
||||
export const ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES = 8192
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function typedLineBytes(line: string): number {
|
||||
@@ -27,6 +35,8 @@ function typedLineBytes(line: string): number {
|
||||
export function startupLineCarriesPrompt(args: {
|
||||
agent: TuiAgent
|
||||
withPrompt: AgentStartupPlan | null
|
||||
/** The shell the line is typed into, when the host can name it before the spawn. */
|
||||
shellName?: string
|
||||
}): boolean {
|
||||
const { withPrompt } = args
|
||||
if (!withPrompt || withPrompt.followupPrompt !== null) {
|
||||
@@ -38,7 +48,25 @@ export function startupLineCarriesPrompt(args: {
|
||||
return true
|
||||
}
|
||||
const line = withPrompt.launchCommand
|
||||
return !hasControlByte(line) && typedLineBytes(line) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES
|
||||
if (!hasControlByte(line)) {
|
||||
return typedLineBytes(line) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES
|
||||
}
|
||||
return args.shellName === 'zsh' && zshReadsMultiLineWhole(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* zsh takes a multi-line line as one bracketed paste, and a write that beats its line editor still
|
||||
* reaches it line by line, so each line must fit the terminal's line buffer. bash 3.2 has no
|
||||
* bracketed paste, and fish drops a paste written before its reader is up.
|
||||
*/
|
||||
function zshReadsMultiLineWhole(line: string): boolean {
|
||||
const lines = line.split('\n')
|
||||
return (
|
||||
lines.every(
|
||||
(part) =>
|
||||
!hasControlByte(part) && typedLineBytes(part) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES
|
||||
) && typedLineBytes(line) <= ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES
|
||||
)
|
||||
}
|
||||
|
||||
/** Any C0 byte or DEL, not just CR/LF: no quoter escapes them, and a single-line command is written
|
||||
@@ -64,11 +92,18 @@ type StartupPlanInputs = Omit<
|
||||
*/
|
||||
export function planStartupWithPromptCandidate(
|
||||
inputs: StartupPlanInputs,
|
||||
prompt: string
|
||||
prompt: string,
|
||||
shellName?: string
|
||||
): { plan: AgentStartupPlan | null; promptCarried: boolean } {
|
||||
if (prompt.trim()) {
|
||||
const withPrompt = buildAgentStartupPlan({ ...inputs, prompt, allowEmptyPromptLaunch: true })
|
||||
if (startupLineCarriesPrompt({ agent: inputs.agent, withPrompt })) {
|
||||
if (
|
||||
startupLineCarriesPrompt({
|
||||
agent: inputs.agent,
|
||||
withPrompt,
|
||||
...(shellName ? { shellName } : {})
|
||||
})
|
||||
) {
|
||||
return { plan: withPrompt, promptCarried: true }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user