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