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