fix(launch-prompt): final review: paste where nothing can confirm, one foreground read, Windows CI

On a Windows host nothing proves the agent is in front, so a prompt an AI
button, a note or Fix checks carried on the line could never be
confirmed: for an agent with no hook the action never ran and a notice
said "couldn't confirm" on a launch that worked. The rule now gives a
caller whose paste main used that paste wherever the host cannot prove
the agent (final review P1-1), and a new tab's unconfirmed result,
which nothing waits on, is silent, as main reported nothing.

The receipt read the terminal twice, by foreground group and by a cached
process name. #24257's judgeTerminalForeground now tells the launched
agent named on its command line ('launched-agent') from any other
process that is not a shell ('other'); its paste guard counts both as
the agent, as before, and the receipt asks only it: delivered on either
once the agent looks ready, exited only after 'launched-agent' then the
shell. Reads back off from 250 ms to 2 s (about 18 per 30 s wait) and
run once more at the ready signal.

unstageableLine now comes from the rule's on-line outcome for every
caller whose paste main used, so the work-item route sends it too. A
draft is judged by the one measured Windows verdict. Command Code's
seeded status row no longer reads as its turn.

Windows CI (package (windows), red since d8cfcf397a): the cmd launch-file
line has two arguments, Claude's folder grant and the whole pointer, not
one; and a non-ASCII prompt piped into cmd is decoded in its startup code
page, so the rule leaves non-ASCII on cmd to main's delivery and the
piped harness no longer claims to measure it.

