fix(launch-prompt): on Windows, paste what the line can't carry where main pasted it

Main pastes an AI button's or notes send's prompt once the agent runs, so the
agent's history, /resume, the chat view and AI Vault show the user's text. A
launch file replaced nearly every such prompt on Windows with a pointer.

The caller's paste is now one of 'never', 'when-host-proves-agent' (#24257's
guarded paste) or 'once-agent-runs' (main's desktop paste for submit-after-ready
prompts). On Windows a prompt the line cannot carry exactly goes to an
'once-agent-runs' paste first; a launch file is left for the lines main would
have typed damaged or too long. Temporary, until the agent's own argv carries
multi-line text past cmd without a shim re-parsing it.
This commit is contained in:
Brennan Benson
2026-10-02 03:08:42 -07:00
parent ea4699eaea
commit 0599ae2f56
19 changed files with 134 additions and 32 deletions
@@ -189,7 +189,7 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe
isRemote: Boolean(workspace.connectionId)
}),
// Why: this create returns before the agent is ready, so nothing pastes after it.
canPasteAfterReady: false
paste: 'never'
})
if (!planned) {
throw new Error('agent_session_identity_required')
@@ -63,7 +63,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt
prompt: opts.agentPrompt ?? '',
host: thisOrcaLaunchHost({ launchPlatform, isRemote }),
// Why: a quick command has no paste after ready.
canPasteAfterReady: false
paste: 'never'
})
if (!planned) {
throw new Error(`Could not build launch command for ${opts.agent}.`)
@@ -272,7 +272,7 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
prompt: opts.startupPrompt ?? '',
...(opts.launchFile ? { launchFile: opts.launchFile } : {}),
host: thisOrcaLaunchHost({ launchPlatform: platform, isRemote }),
canPasteAfterReady: opts.onStartupPromptCarry !== undefined
paste: opts.onStartupPromptCarry ? 'when-host-proves-agent' : 'never'
})
if (!planned) {
// Why: an explicit agent that yields no plan would otherwise spawn a bare
@@ -162,8 +162,8 @@ export function buildWorktreeStartupForAgent(
isRemote: repoIsRemote(repo),
launchPlatform: environment.getLaunchPlatform()
}),
// A caller that cannot paste takes a follow-up and sends it once the agent is ready.
canPasteAfterReady: true
// The caller, or else the host's follow-up, pastes what the line leaves once the agent runs.
paste: 'when-host-proves-agent'
})
if (!planned) {
throw new Error(`Could not build launch command for ${agent}.`)
@@ -134,7 +134,7 @@ export async function submitFolderWorkspaceCreate({
launchPlatform,
isRemote: launchIsRemote
}),
canPasteAfterReady: true
paste: 'when-host-proves-agent'
}),
note
)
@@ -17,7 +17,7 @@ describe('the full composer’s renderer-spawned startup', () => {
platform: 'linux',
isRemote: true,
host: { paired: false, provesAgentInFront: true },
canPasteAfterReady: true
paste: 'when-host-proves-agent'
}),
prompt
)
@@ -210,7 +210,7 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) {
launchPlatform: selectedRepoAgentLaunchPlatform,
isRemote: selectedRepoIsRemote
}),
canPasteAfterReady: true
paste: 'when-host-proves-agent'
}),
submitStartupPrompt
)
@@ -98,7 +98,7 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui
shell: input.shell ?? undefined,
isRemote: input.isRemote,
host: input.host,
canPasteAfterReady: true
paste: 'when-host-proves-agent'
}),
prompt
)
@@ -89,7 +89,7 @@ export async function launchAgentBackgroundSession(
launchPlatform,
isRemote
}),
canPasteAfterReady: true
paste: 'when-host-proves-agent'
})
if (!planned) {
return null
@@ -181,6 +181,26 @@ describe('launchAgentInNewTab Windows shell quoting', () => {
expect(mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft).not.toHaveBeenCalled()
})
it('pastes a multi-line AI-button prompt on Windows, as main did, instead of a launch file', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const prompt = 'Fix the failing checks.\nThen push.'
launchAgentInNewTab({
agent: 'codex',
worktreeId: 'wt-1',
prompt,
promptDelivery: 'submit-after-ready',
launchPlatform: 'win32'
})
const queued = mockQueueTabStartupCommand.mock.calls[0]?.[1]
expect(queued?.launchFile).toBeUndefined()
expect(queued?.command).not.toContain('Fix the failing checks')
expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith(
expect.objectContaining({ tabId: 'tab-1', content: prompt, agent: 'codex', submit: true })
)
})
it('uses the explicit startup shell platform when building draft launch commands', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
@@ -72,7 +72,13 @@ export function planLaunchAgentStartupPrompt(args: {
if (prompt.length > 0 && args.host.paired && promptDelivery === 'submit-after-ready') {
return pasteAfterReady(cleanPlan(), prompt, true)
}
const planned = planLaunchPrompt({ ...base, prompt, host: args.host, canPasteAfterReady: true })
const planned = planLaunchPrompt({
...base,
prompt,
host: args.host,
// Why: main pastes a submit-after-ready prompt once the agent runs and types the rest.
paste: promptDelivery === 'submit-after-ready' ? 'once-agent-runs' : 'when-host-proves-agent'
})
if (!planned) {
return { startupPlan: null, pasteDraftAfterLaunch: null, submitPastedPrompt: false }
}
@@ -83,7 +83,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
...planInputs,
prompt: args.draftContent,
host: args.host,
canPasteAfterReady: true
paste: 'once-agent-runs'
})
if (!planned) {
return { startupPlan: null, promptOnLaunchCommand: false, startupPlanFailed: true }
@@ -55,6 +55,26 @@ describe('planSourceControlAgentActionLaunch', () => {
expect(result.ok && result.summary).not.toContain('included in the launch command')
})
// Why: main pastes an AI button's prompt on Windows, so the agent's history shows the user's
// text; only a typed prompt the line would damage rides a launch file.
it('pastes a multi-line AI-button prompt on Windows, and files the same prompt typed', () => {
const plan = (promptDelivery: 'submit-after-ready' | 'auto-submit') =>
planSourceControlAgentActionLaunch({
agent: 'claude',
commandInput: 'Fix the failing checks.\nThen push.',
promptDelivery,
detectedAgents: ['claude'],
platform: 'win32',
terminalWindowsShell: 'cmd.exe'
})
const button = plan('submit-after-ready')
expect(button.ok && button.delivery).toBe('paste-submit')
expect(button.ok && button.plan.launchCommand).not.toContain('task-context')
const typed = plan('auto-submit')
expect(typed.ok && typed.summary).toContain('private file')
})
it('includes per-action CLI arguments in submit-after-ready launch plans', () => {
const result = planSourceControlAgentActionLaunch({
agent: 'codex',
@@ -160,7 +160,8 @@ export function planSourceControlAgentActionLaunch(args: {
launchPlatform: platform,
isRemote
}),
canPasteAfterReady: true
paste:
args.promptDelivery === 'submit-after-ready' ? 'once-agent-runs' : 'when-host-proves-agent'
})
switch (planned?.carry) {
case undefined:
+1 -1
View File
@@ -4,7 +4,7 @@ export type LaunchHost = {
* stage nor accompany with a launch file. Temporary, until paired hosts advertise both. */
paired: boolean
/** Whether the host can prove the launched agent holds its terminal before a paste
* (`launched-agent-foreground`). A Windows host cannot, so it refuses a paste. */
* (`launched-agent-foreground`). A Windows host cannot, so #24257's guarded paste is refused. */
provesAgentInFront: boolean
}
+40 -3
View File
@@ -14,7 +14,7 @@ function plan(
extra: Partial<Omit<AgentLaunchPromptArgs, 'agent' | 'prompt'>> = {}
) {
const platform = extra.platform ?? 'darwin'
// A local launch on that platform, by a caller that pastes, unless the test says otherwise.
// A local launch on that platform, with #24257's guarded paste, unless the test says otherwise.
const host = describeLaunchHost({
launchPlatform: platform,
isRemote: false,
@@ -27,7 +27,7 @@ function plan(
cmdOverrides: {},
platform,
host,
canPasteAfterReady: true,
paste: 'when-host-proves-agent',
...extra
})
}
@@ -84,7 +84,7 @@ describe('where a launch prompt rides', () => {
// (agentSession.create, a phone quick command) must not refuse it now.
it('keeps a file-sized prompt on the line for a caller that cannot paste', () => {
const prompt = 'g'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS + 1)
const planned = plan('gemini', prompt, { canPasteAfterReady: false })
const planned = plan('gemini', prompt, { paste: 'never' })
expect(planned?.carry).toBe('on-line')
expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toContain('gggg')
})
@@ -140,6 +140,40 @@ 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.
it.each<TuiAgent>(['claude', 'codex'])(
'pastes what a Windows line cannot carry 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'
const pasted = plan(agent, multiLine, { platform: 'win32', shell: 'cmd', paste })
expect(pasted).toMatchObject({ carry: 'paste-after-ready', text: multiLine })
const long = 'y'.repeat(20_000)
expect(plan(agent, long, { platform: 'win32', shell: 'cmd', paste })?.carry).toBe(
'paste-after-ready'
)
expect(plan(agent, 'fix it', { platform: 'win32', shell: 'cmd', paste })?.carry).toBe(
'on-line'
)
const typed = plan(agent, multiLine, { platform: 'win32', shell: 'cmd' })
expect(typed?.carry).toBe('launch-file')
}
)
it('pastes a Windows-damaged prompt for any agent when the caller’s paste is main’s', () => {
const extra = { platform: 'win32' as const, shell: 'powershell' as const }
expect(plan('gemini', 'say "hi"', { ...extra, paste: 'once-agent-runs' })?.carry).toBe(
'paste-after-ready'
)
expect(plan('gemini', 'say "hi"', extra)?.carry).toBe('on-line')
})
it('still points Claude at a file past the argv ceiling on a POSIX host, whatever the paste', () => {
const planned = plan('claude', 'y'.repeat(20_000), { paste: 'once-agent-runs' })
expect(planned?.carry).toBe('launch-file')
})
// Why: a host that cannot prove the agent holds its terminal refuses a paste (#24257), so the
// line carries what the paste would have, as main typed it; Claude and Codex still get the file.
it('keeps a prompt on the line of a host that cannot paste, unless a file can carry it', () => {
@@ -153,6 +187,9 @@ describe('a host that types the line raw', () => {
expect(plan('gemini', 'y'.repeat(20_000), extra)?.carry).toBe('on-line')
expect(plan('claude', 'y'.repeat(20_000), extra)?.carry).toBe('launch-file')
expect(plan('aider', prompt, extra)?.carry).toBe('paste-after-ready')
// An AI button's paste is main's, which ran in WSL too.
const button = { ...extra, paste: 'once-agent-runs' as const }
expect(plan('gemini', 'y'.repeat(20_000), button)?.carry).toBe('paste-after-ready')
})
it('pastes on a paired host what its line cannot carry typed, even for Claude', () => {
+27 -10
View File
@@ -69,6 +69,16 @@ export function launchPromptNeedsPasteRefusal(
'paste the prompt once it opens.'
}
/**
* The paste a caller has for a prompt left until the agent is ready:
* - `never`: none, so the line carries the prompt, as main did;
* - `when-host-proves-agent`: #24257's guarded paste, refused where the host cannot prove the agent
* is in front;
* - `once-agent-runs`: the desktop paste main uses for AI buttons and notes sends on every host,
* written once the agent's process owns the terminal.
*/
export type LaunchPromptPaste = 'never' | 'when-host-proves-agent' | 'once-agent-runs'
export type CarriedPlanArgs = {
agent: TuiAgent
prompt: string
@@ -78,9 +88,8 @@ export type CarriedPlanArgs = {
launchFile?: LaunchFile
/** The host the launch runs on (`describeLaunchHost`). */
host: LaunchHost
/** Whether the caller pastes a prompt left for after the agent is ready. One that cannot still
* starts the agent with the prompt on its line, as main did. */
canPasteAfterReady: boolean
/** The caller's paste for a prompt the line does not carry. */
paste: LaunchPromptPaste
}
export function agentReadsLaunchFile(agent: TuiAgent): boolean {
@@ -106,11 +115,11 @@ function windowsLineCarriesExactly(prompt: string, line: string, shell: AgentSta
/**
* The one carry rule. The prompt rides the agent's line: a host that stages (POSIX, SSH, WSL)
* stages it when it is long or multi-line, and a Windows host types it when its shell carries the
* text exactly. It rides a launch file when it is past the argv ceiling, when a Windows line would
* damage it, or past a paired host's typed budget. A launch file goes only to an agent measured
* reading one, on a host that writes it. Otherwise the prompt is pasted once the agent is ready,
* unless the host cannot prove the agent is in front to paste into or the caller has no paste:
* then the line carries it, as main typed it.
* text exactly. When the line cannot (past the argv ceiling, damaged by a Windows shell, past a
* paired host's typed budget), it rides a launch file, which goes only to an agent measured reading
* one, on a host that writes it; on Windows, a caller whose paste main used gets that paste first.
* Failing a file it is pasted once the agent is ready, unless the caller's paste cannot reach the
* agent on this host: then the line carries it, as main typed it.
*/
export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchCommand: string }>(
args: A,
@@ -135,12 +144,20 @@ export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchC
if (mode === 'stdin-after-start') {
return pasteAfterReady()
}
const pasteReachesAgent =
args.paste === 'once-agent-runs' ||
(args.paste === 'when-host-proves-agent' && args.host.provesAgentInFront)
const lineOrPaste = (): LaunchPromptPlan<P> | null => {
const pasteIsSafe = args.host.provesAgentInFront && args.canPasteAfterReady
const plan = pasteIsSafe ? null : buildLine(args)
const plan = pasteReachesAgent ? null : buildLine(args)
return plan ? { carry: 'on-line', plan } : pasteAfterReady()
}
const viaLaunchFile = (): LaunchPromptPlan<P> | null => {
// Why paste first on Windows: main pastes this caller's prompts, so the agent gets the user's
// text; there a pointer would replace nearly every generated (multi-line) prompt. Temporary,
// until the agent's own argv carries multi-line text past cmd without a shim re-parsing it.
if (args.paste === 'once-agent-runs' && args.platform === 'win32') {
return pasteAfterReady()
}
// Why: an agent not measured reading the file would stop on an approval or refuse the path.
if (args.host.paired || !agentReadsLaunchFile(args.agent)) {
return lineOrPaste()
@@ -18,8 +18,8 @@ export type PlannedLaunchForTest = AgentStartupPlan & {
/** `planLaunchPrompt`, flattened; null where it answers null, or for an empty prompt unless
* `allowEmptyPromptLaunch`, as the builder did before the outcome was explicit. */
export function planLaunchForTest(
args: Omit<AgentLaunchPromptArgs, 'host' | 'canPasteAfterReady'> &
Partial<Pick<AgentLaunchPromptArgs, 'host' | 'canPasteAfterReady'>> & {
args: Omit<AgentLaunchPromptArgs, 'host' | 'paste'> &
Partial<Pick<AgentLaunchPromptArgs, 'host' | 'paste'>> & {
allowEmptyPromptLaunch?: boolean
}
): PlannedLaunchForTest | null {
@@ -29,7 +29,7 @@ export function planLaunchForTest(
}
const planned = planLaunchPrompt({
...launchArgs,
// A local launch on the platform under test, by a caller that pastes, unless the test says.
// A local launch on the platform under test, with #24257's guarded paste, unless the test says.
host:
args.host ??
describeLaunchHost({
@@ -38,7 +38,7 @@ export function planLaunchForTest(
hostPlatform: args.platform,
paired: false
}),
canPasteAfterReady: args.canPasteAfterReady ?? true
paste: args.paste ?? 'when-host-proves-agent'
})
if (!planned) {
return null
+2 -1
View File
@@ -15,6 +15,7 @@ import {
carryLaunchPrompt,
launchFileDirectoryGrant,
windowsShellDamagesPrompt,
type LaunchPromptPaste,
type LaunchPromptPlan
} from './launch-prompt-carry'
import { TUI_AGENT_CONFIG } from './tui-agent-config'
@@ -76,7 +77,7 @@ export type AgentLaunchPromptArgs = AgentStartupPlanInputs & {
/** See `CarriedPlanArgs`. */
host: LaunchHost
/** See `CarriedPlanArgs`. */
canPasteAfterReady: boolean
paste: LaunchPromptPaste
}
/**