Also: stale comments from the old paste rule, the race and folder-mode
wording, and an unreachable half of a refusal message.
This commit is contained in:
Brennan Benson
2026-10-02 15:52:51 -07:00
parent ae59440a6a
commit f29abc5536
43 changed files with 511 additions and 281 deletions
@@ -13,8 +13,9 @@
* argv has no readiness race, so it carries the prompt wherever the agent's CLI takes a prompt
* argument (`agentPromptRidesLaunchCommand`). Where it rides is decided once, where the line is built
* (`carryLaunchPrompt`): on the line, which the host stages when it is too long or multi-line to
* type, or in a launch file the line points at. Only a prompt that needs a file an agent is not
* known to read is left for the paste, and the surface reports that back.
* type, or in a launch file the line points at. A prompt the rule leaves for the paste (an agent
* not known to read a launch file, or a host that cannot write its staging folder) is pasted, and
* the surface reports that back.
*/
import type {
@@ -26,8 +26,8 @@ export type AgentLaunchSurfaceFactory = {
agent: TuiAgent
options?: Readonly<Record<string, unknown>>
/** Offered only for an agent whose CLI takes the prompt on argv. It rides the launch command
* unless it needs a launch file the agent is not known to read; `promptRodeLaunchCommand`
* reports which happened. */
* unless `carryLaunchPrompt` leaves it for the paste; `promptRodeLaunchCommand` reports which
* happened. */
startupPrompt?: string
/** Replaces the settings default for this launch only; `null` means no arguments at all. */
agentArgs?: string | null
@@ -60,8 +60,8 @@ export type AgentLaunchSurfaceFactory = {
* Writes the launch text into a terminal agent's live PTY, answering whether it landed.
*
* The other half of `startupPrompt`, for the cases the launch command cannot serve: a
* `stdin-after-start` agent, whose CLI takes no prompt argument; a prompt needing a launch file
* the agent is not known to read; and a reused terminal, whose process was already running before this launch existed. `false` for every failure, on the same rule the structured
* `stdin-after-start` agent, whose CLI takes no prompt argument; a prompt `carryLaunchPrompt`
* leaves for the paste; and a reused terminal, whose process was already running before this launch existed. `false` for every failure, on the same rule the structured
* twin follows — a launch whose agent is running must not fail because its text did not land.
*/
deliverTerminalPrompt?(args: {
@@ -127,8 +127,8 @@ describePosix('daemon startup command staging', () => {
)
})
// Why (stack QA 2a): the plan saw the same unusable folder and chose main's delivery, the line
// typed as is; refusing it made a 5-line prompt worse than main.
// Why (stack QA 2a): main's delivery for an unusable folder is the line typed as is; refusing it
// made a 5-line prompt worse than main.
it('types an agent line as is when the temp folder is unusable', async () => {
vi.stubEnv('TMPDIR', join(tempDir, 'missing'))
const command = `claude 'one\ntwo'`
@@ -1,19 +1,20 @@
import { getPtyIpc } from '../../pty-host-bindings'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
import type { LaunchedAgentForeground } from '../../../runtime/launched-agent-foreground'
import type { TerminalForegroundVerdict } from '../../../runtime/terminal-foreground-group'
/** The renderer's launch-prompt receipt asks the question #24257's crash guard asks before a paste. */
/** The renderer's launch-prompt receipt asks #24257's crash-guard question, keeping the launched
* agent named on its command line apart from any other process that is not the shell. */
export function installLaunchedAgentForegroundIpcHandler(
runtime: OrcaRuntimeService | undefined
): void {
getPtyIpc().handle(
'pty:readLaunchedAgentForeground',
async (_event, args: { id: string; agent: string }): Promise<LaunchedAgentForeground> => {
async (_event, args: { id: string; agent: string }): Promise<TerminalForegroundVerdict> => {
if (!runtime || typeof args?.id !== 'string' || !isTuiAgent(args.agent)) {
return 'unknown'
}
return runtime.readLaunchedAgentForeground(args.id, args.agent)
return runtime.readTerminalForegroundVerdict(args.id, args.agent)
}
)
}
@@ -33,7 +33,7 @@ async function spawnOptionsFor(
}
describe('renderer pty spawn: launch file', () => {
const command = `claude 'The full task is in the file "${LAUNCH_FILE.placeholder}".'`
const command = `claude 'The full task is in the file \`${LAUNCH_FILE.placeholder}\`.'`
it('hands the provider the launch file its command names', async () => {
const options = await spawnOptionsFor(
@@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence'
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
import type * as TerminalForegroundGroup from './terminal-foreground-group'
import { readLaunchedAgentForeground } from './launched-agent-foreground'
import {
readLaunchedAgentForeground,
readTerminalForegroundVerdict
} from './launched-agent-foreground'
const paneTerminal = vi.hoisted(() => {
const state: { rows: ProcessTableRow[] | null } = { rows: null }
@@ -70,6 +73,23 @@ describe('the relay’s foreground group as proof of a launched agent', () => {
).resolves.toBe('agent')
})
// Why: the receipt counts only the agent named as having run; the paste guard keeps any process.
it.each<[string, 'launched-agent' | 'other']>([
['claude', 'launched-agent'],
['node', 'other']
])('tells the launched agent from another process (%s) by name', async (name, verdict) => {
const controller = {
...controllerAnswering(async () => claudeInGroup(1_500)),
getForegroundProcess: async () => name
}
await expect(
readTerminalForegroundVerdict(controller, POSIX_SSH, 'pty-1', 'claude')
).resolves.toBe(verdict)
await expect(
readLaunchedAgentForeground(controller, POSIX_SSH, 'pty-1', 'claude')
).resolves.toBe('agent')
})
it('takes the relay’s name over a capture reused from before the read was asked for', async () => {
const controller = controllerAnswering(async () => claudeInGroup(1_500))
+28 -6
View File
@@ -7,7 +7,11 @@ import { isShellProcess } from '../../shared/shell-process-detection'
import type { TuiAgent } from '../../shared/tui-agent'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
import { judgeTerminalForeground, readTerminalProcessRows } from './terminal-foreground-group'
import {
judgeTerminalForeground,
readTerminalProcessRows,
type TerminalForegroundVerdict
} from './terminal-foreground-group'
/**
* What holds a launched agent's terminal: the agent (anything but the pane's shell), the shell
@@ -44,6 +48,21 @@ function isLaunchedAgent(processName: string, agent: TuiAgent): boolean {
* there proves the agent. A Windows host can still prove its shell alone (`confirmShellForeground`).
*/
export async function readLaunchedAgentForeground(
...args: Parameters<typeof readTerminalForegroundVerdict>
): Promise<LaunchedAgentForeground> {
return asLaunchedAgentForeground(await readTerminalForegroundVerdict(...args))
}
/** The paste guard's answer: any process but the shell counts as the agent. */
export function asLaunchedAgentForeground(
verdict: TerminalForegroundVerdict
): LaunchedAgentForeground {
return verdict === 'launched-agent' || verdict === 'other' ? 'agent' : verdict
}
/** The same read, telling the launched agent named on its command line from any other process
* that is not the shell: only the first proves the agent itself ran. */
export async function readTerminalForegroundVerdict(
controller: Pick<
RuntimePtyController,
'getForegroundProcess' | 'confirmShellForeground' | 'inspectProcess' | 'listProcesses'
@@ -51,7 +70,7 @@ export async function readLaunchedAgentForeground(
host: { remote: boolean; windows: boolean },
ptyId: string,
agent: TuiAgent
): Promise<LaunchedAgentForeground> {
): Promise<TerminalForegroundVerdict> {
if (!controller) {
return 'unknown'
}
@@ -76,16 +95,19 @@ export async function readLaunchedAgentForeground(
evidence.processName &&
isLaunchedAgent(evidence.processName, agent)
) {
return 'agent'
return 'launched-agent'
}
const foreground = await controller.getForegroundProcess(ptyId)
if (!foreground) {
return 'unknown'
}
return isLaunchShell(foreground) &&
if (
isLaunchShell(foreground) &&
!isExpectedAgentProcess(foreground, TUI_AGENT_CONFIG[agent].expectedProcess)
? 'shell'
: 'agent'
) {
return 'shell'
}
return isLaunchedAgent(foreground, agent) ? 'launched-agent' : 'other'
} catch {
return 'unknown'
}
@@ -36,9 +36,11 @@ import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisio
import { readFreshComposerHold } from './launched-agent-composer-readiness'
import { buildTerminalWaitText } from './terminal-wait-tail-state'
import {
readLaunchedAgentForeground,
asLaunchedAgentForeground,
readTerminalForegroundVerdict,
type LaunchedAgentForeground
} from './launched-agent-foreground'
import type { TerminalForegroundVerdict } from './terminal-foreground-group'
export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListManagedWorktrees {
async activateManagedWorktree(
@@ -251,10 +253,20 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
}
/** What holds the terminal a launch started its agent in, read fresh from the execution host. */
readLaunchedAgentForeground(ptyId: string, agent: TuiAgent): Promise<LaunchedAgentForeground> {
async readLaunchedAgentForeground(
ptyId: string,
agent: TuiAgent
): Promise<LaunchedAgentForeground> {
return asLaunchedAgentForeground(await this.readTerminalForegroundVerdict(ptyId, agent))
}
readTerminalForegroundVerdict(
ptyId: string,
agent: TuiAgent
): Promise<TerminalForegroundVerdict> {
const pty = this.ptysById.get(ptyId)
const remote = !!pty?.connectionId
return readLaunchedAgentForeground(
return readTerminalForegroundVerdict(
this.ptyController,
// A local WSL pane still runs on a Windows host, whose process reads cannot see into it.
{
@@ -26,7 +26,6 @@ import {
launchPromptNeedsPasteRefusal,
windowsDraftRefusal
} from '../../shared/launch-prompt-carry'
import { resolveStartupShell } from '../../shared/tui-agent-startup-shell'
import { probedThisOrcaLaunchHost } from './this-orca-launch-host'
import type { RuntimeTerminalCreate } from '../../shared/runtime-types'
import type {
@@ -174,10 +173,7 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe
if (request.promptDelivery === 'draft') {
startup = buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' })
if (!startup) {
const refusal = windowsDraftRefusal(
request.prompt ?? '',
resolveStartupShell(startupArgs.platform, startupArgs.shell)
)
const refusal = windowsDraftRefusal(request.agent, startupArgs.platform)
throw new Error(refusal ?? 'agent_session_identity_required')
}
} else {
@@ -126,8 +126,8 @@ export function agentLaunchSurfaceFactory(
// The agent id is not a shell command — `cursor` is the desktop app, its CLI is
// `cursor-agent` — so the runtime builds the configured launcher.
startupAgent: agent,
// Offered to that launcher's startup plan; it rides unless it needs a launch file the agent
// is not known to read, and the runtime reports which so that prompt is pasted once ready.
// Offered to that launcher's startup plan; it rides unless `carryLaunchPrompt` leaves it for
// the paste, and the runtime reports which so that prompt is pasted once ready.
...(startupPrompt
? {
startupPrompt,
@@ -9,8 +9,9 @@
*
* This is the half the launch command cannot serve. An argv agent's prompt rides that command, on
* the line or in a launch file (`carryLaunchPrompt`), so no readiness race exists for it. What
* reaches here is a `stdin-after-start` agent, whose CLI accepts no such argument; a prompt that
* needs a launch file its agent is not known to read; and a reused terminal, whose process was
* reaches here is a `stdin-after-start` agent, whose CLI accepts no such argument; a prompt the rule
* leaves for the paste (an agent not known to read a launch file, or a host that cannot write its
* staging folder); and a reused terminal, whose process was
* already running before this launch existed. Readiness is `waitForLaunchedAgentComposer`, the one
* the worker start uses.
*
@@ -67,7 +67,7 @@ export function agentLaunchWorkspaceFactory(
...(startupAgent ? { startupAgent } : {}),
// Only ever set alongside `startupAgent`, which is what the create requires; the
// executor offers it only to an agent that takes its prompt on argv, and it rides
// unless it needs a launch file the agent is not known to read.
// unless `carryLaunchPrompt` leaves it for the paste.
...(startupPrompt ? { startupPrompt } : {})
},
{
@@ -51,8 +51,8 @@ export type RuntimeManagedWorktreeCreateArgs = {
startupAgent?: TuiAgent
startupLaunchPreferences?: AgentLaunchPreferences
startupPrompt?: string
/** Main-internal: set by a caller that delivers an uncarried `startupPrompt` itself (one needing a
* launch file the agent is not known to read); reports whether the prompt rode the command. */
/** Main-internal: set by a caller that delivers an uncarried `startupPrompt` itself (one
* `carryLaunchPrompt` leaves for the paste); reports whether the prompt rode the command. */
onStartupPromptCarry?: (carried: boolean) => void
/** Per-launch inputs used when `startupAgent` is the created terminal surface. */
startupAgentArgs?: string | null
@@ -27,7 +27,7 @@ describe('who holds a pane’s terminal, by its foreground process group', () =>
[
'the agent the launch line ran',
pane(200, [{ pid: 200, ppid: 101, pgid: 200, stat: 'S+', command: '/opt/bin/claude' }]),
'agent'
'launched-agent'
],
// A tcsh or nu launch line, or a wrapper script that does not `exec` its agent.
[
@@ -36,7 +36,7 @@ describe('who holds a pane’s terminal, by its foreground process group', () =>
{ pid: 200, ppid: 101, pgid: 200, stat: 'S+', command: '/bin/sh /tmp/orca-launch/run.sh' },
{ pid: 201, ppid: 200, pgid: 200, stat: 'S+', command: '/opt/bin/claude' }
]),
'agent'
'launched-agent'
],
[
'an agent it cannot recognize behind a bash wrapper',
@@ -44,7 +44,9 @@ describe('who holds a pane’s terminal, by its foreground process group', () =>
{ pid: 200, ppid: 101, pgid: 200, stat: 'S+', command: 'bash ./start-agent.sh' },
{ pid: 201, ppid: 200, pgid: 200, stat: 'S+', command: 'node /opt/agent/cli.js' }
]),
'agent'
// Not named as the launched agent: the paste guard still counts it, the receipt's exit
// verdict does not.
'other'
],
// Between its commands a wrapper is only shells, and typing into it could run the text.
[
+12 -8
View File
@@ -72,6 +72,13 @@ function isShellCommand(command: string): boolean {
return isShellProcess(executable.split('/').pop() ?? executable)
}
/**
* What holds a terminal: `launched-agent` when a member names the launched agent on its command
* line, `other` when a member is not a shell (an agent behind a wrapper that did not `exec` it, an
* unrecognized command override, or a startup file's own command), `shell` when every member is.
*/
export type TerminalForegroundVerdict = 'launched-agent' | 'other' | 'shell' | 'unknown'
/**
* The terminal's foreground process group decides. The launched agent among its members, or any
* member that is not a shell, is the agent: that finds it behind a wrapper that did not `exec` it,
@@ -83,7 +90,7 @@ export function judgeTerminalForeground(
rows: readonly ProcessTableRow[],
rootPid: number,
agent: TuiAgent
): 'agent' | 'shell' | 'unknown' {
): TerminalForegroundVerdict {
const root = rows.find((row) => row.pid === rootPid)
const foregroundGroup = root?.tpgid
if (foregroundGroup === undefined || foregroundGroup <= 0) {
@@ -93,11 +100,8 @@ export function judgeTerminalForeground(
if (front.length === 0) {
return 'unknown'
}
return front.some(
(row) =>
recognizeAgentProcessFromCommandLine(row.command)?.agent === agent ||
!isShellCommand(row.command)
)
? 'agent'
: 'shell'
if (front.some((row) => recognizeAgentProcessFromCommandLine(row.command)?.agent === agent)) {
return 'launched-agent'
}
return front.some((row) => !isShellCommand(row.command)) ? 'other' : 'shell'
}
+6 -2
View File
@@ -129,8 +129,12 @@ export type PtyApi = {
}
) => Promise<TerminalProcessInspection>
confirmForegroundProcess: (id: string) => Promise<string | null>
/** What holds a launched agent's terminal, read fresh on its execution host (#24257's guard). */
readLaunchedAgentForeground: (id: string, agent: string) => Promise<'agent' | 'shell' | 'unknown'>
/** What holds a launched agent's terminal, read fresh on its execution host (#24257's guard),
* with the agent named on its command line kept apart from any other process. */
readLaunchedAgentForeground: (
id: string,
agent: string
) => Promise<'launched-agent' | 'other' | 'shell' | 'unknown'>
/** Local panes only; false for any other pane. */
isCodexOnSharedServer: (id: string) => Promise<boolean>
/** Runs the fix with the pane's own Codex; true only once verified. Local panes only. */
@@ -19,7 +19,7 @@ export const ptyStreamAndSerializationApi = {
readLaunchedAgentForeground: (
id: string,
agent: string
): Promise<'agent' | 'shell' | 'unknown'> =>
): Promise<'launched-agent' | 'other' | 'shell' | 'unknown'> =>
ipcRenderer.invoke('pty:readLaunchedAgentForeground', { id, agent }),
isCodexOnSharedServer: (id: string): Promise<boolean> =>
ipcRenderer.invoke('pty:isCodexOnSharedServer', { id }),
@@ -33,8 +33,7 @@ const mocks = vi.hoisted(() => {
state = empty()
},
readiness: vi.fn(),
readForeground: vi.fn(),
foregroundName: vi.fn()
readForeground: vi.fn()
}
})
@@ -61,18 +60,12 @@ describe('whether a prompt that rode the launch command reached the agent', () =
mocks.reset()
mocks.setState({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } })
mocks.readiness.mockReset().mockResolvedValue(true)
mocks.readForeground.mockReset().mockResolvedValue('agent')
mocks.foregroundName.mockReset().mockResolvedValue('claude')
mocks.readForeground.mockReset().mockResolvedValue('launched-agent')
// Timers go through globalThis at call time, so fake timers reach them.
vi.stubGlobal('window', {
setTimeout: (handler: () => void, ms: number) => globalThis.setTimeout(handler, ms),
clearTimeout: (timer: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(timer),
api: {
pty: {
readLaunchedAgentForeground: mocks.readForeground,
getForegroundProcess: mocks.foregroundName
}
}
api: { pty: { readLaunchedAgentForeground: mocks.readForeground } }
})
})
@@ -104,7 +97,7 @@ describe('whether a prompt that rode the launch command reached the agent', () =
vi.useFakeTimers()
let ready: () => void = () => {}
mocks.readiness.mockReturnValue(new Promise<void>((resolve) => (ready = resolve)))
mocks.readForeground.mockResolvedValueOnce('agent').mockResolvedValue('shell')
mocks.readForeground.mockResolvedValueOnce('launched-agent').mockResolvedValue('shell')
const pending = receipt()
mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
await vi.advanceTimersByTimeAsync(250)
@@ -122,23 +115,17 @@ describe('whether a prompt that rode the launch command reached the agent', () =
// The shell's startup file runs `sleep` in front, then the shell draws its prompt (and looks
// ready) before it runs the launch line, then the agent starts.
mocks.readForeground
.mockResolvedValueOnce('agent')
.mockResolvedValueOnce('other')
.mockResolvedValueOnce('shell')
.mockResolvedValueOnce('shell')
.mockResolvedValueOnce('shell')
.mockResolvedValue('agent')
mocks.foregroundName
.mockResolvedValueOnce('sleep')
.mockResolvedValueOnce('zsh')
.mockResolvedValueOnce('zsh')
.mockResolvedValueOnce('zsh')
.mockResolvedValue('claude')
.mockResolvedValue('launched-agent')
mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 50 } } })
const pending = receipt()
mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
await vi.advanceTimersByTimeAsync(100)
ready()
await vi.advanceTimersByTimeAsync(2_000)
await vi.advanceTimersByTimeAsync(10_000)
await expect(pending).resolves.toBe('delivered')
expect(mocks.readForeground.mock.calls.length).toBeGreaterThanOrEqual(5)
})
@@ -146,15 +133,40 @@ describe('whether a prompt that rode the launch command reached the agent', () =
it('is unconfirmed, never exited, when the shell stays in front with no sign the agent ran', async () => {
vi.useFakeTimers()
mocks.readForeground.mockResolvedValue('shell')
mocks.foregroundName.mockResolvedValue('zsh')
const pending = receipt()
mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
let settled: string | null = null
void pending.then((value) => (settled = value))
await vi.advanceTimersByTimeAsync(29_000)
expect(settled).toBeNull()
await vi.advanceTimersByTimeAsync(1_500)
await vi.advanceTimersByTimeAsync(2_500)
expect(settled).toBe('unconfirmed')
// Why: each read forks a `ps`; backed off, the whole wait costs a few dozen, not 120.
expect(mocks.readForeground.mock.calls.length).toBeLessThanOrEqual(20)
})
// Why: #24257's predicate finds an agent behind a wrapper or a command override as another
// process; once the agent looks ready that counts as delivered, as before.
it('is delivered on another process in front once the agent looks ready', async () => {
mocks.readForeground.mockResolvedValue('other')
const pending = receipt()
mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
await expect(pending).resolves.toBe('delivered')
})
it('does not count another process as the agent having run', async () => {
vi.useFakeTimers()
let ready: () => void = () => {}
mocks.readiness.mockReturnValue(new Promise<void>((resolve) => (ready = resolve)))
mocks.readForeground.mockResolvedValueOnce('other').mockResolvedValue('shell')
const pending = receipt()
mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
await vi.advanceTimersByTimeAsync(300)
ready()
let settled: string | null = null
void pending.then((value) => (settled = value))
await vi.advanceTimersByTimeAsync(5_000)
expect(settled).toBeNull()
})
it('is unconfirmed when the host cannot tell and no hook turn arrives', async () => {
@@ -208,4 +220,22 @@ describe('whether a prompt that rode the launch command reached the agent', () =
mocks.setState({ tabsByWorktree: { 'wt-1': [] } })
await expect(pending).resolves.toBe('not-delivered')
})
// Why (final review P3-3): the launch seeds Command Code's working row from its own prompt.
it('does not take Command Code’s seeded status row as its turn', async () => {
vi.useFakeTimers()
mocks.readiness.mockReturnValue(new Promise(() => {}))
mocks.readForeground.mockResolvedValue('shell')
const pending = waitForLaunchPromptReceipt({
tabId: 'tab-1',
agent: 'command-code',
launchedAt: 100
})
mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 150 } } })
let settled: string | null = null
void pending.then((value) => (settled = value))
await vi.advanceTimersByTimeAsync(1_000)
expect(settled).toBeNull()
})
})
@@ -1,8 +1,4 @@
import type { TuiAgent } from '../../../shared/tui-agent'
import {
isExpectedAgentProcess,
recognizeAgentProcess
} from '../../../shared/agent-process-recognition'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout'
import { parsePaneKey } from '../../../shared/stable-pane-id'
@@ -17,8 +13,9 @@ export type LaunchPromptReceipt = 'delivered' | 'not-delivered' | 'agent-exited'
/** How long a hook turn may still arrive after a read that could not tell. */
const HOOK_GRACE_AFTER_UNKNOWN_READ_MS = 2000
/** How often what holds the terminal is read until the receipt settles. */
const FOREGROUND_POLL_MS = 250
/** Reads start this often and back off: each forks one `ps` on the execution host. */
const FIRST_READ_INTERVAL_MS = 250
const MAX_READ_INTERVAL_MS = 2000
/** How long after the ready signal the shell may stay in front before the launch line runs: a
* slow shell startup can draw its prompt, and look ready, before it types the line. */
const LAUNCH_LINE_WAIT_AFTER_READY_MS = 30_000
@@ -33,10 +30,9 @@ function tabExists(state: AppState, tabId: string): boolean {
return Object.values(state.tabsByWorktree).some((tabs) => tabs?.some((tab) => tab.id === tabId))
}
async function readLaunchedAgentForeground(
ptyId: string,
agent: TuiAgent
): Promise<'agent' | 'shell' | 'unknown'> {
type TerminalForeground = 'launched-agent' | 'other' | 'shell' | 'unknown'
async function readTerminalForeground(ptyId: string, agent: TuiAgent): Promise<TerminalForeground> {
// A paired host's process reads do not reach this client.
if (isRemoteRuntimePtyId(ptyId)) {
return 'unknown'
@@ -44,30 +40,18 @@ async function readLaunchedAgentForeground(
return window.api.pty.readLaunchedAgentForeground(ptyId, agent).catch(() => 'unknown' as const)
}
/** Whether the host names the launched agent itself in front: proof it started, which a read of
* "not the shell" is not (a slow shell startup runs its own commands in front first). */
async function launchedAgentNamedInFront(ptyId: string, agent: TuiAgent): Promise<boolean> {
if (isRemoteRuntimePtyId(ptyId)) {
return false
}
const name = await window.api.pty.getForegroundProcess(ptyId).catch(() => null)
return (
name !== null &&
(recognizeAgentProcess(name)?.agent === agent ||
isExpectedAgentProcess(name, TUI_AGENT_CONFIG[agent].expectedProcess))
)
}
/**
* Whether a prompt that rode an agent's launch command reached the agent, proven the way #24257's
* crash guard proves an agent before a paste. Delivered on the agent's own hook turn on the tab, or
* a fresh read on the execution host that finds the launched agent, not its shell, in front of the
* terminal (`readLaunchedAgentForeground`). A ready signal never counts on its own: it only times
* that read, and bracketed paste turned off (`2004l`) revokes it. Not delivered when the PTY never
* spawns (a refused launch file included) or the tab closes. Agent exited only on proof it ran and
* ended: the agent itself was named in front, then the shell came back or the PTY exited. A shell
* in front before then is a launch line not yet run (a slow shell startup), so the reads go on.
* Unconfirmed when nothing proves either, as on a Windows host without hooks.
* Whether a prompt that rode an agent's launch command reached the agent, read with #24257's
* crash-guard predicate (`readLaunchedAgentForeground`), which tells the launched agent named on its
* command line from any other process that is not the shell. Delivered on the agent's own hook turn
* on the tab, or on a process other than the shell in front once the agent looks ready (one behind
* a wrapper or a command override counts); the ready signal never counts on its own, and bracketed
* paste turned off (`2004l`) revokes it. Not delivered when the PTY never spawns (a
* refused launch file included) or the tab closes. Agent exited only on proof it ran and ended:
* the launched agent was in front, then the shell came back or the PTY exited. A shell in front
* before then is a launch line not yet run (a slow shell startup), so the reads go on until the
* agent is in front, or for 30 s after the ready signal. Unconfirmed when nothing proves either,
* as on a Windows host without hooks.
*/
export function waitForLaunchPromptReceipt(args: {
tabId: string
@@ -92,8 +76,36 @@ export function waitForLaunchPromptReceipt(args: {
let agentSeen = false
const watchForeground = (ptyId: string): void => {
const config = TUI_AGENT_CONFIG[agent]
let readyAt: number | null = null
let ready = false
let graceArmed = false
let interval = FIRST_READ_INTERVAL_MS
let reading = false
let nextRead: number | null = null
const read = async (): Promise<void> => {
nextRead = null
reading = true
const foreground = await readTerminalForeground(ptyId, agent)
reading = false
if (settled) {
return
}
agentSeen ||= foreground === 'launched-agent'
if ((foreground === 'launched-agent' || foreground === 'other') && ready) {
finish('delivered')
} else if (foreground === 'shell' && agentSeen) {
finish('agent-exited')
} else {
if (foreground === 'unknown' && ready && !graceArmed) {
graceArmed = true
timers.push(
window.setTimeout(() => finish('unconfirmed'), HOOK_GRACE_AFTER_UNKNOWN_READ_MS)
)
}
nextRead = window.setTimeout(() => void read(), interval)
timers.push(nextRead)
interval = Math.min(interval * 2, MAX_READ_INTERVAL_MS)
}
}
void waitForAgentDraftInputReady(
ptyId,
resolveDraftPasteReadyTimeoutMs(agent),
@@ -102,42 +114,31 @@ export function waitForLaunchPromptReceipt(args: {
{ revokeOnBracketedPasteOff: true }
)
.then(() => {
readyAt = Date.now()
ready = true
timers.push(
window.setTimeout(() => finish('unconfirmed'), LAUNCH_LINE_WAIT_AFTER_READY_MS)
)
// Why: the read the ready signal times runs now, not at the backed-off interval.
interval = FIRST_READ_INTERVAL_MS
if (!reading && !settled) {
if (nextRead !== null) {
window.clearTimeout(nextRead)
}
void read().catch(() => finish('unconfirmed'))
}
})
.catch(() => finish('unconfirmed'))
const read = async (): Promise<void> => {
const [foreground, named] = await Promise.all([
readLaunchedAgentForeground(ptyId, agent),
agentSeen ? Promise.resolve(true) : launchedAgentNamedInFront(ptyId, agent)
])
if (settled) {
return
}
agentSeen ||= named && foreground === 'agent'
if (foreground === 'agent' && readyAt !== null) {
finish('delivered')
} else if (foreground === 'shell' && agentSeen) {
finish('agent-exited')
} else if (readyAt !== null && Date.now() - readyAt >= LAUNCH_LINE_WAIT_AFTER_READY_MS) {
finish('unconfirmed')
} else {
if (foreground === 'unknown' && readyAt !== null && !graceArmed) {
graceArmed = true
timers.push(
window.setTimeout(() => finish('unconfirmed'), HOOK_GRACE_AFTER_UNKNOWN_READ_MS)
)
}
timers.push(window.setTimeout(() => void read(), FOREGROUND_POLL_MS))
}
}
void read().catch(() => finish('unconfirmed'))
}
let scannedStatus: AppState['agentStatusByPaneKey'] | null = null
// Why: Command Code has no hook; its launch seeds a working row from the prompt itself (the new
// agent tab and workspace creation), which proves nothing, so only the foreground read does.
const statusRowProvesTurn = agent !== 'command-code'
const observe = (state: AppState): void => {
// Why the identity check: the store writes often and replaces this map only when it changes.
if (state.agentStatusByPaneKey !== scannedStatus) {
scannedStatus = state.agentStatusByPaneKey
if (hookReportedTurnOnTab(state, tabId, launchedAt)) {
if (statusRowProvesTurn && hookReportedTurnOnTab(state, tabId, launchedAt)) {
finish('delivered')
return
}
@@ -154,7 +154,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId)
const hostPublishesTab = isWebRuntimeSessionActive(runtimeEnvironmentId)
const { startupPlan, launchFile, pasteDraftAfterLaunch, submitPastedPrompt } =
const { startupPlan, launchFile, unstageableLine, pasteDraftAfterLaunch, submitPastedPrompt } =
planLaunchAgentStartupPrompt({
base: startupPlanBase,
prompt: trimmedPrompt,
@@ -274,11 +274,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
...(hasPrompt && pasteDraftAfterLaunch === null && promptDelivery !== 'draft'
? { launchPrompt: trimmedPrompt }
: {}),
// Why: main pasted a submit-after-ready prompt, so a line the host cannot stage is refused
// with the prompt to copy rather than typed raw. Temporary until these move to agent.launch.
...(hasPrompt && pasteDraftAfterLaunch === null && promptDelivery === 'submit-after-ready'
? { unstageableLine: 'refuse' as const }
: {}),
...(unstageableLine ? { unstageableLine } : {}),
// Why: Command Code has no prompt-submit hook, so a prompt its launch line submits seeds working.
...(agent === 'command-code' &&
hasPrompt &&
@@ -27,13 +27,13 @@ vi.mock('@/lib/telemetry', () => ({ track: vi.fn(), tuiAgentToAgentKind: vi.fn()
import { deliverNewTabLaunchPrompt } from './launch-agent-new-tab-prompt-delivery'
function deliver() {
function deliver(promptDelivery: 'submit-after-ready' | 'auto-submit' = 'submit-after-ready') {
return deliverNewTabLaunchPrompt({
worktreeId: 'wt-1',
tabId: 'tab-1',
agent: 'claude',
prompt: 'fix the failing checks',
promptDelivery: 'submit-after-ready',
promptDelivery,
pasteDraftAfterLaunch: null,
submitPastedPrompt: false,
promptInLaunchFile: false,
@@ -64,4 +64,19 @@ describe('the notice for a prompt the launch line carried', () => {
expect(mocks.notDelivered).toHaveBeenCalled()
expect(mocks.exited).not.toHaveBeenCalled()
})
// Why (final review P1-1): main reported nothing for a launch nothing waits on, and a Windows host
// without hooks can never confirm one, so that notice would follow every such launch.
it('stays silent for an unconfirmed prompt nothing waits on, and says so when an action waits', async () => {
mocks.receipt.mockResolvedValue('unconfirmed')
deliver('auto-submit')
await vi.waitFor(() => expect(mocks.receipt).toHaveBeenCalled())
await new Promise((resolve) => setTimeout(resolve, 0))
expect(mocks.unconfirmed).not.toHaveBeenCalled()
await expect(deliver('submit-after-ready')).resolves.toEqual({
delivered: false,
failureNotified: true
})
expect(mocks.unconfirmed).toHaveBeenCalledTimes(1)
})
})
@@ -77,6 +77,11 @@ export function deliverNewTabLaunchPrompt(args: {
showAgentLaunchExitedNotice({ agent, prompt })
return { delivered: false, failureNotified: true }
case 'unconfirmed':
// Why silent when nothing waits on it: main reported nothing for such a launch, and
// a host that cannot prove the agent (Windows without hooks) would raise it every time.
if (promptDelivery !== 'submit-after-ready') {
return { delivered: false, failureNotified: false }
}
// The agent may have the prompt: say so, and never invite pasting it a second time.
showAgentLaunchPromptUnconfirmedNotice({ agent, prompt })
return { delivered: false, failureNotified: true }
@@ -5,13 +5,15 @@ import {
type AgentStartupPlan,
type AgentStartupPlanInputs
} from '@/lib/tui-agent-startup'
import type { LaunchFile } from '../../../shared/launch-prompt-file'
import type { LaunchFile, UnstageableLine } from '../../../shared/launch-prompt-file'
import type { LaunchHost } from '../../../shared/launch-host'
export type LaunchAgentStartupPromptPlan = {
startupPlan: AgentStartupPlan | null
/** Written by the host before it types the launch line naming it. */
launchFile?: LaunchFile
/** The rule's wish for a line the host cannot stage, sent with the spawn. */
unstageableLine?: UnstageableLine
/** Text to paste once the TUI is ready; null when the launch command already carries it. */
pasteDraftAfterLaunch: string | null
submitPastedPrompt: boolean
@@ -84,8 +86,14 @@ export function planLaunchAgentStartupPrompt(args: {
}
switch (planned.carry) {
case 'none':
case 'on-line':
return { startupPlan: planned.plan, pasteDraftAfterLaunch: null, submitPastedPrompt: false }
case 'on-line':
return {
startupPlan: planned.plan,
...(planned.unstageableLine ? { unstageableLine: planned.unstageableLine } : {}),
pasteDraftAfterLaunch: null,
submitPastedPrompt: false
}
case 'launch-file':
return {
startupPlan: planned.plan,
@@ -126,6 +126,35 @@ describe('buildDirectWorkItemAgentStartupPlan', () => {
})
})
// Why (final review P2-2): main pasted a work item's submitted prompt, so like an AI button its
// line asks the host to refuse, not type raw, when the host cannot stage it.
describe('a work item whose prompt rides the launch line', () => {
it('asks the host to refuse a line it cannot stage, through to the queued startup', () => {
const preparation = buildDirectWorkItemAgentStartupPlan({
host: {
paired: false,
provesAgentInFront: true,
takesLaunchFile: true,
windowsPaneShell: null
},
agent: 'codex',
draftContent: `Review issue 42\n${'x'.repeat(2_000)}`,
promptDelivery: 'submit-after-ready',
settings,
launchPlatform: 'darwin'
})
expect(preparation.unstageableLine).toBe('refuse')
const opts = buildDirectWorkItemStartupOpts(
'codex',
preparation.startupPlan,
'task_page',
undefined,
preparation
)
expect(opts.startup?.unstageableLine).toBe('refuse')
})
})
describe('notifyDirectWorkItemAgentStartTimeout', () => {
it('toasts the paste hint and records the startup timeout', () => {
notifyDirectWorkItemAgentStartTimeout('codex', true)
@@ -18,7 +18,7 @@ import {
import { translate } from '@/i18n/i18n'
import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options'
import type { PersistedNativeChatSessionOptions } from '../../../shared/native-chat-session-options'
import type { LaunchFile } from '../../../shared/launch-prompt-file'
import type { LaunchFile, UnstageableLine } from '../../../shared/launch-prompt-file'
import type { LaunchHost } from '../../../shared/launch-host'
export function buildDirectWorkItemAgentStartupPlan(args: {
@@ -49,6 +49,8 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
launchFile?: LaunchFile
/** The submitted prompt the launch command carries (on its line or in `launchFile`). */
launchPrompt?: string
/** The rule's wish for a line the host cannot stage, sent with the spawn. */
unstageableLine?: UnstageableLine
/** The launch command carries the content (a native draft or a submitted prompt); no paste runs. */
promptOnLaunchCommand: boolean
startupPlanFailed: boolean
@@ -95,6 +97,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
return {
startupPlan: planned.plan,
launchPrompt: args.draftContent,
...(planned.unstageableLine ? { unstageableLine: planned.unstageableLine } : {}),
promptOnLaunchCommand: true,
startupPlanFailed: false
}
@@ -175,7 +178,11 @@ export function buildDirectWorkItemStartupOpts(
launchDraftText?: string,
/** What the launch command carries: its launch file, and the submitted prompt (see
* `PtyPaneStartup.launchPrompt`). */
carried: { launchFile?: LaunchFile; launchPrompt?: string } = {}
carried: {
launchFile?: LaunchFile
launchPrompt?: string
unstageableLine?: UnstageableLine
} = {}
): {
startup?: {
command: string
@@ -188,6 +195,7 @@ export function buildDirectWorkItemStartupOpts(
startupCommandDelivery?: StartupCommandDelivery
launchFile?: LaunchFile
launchPrompt?: string
unstageableLine?: UnstageableLine
telemetry?: AgentStartedTelemetry
}
} {
@@ -212,6 +220,7 @@ export function buildDirectWorkItemStartupOpts(
: {}),
...(carried.launchFile ? { launchFile: carried.launchFile } : {}),
...(carried.launchPrompt ? { launchPrompt: carried.launchPrompt } : {}),
...(carried.unstageableLine ? { unstageableLine: carried.unstageableLine } : {}),
...(telemetry ? { telemetry } : {})
}
}
@@ -4,7 +4,7 @@ import { getConnectionId } from '@/lib/connection-context'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import type { LaunchFile } from '../../../shared/launch-prompt-file'
import type { LaunchFile, UnstageableLine } from '../../../shared/launch-prompt-file'
import type {
AgentSessionLaunchPlan,
planAgentSessionLaunch
@@ -21,6 +21,7 @@ export type DirectWorkItemAgentLaunchPreparation = {
startupPlan: ReturnType<typeof buildDirectWorkItemStartup>['startupPlan']
launchFile?: LaunchFile
launchPrompt?: string
unstageableLine?: UnstageableLine
promptOnLaunchCommand: boolean
startupPlanFailed: boolean
structuredLaunch: boolean
@@ -74,26 +75,32 @@ export async function prepareDirectWorkItemAgentLaunch(args: {
// Non-critical: activation still has the explicit startup below.
})
}
const { startupPlan, launchFile, launchPrompt, promptOnLaunchCommand, startupPlanFailed } =
buildDirectWorkItemStartup({
agent: effectiveAgent,
agentArgs: args.agentArgs,
draftContent: args.draftContent,
promptDelivery: args.promptDelivery,
settings: args.settings,
launchPlatform: args.launchPlatform,
launchConnectionId,
worktreePath: args.worktreePath,
repoProjectRuntime:
launchConnectionId === null
? (getLocalProjectExecutionRuntimeContext(
args.latestStore,
args.worktreeId,
CLIENT_PLATFORM
) ?? args.repoProjectRuntime)
: undefined,
runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId)
})
const {
startupPlan,
launchFile,
launchPrompt,
unstageableLine,
promptOnLaunchCommand,
startupPlanFailed
} = buildDirectWorkItemStartup({
agent: effectiveAgent,
agentArgs: args.agentArgs,
draftContent: args.draftContent,
promptDelivery: args.promptDelivery,
settings: args.settings,
launchPlatform: args.launchPlatform,
launchConnectionId,
worktreePath: args.worktreePath,
repoProjectRuntime:
launchConnectionId === null
? (getLocalProjectExecutionRuntimeContext(
args.latestStore,
args.worktreeId,
CLIENT_PLATFORM
) ?? args.repoProjectRuntime)
: undefined,
runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId)
})
const plan =
effectiveAgent === null
@@ -114,6 +121,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: {
startupPlan,
...(launchFile ? { launchFile } : {}),
...(launchPrompt ? { launchPrompt } : {}),
...(unstageableLine ? { unstageableLine } : {}),
promptOnLaunchCommand,
startupPlanFailed,
structuredLaunch,
@@ -419,14 +419,13 @@ describe('launchWorkItemDirect', () => {
platform: 'win32',
isRemote: false
})
// Windows shells have no bracketed paste, so a multi-line draft is never typed onto the launch
// line; it is pasted into the agent's own composer instead.
// Why (final review P3-2): a short multi-line draft was measured exact on PowerShell's line, so
// it is prefilled there, as main typed it; only a measured damage pastes it.
const startup = mocks.activateAndRevealWorktree.mock.calls[0]?.[1]?.startup
expect(startup?.command).not.toContain('Linked Linear issue')
const pasted: string = startup?.draftPrompt ?? ''
expect(pasted).toContain(expectedDraft)
expect(pasted).not.toContain('The distinctive Linear body text is here.')
expect(pasted).not.toContain('--- BEGIN LINKED WORK ITEM CONTEXT ---')
expect(startup?.command).toContain('Linked Linear issue: ENG-42')
expect(startup?.command).not.toContain('The distinctive Linear body text is here.')
expect(startup?.command).not.toContain('--- BEGIN LINKED WORK ITEM CONTEXT ---')
expect(startup?.draftPrompt).toBeUndefined()
})
it('seeds the chat-composer launch draft for a GitHub issue draft launch', async () => {
@@ -523,10 +522,19 @@ describe('launchWorkItemDirect', () => {
).resolves.toBe(true)
expect(buildAgentDraftLaunchPlan).not.toHaveBeenCalled()
// The explicit prompt is submitted by the launch line, never pasted into the running agent.
expect(pasteDraftWhenAgentReady).not.toHaveBeenCalled()
// Why (final review P1-1): on a Windows host nothing can confirm a prompt the line carried, so
// a work item's submit-after-ready prompt is pasted once the agent runs, as main did.
expect(pasteDraftWhenAgentReady).toHaveBeenCalledWith(
expect.objectContaining({
tabId: 'tab-1',
content: 'Use this explicit user prompt.',
agent: 'claude',
submit: true,
forcePaste: true
})
)
const startup = mocks.activateAndRevealWorktree.mock.calls.at(-1)?.[1]?.startup
expect(startup?.command).toContain('Use this explicit user prompt.')
expect(startup?.command).not.toContain('Use this explicit user prompt.')
expect(startup?.command).not.toContain('generated Linear source')
expect(mocks.seedNativeChatLaunchPrompt).toHaveBeenCalledWith({
tabId: 'tab-1',
+1 -1
View File
@@ -289,7 +289,7 @@ async function deliverAgentStartupToTerminal(
// live session and submitted. Wait until the agent owns the PTY before writing.
if (startup.pastePromptAfterReady) {
const prompt = startup.pastePromptAfterReady
// Why: an argv agent's prompt left for paste (a WSL line too long to type, a paired host) waits
// Why: an argv agent's prompt left for paste (a paired host's line past its typed budget) waits
// for the agent's composer and lands as one bracketed paste, not raw keys once the process shows.
const delivered = agentPromptRidesLaunchCommand(startup.agent)
? await pasteDraftToAgentPtyWhenReady({
@@ -6,7 +6,7 @@ import type {
SleepingAgentLaunchConfig
} from '../../../shared/agent-session-resume'
import type { SessionOptionValue } from '../../../shared/native-chat-session-options'
import type { LaunchFile } from '../../../shared/launch-prompt-file'
import type { LaunchFile, UnstageableLine } from '../../../shared/launch-prompt-file'
/** Telemetry threaded from the launch site to `pty:spawn`; main fires `agent_started`
* only after the spawn succeeds. See telemetry-plan.md§Agent launch semantics. */
@@ -35,6 +35,7 @@ export type WorktreeStartupPayload = {
launchFile?: LaunchFile
/** The submitted prompt `command` carries; handed back to copy if the host refuses the spawn. */
launchPrompt?: string
unstageableLine?: UnstageableLine
initialAgentStatus?: { agent: TuiAgent; prompt: string }
sessionOptions?: Record<string, SessionOptionValue>
telemetry?: AgentStartedTelemetry
@@ -54,12 +54,16 @@ describeOnWindows('cmd launch-line prompt quoting', () => {
expect(await typeIntoCmd(`"${shim}" ${quoteStartupArg(prompt, 'cmd')}`)).toEqual([prompt])
})
it.each(WINDOWS_ARGUMENT_CORPUS.filter(({ value }) => value !== ''))(
'delivers $name unchanged',
async ({ value }) => {
expect(await typeIntoCmd(`"${shim}" ${quoteStartupArg(value, 'cmd')}`)).toEqual([value])
}
)
// Why ASCII only: this harness pipes the line in, which cmd decodes in its startup code page
// whatever `chcp` says, while a pane's typed input arrives as UTF-16. The carry rule leaves a
// non-ASCII prompt on cmd to main's delivery (`windowsLaunchLineVerdict`), as it is unmeasured.
it.each(
WINDOWS_ARGUMENT_CORPUS.filter(
({ value }) => value !== '' && [...value].every((char) => char.charCodeAt(0) <= 0x7f)
)
)('delivers $name unchanged', async ({ value }) => {
expect(await typeIntoCmd(`"${shim}" ${quoteStartupArg(value, 'cmd')}`)).toEqual([value])
})
it('never lets a multi-line prompt reach cmd as commands: the line names a launch file', async () => {
const marker = join(dir, 'pwned.txt')
@@ -84,9 +88,11 @@ describeOnWindows('cmd launch-line prompt quoting', () => {
try {
const typed = written.command ?? ''
expect(typed).not.toMatch(/[\r\n]/)
const args = await typeIntoCmd(typed)
expect(args).toHaveLength(1)
expect(args[0]).toContain(written.path)
// Claude's read grant for the file's folder, then the pointer naming the file, whole.
expect(await typeIntoCmd(typed)).toEqual([
`--add-dir=${written.directory}`,
expect.stringContaining(written.path)
])
expect(readFileSync(written.path, 'utf8')).toBe(prompt)
expect(existsSync(marker)).toBe(false)
} finally {
@@ -115,9 +121,10 @@ describeOnWindows('cmd launch-line prompt quoting', () => {
baseDirectory: home
})
try {
const args = await typeIntoCmd(written.command ?? '')
expect(args).toHaveLength(1)
expect(args[0]).toContain(written.path)
expect(await typeIntoCmd(written.command ?? '')).toEqual([
`--add-dir=${written.directory}`,
expect.stringContaining(written.path)
])
} finally {
removeLaunchFile(written)
}
+2 -2
View File
@@ -191,8 +191,8 @@ export function spawnNeedsWslLaunchDirectory(args: {
/**
* The write site's launch file for a spawn. A WSL session writes it into the distro, and refuses
* when the distro directory is unknown: a Windows path means nothing to an agent in the distro. A
* line is not refused here: with no directory the host types it as is, which is the delivery the
* plan chose, from the same probe, for a distro whose folder is unusable.
* line is not refused here: with no directory the host types it as is, main's delivery, unless the
* launch asked to refuse it (`UnstageableLine`); a plan made in main read the same probe.
*/
export function writeSpawnLaunchFile(args: {
launchFile?: LaunchFile
+2 -1
View File
@@ -24,7 +24,8 @@ export type LaunchHost = {
/** Whether the host writes a launch file the agent can read, and stages a long line, both in one
* folder. A paired Orca may be older, an SSH Windows host's relay may run its panes in WSL (its
* OpenSSH default shell, which this client cannot see), and a host may find that folder
* unwritable. Such a host gets the line within its typed budget, or the paste, instead. */
* unwritable. Such a host gets the line (within its typed budget where it types raw, or by its
* Windows shell's verdict), or the paste, instead. */
takesLaunchFile: boolean
/** The shell a local Windows pane is spawned as, which the line is judged and quoted by: a WSL
* pane runs the distro's POSIX shell whatever the Windows setting, and the PowerShell decides
+1 -1
View File
@@ -6,7 +6,7 @@ const base = { agent: 'claude' as const, cmdOverrides: {} }
describe('the one launch-prompt decision every launch path builds through', () => {
// Why: the composer, phone quick commands and background sessions call the builder directly.
it('points a single-line Windows prompt past the typed budget at a launch file', () => {
it('points a single-line cmd prompt past cmd’s 8,191-character line at a launch file', () => {
const prompt = 'y'.repeat(9_000)
const plan = planLaunchForTest({ ...base, prompt, platform: 'win32', shell: 'cmd' })
expect(plan?.launchFile?.content).toBe(prompt)
+7 -4
View File
@@ -131,10 +131,11 @@ describe('a host that types the line raw', () => {
expect(plan('gemini', prompt, { platform: 'win32', shell: 'cmd' })?.carry).toBe('on-line')
})
// Why: main pastes an AI button's or notes send's prompt once the agent runs, so the agent's
// history shows the user's text; a pointer replaces only a line main would type damaged.
// Why: main pastes an AI button's or notes send's prompt once the agent runs, and runs the action
// on that paste; a Windows host cannot prove the agent in front to confirm a carried prompt, so
// even a short exact line is pasted there (final review P1-1).
it.each<TuiAgent>(['claude', 'codex'])(
'pastes what a Windows line cannot carry for a caller whose paste main used, for %s',
'pastes every prompt on a Windows host for a caller whose paste main used, for %s',
(agent) => {
const paste = 'once-agent-runs' as const
const multiLine = 'fix the build\nthen run the tests'
@@ -145,8 +146,10 @@ describe('a host that types the line raw', () => {
'paste-after-ready'
)
expect(plan(agent, 'fix it', { platform: 'win32', shell: 'cmd', paste })?.carry).toBe(
'on-line'
'paste-after-ready'
)
// A host that proves the agent keeps the exact line for the same caller.
expect(plan(agent, 'fix it', { platform: 'darwin', paste })?.carry).toBe('on-line')
const typed = plan(agent, multiLine, { platform: 'win32', shell: 'cmd' })
expect(typed?.carry).toBe('launch-file')
}
+37 -37
View File
@@ -3,45 +3,27 @@ import {
MAX_LINE_PROMPT_BYTES,
carryInLaunchFile,
launchFileDirectoryPlaceholder,
type LaunchFile
type LaunchFile,
type UnstageableLine
} from './launch-prompt-file'
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import {
isPosixStartupShell,
quoteStartupArg,
resolveStartupShell,
type AgentStartupShell
} from './tui-agent-startup-shell'
import { hasControlByte, typedStartupLineFits } from './typed-startup-line'
import { typedStartupLineFits } from './typed-startup-line'
import type { TuiAgent } from './tui-agent'
import type { LaunchHost } from './launch-host'
import { windowsLaunchLineVerdict } from './windows-launch-line'
/**
* Whether a Windows shell would damage `prompt` as one quoted argument. No quoting keeps a control
* byte literal: cmd types the line and a line break submits it early. PowerShell runs a short line
* from its argv, so its damage is the hand-off to the agent (measured): legacy argument passing (5.1
* always, 7.x into a `.cmd` shim) splits at an inner `"` and turns a trailing `\` into `"`, and a
* shim's cmd.exe expands `%NAME%`; a lone `%` stays literal.
*/
export function windowsShellDamagesPrompt(prompt: string, shell: AgentStartupShell): boolean {
if (isPosixStartupShell(shell)) {
return false
}
return (
hasControlByte(prompt) ||
(shell === 'powershell' &&
(prompt.includes('"') || /%[^%]+%/.test(prompt) || prompt.endsWith('\\')))
)
}
/** Why a prefill draft could not be launched, in the user's words, when the Windows shell is why. */
export function windowsDraftRefusal(draft: string, shell: AgentStartupShell): string | null {
return windowsShellDamagesPrompt(draft.trim(), shell)
? "The host's Windows shell would break this draft on the agent's command line (it has a line " +
'break or other control character, or on PowerShell a double quote, a %NAME% pair or a ' +
'trailing backslash), so the agent was not started. Start it without the draft and paste ' +
'the draft once it opens.'
/** Why a prefill draft could not be launched, in the user's words: on a Windows host the only
* reason a flag-prefilled draft builds no line is the shell's measured damage
* (`windowsLaunchLineVerdict`) or the line's length. */
export function windowsDraftRefusal(agent: TuiAgent, platform: NodeJS.Platform): string | null {
return platform === 'win32' && TUI_AGENT_CONFIG[agent].draftPromptFlag
? "The host's Windows shell would break this draft on the agent's command line, so the agent " +
'was not started. Start it without the draft and paste the draft once it opens.'
: null
}
@@ -51,7 +33,12 @@ export function windowsDraftRefusal(draft: string, shell: AgentStartupShell): st
*/
export type LaunchPromptPlan<P> =
| { carry: 'none'; plan: P }
| { carry: 'on-line'; plan: P }
| {
carry: 'on-line'
plan: P
/** Sent with the spawn: the line is refused, not typed raw, if the host cannot stage it. */
unstageableLine?: UnstageableLine
}
| { carry: 'launch-file'; plan: P; launchFile: LaunchFile }
| { carry: 'paste-after-ready'; cleanPlan: P; text: string }
@@ -65,9 +52,8 @@ export function launchPromptNeedsPasteRefusal(
return TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start'
? `${agent} takes its prompt only after it starts, so no ${created} was created. Start the ` +
'agent and paste the prompt once it opens.'
: `${agent} cannot take this prompt on its command line here (too long, or broken by the ` +
`host's Windows shell), so no ${created} was created. Start the agent without it and ` +
'paste the prompt once it opens.'
: `${agent} cannot take this prompt on its command line here (it is too long for it), so no ` +
`${created} was created. Start the agent without it and paste the prompt once it opens.`
}
/**
@@ -93,14 +79,16 @@ export type CarriedPlanArgs = {
paste: LaunchPromptPaste
}
export function agentReadsLaunchFile(agent: TuiAgent): boolean {
function agentReadsLaunchFile(agent: TuiAgent): boolean {
return TUI_AGENT_CONFIG[agent].readsLaunchFile === true
}
const utf8 = new TextEncoder()
/**
* The one carry rule. The prompt rides the agent's line: a host that stages (POSIX, SSH, WSL)
* The one carry rule. A caller that acts once its prompt is sent, on a host that cannot prove the
* agent is in front (Windows), gets main's paste: nothing there could confirm a carried prompt.
* Otherwise 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 was measured
* carrying such a line exactly (`windowsLaunchLineVerdict`); an unmeasured one does what main does
* on the caller's path. When the line cannot (past the argv ceiling, measured damaged by a Windows
@@ -133,12 +121,24 @@ export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchC
if (mode === 'stdin-after-start') {
return pasteAfterReady()
}
// Why: a host that cannot prove the agent is in front cannot confirm a carried prompt either, so
// a caller that acts once its prompt is sent (AI buttons, notes) gets main's paste and its verdict.
if (args.paste === 'once-agent-runs' && !args.host.provesAgentInFront) {
return pasteAfterReady()
}
// Why from the caller's paste: main pasted this caller's prompts, so a line the host cannot stage
// is refused with the prompt to copy rather than typed raw.
const onLine = (plan: P): LaunchPromptPlan<P> => ({
carry: 'on-line',
plan,
...(args.paste === 'once-agent-runs' ? { unstageableLine: 'refuse' as const } : {})
})
const pasteReachesAgent =
args.paste === 'once-agent-runs' ||
(args.paste === 'when-host-proves-agent' && args.host.provesAgentInFront)
const lineOrPaste = (): LaunchPromptPlan<P> | null => {
const plan = pasteReachesAgent ? null : buildLine(args)
return plan ? { carry: 'on-line', plan } : pasteAfterReady()
return plan ? onLine(plan) : pasteAfterReady()
}
const viaLaunchFile = (): LaunchPromptPlan<P> | null => {
// Why paste first: main pastes this caller's prompts, so the agent gets the user's text; a
@@ -168,7 +168,7 @@ export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchC
}
// Hermes's line never holds the text.
if (readsEnv) {
return { carry: 'on-line', plan }
return onLine(plan)
}
const paneShell = args.host.windowsPaneShell
// Why not for a WSL pane: it runs the distro's POSIX shell, which the host stages into.
@@ -192,7 +192,7 @@ export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchC
if (windowsLine === 'uncertain' && args.paste === 'once-agent-runs') {
return pasteAfterReady()
}
return { carry: 'on-line', plan }
return onLine(plan)
}
/** The host writes the path inside this line's quoting, so the file carries which one it is. */
+8 -8
View File
@@ -86,16 +86,16 @@ export function parseLaunchFile(value: unknown): LaunchFile | undefined {
}
/**
* What a caller wants for an agent line too long or multi-line to type when the host cannot stage
* it (its staging folder is unusable): `type` it as is, main's delivery where main typed the line,
* or `refuse` it, for a caller whose prompt main pasted, so the user is handed the prompt instead of
* a raw line that can leave the shell waiting. Absent means `type`, which is also what a host that
* predates the field does.
* What a launch wants for an agent line too long or multi-line to type when the host cannot stage
* it (its staging folder is unusable): `refuse` it, for a caller whose prompt main pasted, so the
* user is handed the prompt instead of a raw line that can leave the shell waiting. Absent, the
* host types the line as is, main's delivery where main typed it, as a host that predates the
* field does. Set by the carry rule (`carryLaunchPrompt`), never by a caller.
*/
export type UnstageableLine = 'type' | 'refuse'
export type UnstageableLine = 'refuse'
export function parseUnstageableLine(value: unknown): UnstageableLine | undefined {
return value === 'type' || value === 'refuse' ? value : undefined
return value === 'refuse' ? value : undefined
}
export const LAUNCH_FILE_UNAVAILABLE_CODE = 'launch_file_unavailable'
@@ -113,7 +113,7 @@ export function describeLaunchFileUnavailable(
return `Orca could not write ${what} (${reason}), so the agent was not started. [${LAUNCH_FILE_UNAVAILABLE_CODE}]`
}
/** Also matches the `launch_file_unavailable: <reason>` form earlier builds sent. */
/** Matched by its code, wherever in the message a host or the IPC layer put it. */
export function isLaunchFileUnavailableMessage(message: string): boolean {
return message.includes(LAUNCH_FILE_UNAVAILABLE_CODE)
}
+5 -3
View File
@@ -39,9 +39,11 @@ export type StartupCommandStaging = {
scriptPath?: string
/** Why staging failed, for the host's log. */
failure?: string
/** The failed write's folder was unusable before it (this host's temp folder, unwritable), which
* the launch's plan also saw, so it chose main's typed line; otherwise the folder looked usable
* (a WSL distro's, found by its probe) and the write still failed. */
/** The failed write's folder fails its writable check now (this host's temp folder), so the host
* types the line as main typed it; a plan made in main checked the same folder and planned for
* that. Otherwise the folder looked usable (a WSL distro's found by its probe, or a temp folder
* that passes the check, as when the disk is full) and the write still failed: a race, refused.
* A temp folder that turns unwritable between the plan and the spawn reads as unusable here. */
folderUnusable?: true
}
+1 -1
View File
@@ -36,7 +36,7 @@ export type TuiAgentConfig = {
* following positional prompt as a second directory. */
launchFileDirectoryFlag?: '--add-dir'
/** Measured reading its launch file (through `launchFileDirectoryFlag` when set) without asking.
* Any other agent keeps the paste after ready for a prompt that would need one. */
* Any other agent gets the caller's paste for a prompt that would need one, or else the line. */
readsLaunchFile?: boolean
/** Native CLI flag that seeds the input without submitting (e.g. Claude's `--prefill <text>`); preferred over the paste-after-ready path. */
draftPromptFlag?: string
-1
View File
@@ -149,7 +149,6 @@ describe('tui agent startup plans', () => {
expect(plan?.launchCommand).toContain('line one\nline two')
})
// Why: a Windows host stages nothing, so Git Bash would get the raw line break typed.
// Why: measured, a multi-line line arrives whole in Git Bash, as main typed it.
it('keeps a multi-line prompt on a Git Bash line on a Windows host', () => {
const plan = planLaunchForTest({
+19 -13
View File
@@ -14,10 +14,10 @@ import type { LaunchHost } from './launch-host'
import {
carryLaunchPrompt,
launchFileDirectoryGrant,
windowsShellDamagesPrompt,
type LaunchPromptPaste,
type LaunchPromptPlan
} from './launch-prompt-carry'
import { windowsLaunchLineVerdict } from './windows-launch-line'
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import type { StartupCommandDelivery } from './codex-startup-delivery'
import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config'
@@ -220,11 +220,13 @@ function buildPlanWithPromptOnLine(
/**
* Whether this agent's prompt rides the launch command rather than the live PTY.
*
* The same question `planLaunchPrompt` answers with any outcome but `paste-after-ready`, asked
* before a command exists — a caller deciding how to deliver a prompt has to know which half it is
* getting while it is still choosing what to create. Derived from the one injection table rather
* than restating it, and pinned against the builder for every agent by
* `tui-agent-prompt-transport.test.ts`, so the two cannot answer differently.
* Whether the agent's CLI can take its prompt on argv at all, asked before a command exists: a
* caller deciding how to deliver a prompt has to know which half it may get while it is still
* choosing what to create. Derived from the one injection table rather than restating it, and pinned
* against the builder for every agent by `tui-agent-prompt-transport.test.ts`. `planLaunchPrompt`
* can still leave such a prompt for the paste (a file the agent is not known to read, a host that
* cannot write its staging folder, a caller whose paste main used on a Windows host); a `false`
* here is always the paste.
*
* Every mode but `stdin-after-start` folds the prompt into argv — that is what argv is FOR, so
* multi-line and special-character text reaches the CLI as one argument instead of keystrokes.
@@ -284,16 +286,20 @@ export function buildAgentDraftLaunchPlan(args: {
agentCommand: baseCommand.commandWithoutSessionOptions
})
let plan: AgentDraftLaunchPlan | null = null
// Why: the shell would damage the draft (see windowsShellDamagesPrompt); callers paste it into
// the agent instead, and a pointer sentence is no draft to edit.
if (config.draftPromptFlag && windowsShellDamagesPrompt(trimmed, shell)) {
return null
}
if (config.draftPromptFlag) {
const quoted = quoteStartupArg(trimmed, shell)
const draftLine = `${launchCommand} ${config.draftPromptFlag} ${quoteStartupArg(trimmed, shell)}`
// Why: a line the Windows shell was measured to damage is no draft to edit, and a pointer
// sentence is none either, so callers paste it into the agent; an unmeasured line is typed as
// main typed it. The pane's PowerShell is not known here.
if (
platform === 'win32' &&
windowsLaunchLineVerdict(trimmed, draftLine, shell, null) === 'damaged'
) {
return null
}
plan = {
agent,
launchCommand: `${launchCommand} ${config.draftPromptFlag} ${quoted}`,
launchCommand: draftLine,
expectedProcess: config.expectedProcess,
launchConfig,
...appliedSessionOptionProps(baseCommand.appliedSessionOptions),
+31 -6
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { describeLaunchHost, type WindowsPowerShell } from './launch-host'
import { planLaunchPrompt } from './tui-agent-startup'
import { windowsLaunchLineVerdict } from './windows-launch-line'
import type { AgentStartupShell } from './tui-agent-startup-shell'
import type { TuiAgent } from './tui-agent'
@@ -160,6 +161,29 @@ describe('an agent.launch prompt on a Windows host, per measured shell', () => {
}
})
// Why (Windows CI): no QA row carried a non-ASCII prompt into cmd, and the piped-stdin harness
// garbles it, so cmd leaves one to main's delivery; PowerShell and Git Bash rows are unchanged.
it('leaves a non-ASCII prompt on cmd to main’s delivery', () => {
const planned = (shell: AgentStartupShell, paste: 'once-agent-runs' | 'never') =>
planLaunchPrompt({
agent: 'claude',
prompt: '日本語 café',
cmdOverrides: {},
platform: 'win32',
shell,
host: windowsHost('pwsh.exe'),
paste
})?.carry
expect(planned('cmd', 'never')).toBe('on-line')
expect(planned('cmd', 'once-agent-runs')).toBe('paste-after-ready')
expect(windowsLaunchLineVerdict('日本語 café', "claude '日本語 café'", 'cmd', null)).toBe(
'uncertain'
)
expect(windowsLaunchLineVerdict('日本語 café', "claude '日本語 café'", 'posix', null)).toBe(
'exact'
)
})
it('leaves another control byte, never measured, to main’s delivery', () => {
const planned = (paste: 'once-agent-runs' | 'when-host-proves-agent') =>
planLaunchPrompt({
@@ -175,16 +199,17 @@ describe('an agent.launch prompt on a Windows host, per measured shell', () => {
expect(planned('when-host-proves-agent')).toBe('on-line')
})
// Why: an AI button's prompt was pasted on main, so only a line measured exact replaces that
// paste; measured damage and the unmeasured both keep main's paste.
// Why: an AI button's prompt was pasted on main, which ran the action on that paste. A Windows
// host cannot prove the agent in front to confirm a carried prompt, so every row keeps the paste,
// even one the shell carries exactly (final review P1-1).
it.each<[AgentStartupShell, Row, 'on-line' | 'paste-after-ready']>([
['cmd', 'ml5', 'paste-after-ready'],
['cmd', 'pq', 'on-line'],
['powershell', 'ml5', 'on-line'],
['cmd', 'pq', 'paste-after-ready'],
['powershell', 'ml5', 'paste-after-ready'],
['powershell', 'ml9k', 'paste-after-ready'],
['powershell', 'pq', 'paste-after-ready'],
['powershell', 'p20k', 'on-line'],
['posix', 'ml9k', 'on-line'],
['powershell', 'p20k', 'paste-after-ready'],
['posix', 'ml9k', 'paste-after-ready'],
['posix', 'p20k', 'paste-after-ready']
])('an AI button on %s with %s gets %s', (shell, row, expected) => {
const planned = planLaunchPrompt({
+15 -3
View File
@@ -67,13 +67,22 @@ function shellVerdict(line: string, shell: AgentStartupShell): WindowsLineVerdic
}
return bytes >= WINDOWS_POWERSHELL_MULTI_LINE_DAMAGED_BYTES ? 'damaged' : 'uncertain'
}
// Git Bash (and a WSL shell): POSIX quoting arrived whole.
// Git Bash: POSIX quoting arrived whole. A WSL pane never reaches this check.
if (line.length <= WINDOWS_POSIX_LINE_EXACT_MAX_CHARS) {
return 'exact'
}
return line.length >= WINDOWS_POSIX_LINE_DAMAGED_CHARS ? 'damaged' : 'uncertain'
}
function hasNonAscii(line: string): boolean {
for (let i = 0; i < line.length; i += 1) {
if (line.charCodeAt(i) > 0x7f) {
return true
}
}
return false
}
function withUnmeasuredKeys(verdict: WindowsLineVerdict, unmeasured: boolean): WindowsLineVerdict {
return unmeasured && verdict === 'exact' ? 'uncertain' : verdict
}
@@ -82,7 +91,7 @@ export function windowsLaunchLineVerdict(
prompt: string,
line: string,
shell: AgentStartupShell,
/** The PowerShell the pane is spawned as (`LaunchHost.windowsPowerShell`), when known. */
/** The PowerShell the pane is spawned as (from `LaunchHost.windowsPaneShell`), when known. */
windowsPowerShell: WindowsPowerShell | null
): WindowsLineVerdict {
const keys = keyBytes(line)
@@ -91,7 +100,10 @@ export function windowsLaunchLineVerdict(
if (keys.tabOrReturn && shell !== 'powershell') {
return 'damaged'
}
const verdict = withUnmeasuredKeys(shellVerdict(line, shell), keys.unmeasured)
// Not measured: a non-ASCII prompt typed into cmd (Orca's cmd panes run `chcp 65001`, as main's
// do, but no QA row carried one), so main's delivery decides.
const unmeasured = keys.unmeasured || (shell === 'cmd' && hasNonAscii(line))
const verdict = withUnmeasuredKeys(shellVerdict(line, shell), unmeasured)
const legacyArgsDamage = prompt.includes('"') || prompt.endsWith('\\')
if (shell === 'cmd' || !launchesThroughCmdShim(line)) {
if (shell !== 'powershell' || verdict !== 'exact' || !legacyArgsDamage) {
+14 -12
View File
@@ -67,18 +67,23 @@ describe('a prompt a Windows shell would damage on the launch line', () => {
expect(plan('fix the "foo bar" bug', 'posix')?.launchFile).toBeUndefined()
})
it('pastes a PowerShell draft holding `"` or ending in `\\` instead of prefilling it', () => {
const draft = (text: string) =>
// Why (final review P3-2): one measured predicate judges a draft's line as it judges a prompt's.
// A measured damage pastes the draft; an unmeasured line (PowerShell `"`, which the pane's
// PowerShell decides) is typed as main typed it.
it('pastes a Windows draft only where its line was measured to damage it', () => {
const draft = (text: string, shell: AgentStartupShell) =>
buildAgentDraftLaunchPlan({
agent: 'claude',
draft: text,
cmdOverrides: {},
platform: 'win32',
shell: 'powershell'
shell
})
expect(draft('say "hi"')).toBeNull()
expect(draft('see C:\\dir\\')).toBeNull()
expect(draft('say hi')?.launchCommand).toBe("claude --prefill 'say hi'")
expect(draft('line one\nline two', 'cmd')).toBeNull()
expect(draft('say\thi', 'cmd')).toBeNull()
expect(draft('say "hi"', 'powershell')?.launchCommand).toBe(`claude --prefill 'say "hi"'`)
expect(draft('line one\nline two', 'powershell')?.launchCommand).toContain('line two')
expect(draft('say hi', 'powershell')?.launchCommand).toBe("claude --prefill 'say hi'")
})
it('points at the file with no double quote in the sentence', () => {
@@ -114,12 +119,9 @@ describe('a prompt a Windows shell would damage on the launch line', () => {
})
it('says in plain words why a Windows shell draft was not launched', () => {
expect(windowsDraftRefusal('line one\nline two', 'powershell')).toMatch(
/Windows shell would break this draft on the agent's command line \(it has a line break or other control character, or on PowerShell a double quote, a %NAME% pair or a trailing backslash\), so the agent was not started/
expect(windowsDraftRefusal('claude', 'win32')).toMatch(
/Windows shell would break this draft on the agent's command line, so the agent was not started/
)
expect(windowsDraftRefusal('see C:\\dir\\', 'powershell')).not.toBeNull()
expect(windowsDraftRefusal('say "hi"', 'powershell')).not.toBeNull()
expect(windowsDraftRefusal('say "hi"', 'cmd')).toBeNull()
expect(windowsDraftRefusal('line one\nline two', 'posix')).toBeNull()
expect(windowsDraftRefusal('claude', 'darwin')).toBeNull()
})
})