mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
fix(launch-prompt): one host-derived carry rule, receipts proven like the crash guard
- The carried prompt's receipt is delivered only on the agent's hook turn or a fresh foreground read that finds the launched agent in front; readiness alone never counts and bracketed paste turned off revokes it. An unknown read is reported as unconfirmed. - Host facts (paired, proves the agent in front) come from describeLaunchHost; a caller that cannot paste keeps the prompt on the line as main did instead of refusing. - Windows keeps any exact line up to cmd's 8,191 cap on the line; only damaged or multi-line prompts ride a launch file. - A launch-file refusal is a "failed without effects" code for agent.launch, and the desktop closes the tab it opened for it. - Launch-file folders carry their owner's pid; the sweep never removes a live owner's. - The terminal-handle wait keeps waiting while that tab's spawn is still in flight.
This commit is contained in:
@@ -51,7 +51,7 @@ describe('a daemon WSL session with a launch file', () => {
|
||||
it('writes the file into the distro and types its Linux path', async () => {
|
||||
await create({ distro: 'Ubuntu', windowsPath: windowsSide, linuxPath: '/home/ada/.cache/orca' })
|
||||
const command = spawn.mock.calls[0]?.[0].command ?? ''
|
||||
const linuxPath = /\/home\/ada\/\.cache\/orca\/orca-launch-file-\w+\/task-context\.md/.exec(
|
||||
const linuxPath = /\/home\/ada\/\.cache\/orca\/orca-launch-file-\d+-\w+\/task-context\.md/.exec(
|
||||
command
|
||||
)?.[0]
|
||||
expect(command).toBe(`claude '${buildLaunchFilePointer(linuxPath ?? 'missing')}'`)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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'
|
||||
|
||||
/** The renderer's launch-prompt receipt asks the question #24257's crash guard asks before a paste. */
|
||||
export function installLaunchedAgentForegroundIpcHandler(
|
||||
runtime: OrcaRuntimeService | undefined
|
||||
): void {
|
||||
getPtyIpc().handle(
|
||||
'pty:readLaunchedAgentForeground',
|
||||
async (_event, args: { id: string; agent: string }): Promise<LaunchedAgentForeground> => {
|
||||
if (!runtime || typeof args?.id !== 'string' || !isTuiAgent(args.agent)) {
|
||||
return 'unknown'
|
||||
}
|
||||
return runtime.readLaunchedAgentForeground(args.id, args.agent)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import { getPtyIpc } from '../../pty-host-bindings'
|
||||
import { runPtyIpcSpawn } from './spawn-run'
|
||||
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
|
||||
import { isLaunchFileUnavailableMessage } from '../../../../shared/launch-prompt-file'
|
||||
import { recordTerminalLaunchRefusal } from '../../../runtime/terminal-launch-refusals'
|
||||
import {
|
||||
noteTerminalPaneSpawn,
|
||||
recordTerminalLaunchRefusal
|
||||
} from '../../../runtime/terminal-launch-refusals'
|
||||
|
||||
export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void {
|
||||
const ipcMain = getPtyIpc()
|
||||
@@ -13,6 +16,8 @@ export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void {
|
||||
if (startupPromise) {
|
||||
await startupPromise
|
||||
}
|
||||
// Why: a create waiting on this tab's handle keeps waiting while the spawn still runs.
|
||||
const settleSpawn = args.tabId ? noteTerminalPaneSpawn(args.tabId) : () => {}
|
||||
try {
|
||||
return await runPtyIpcSpawn(deps, args)
|
||||
} catch (error) {
|
||||
@@ -22,6 +27,8 @@ export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void {
|
||||
recordTerminalLaunchRefusal(args.tabId, message)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
settleSpawn()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { installPtyWriteIpcHandlers } from './ipc/write'
|
||||
import { installPtySpawnIpcHandler } from './ipc/spawn'
|
||||
import { installPtyRuntimeController } from './runtime/controller'
|
||||
import { installLaunchedAgentForegroundIpcHandler } from './ipc/launched-agent-foreground'
|
||||
import { installPtySnapshotIpcHandlers } from './ipc/snapshot'
|
||||
import {
|
||||
assertFolderWorkspacePtyPathUsable as assertFolderWorkspacePtyPathUsableImpl,
|
||||
@@ -119,6 +120,7 @@ export function registerPtyHandlers(
|
||||
ipcMain.removeHandler('pty:getForegroundProcess')
|
||||
ipcMain.removeHandler('pty:inspectProcess')
|
||||
ipcMain.removeHandler('pty:confirmForegroundProcess')
|
||||
ipcMain.removeHandler('pty:readLaunchedAgentForeground')
|
||||
ipcMain.removeHandler('pty:isCodexOnSharedServer')
|
||||
ipcMain.removeHandler('pty:disableCodexSharedServerAutoStart')
|
||||
ipcMain.removeHandler('pty:stopCodexSharedServer')
|
||||
@@ -282,6 +284,7 @@ export function registerPtyHandlers(
|
||||
installPtyWriteIpcHandlers({ mainWindow, runtime })
|
||||
installPtyResizeVisibilityIpc(session)
|
||||
installPtyInspectIpcHandlers({ getLocalPtyProviderStartupPromise })
|
||||
installLaunchedAgentForegroundIpcHandler(runtime)
|
||||
installPtyCodexSharedServerIpcHandler({ getLocalPtyProviderStartupPromise })
|
||||
installPtyKillIpcHandler(killDeps)
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ describe('a terminal create that is handed a launch prompt', () => {
|
||||
startupAgent: 'aider',
|
||||
startupPrompt: 'summarize the diff'
|
||||
})
|
||||
).rejects.toThrow(/does not take a startup prompt/)
|
||||
).rejects.toThrow(/takes its prompt only after it starts/)
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { launchHostProvesAgentInFront } from './launch-host-agent-in-front'
|
||||
|
||||
describe('whether a launch host can prove its agent is in front before a paste', () => {
|
||||
it.each([
|
||||
['a local macOS host', false, 'darwin', 'darwin', true],
|
||||
['a local Linux host', false, 'linux', 'linux', true],
|
||||
['a local Windows host', false, 'win32', 'win32', false],
|
||||
// The pane runs in the distro, but the reads run on the Windows host.
|
||||
['a local WSL pane', false, 'linux', 'win32', false],
|
||||
['an SSH Linux host from Windows', true, 'linux', 'win32', true],
|
||||
['an SSH Windows host', true, 'win32', 'darwin', false]
|
||||
] as const)('%s: %s', (_label, isRemote, launchPlatform, hostPlatform, proves) => {
|
||||
expect(launchHostProvesAgentInFront({ isRemote, launchPlatform, hostPlatform })).toBe(proves)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* Whether the execution host can prove a launched agent holds its terminal before a paste
|
||||
* (`launched-agent-foreground`). A Windows host cannot, and a local WSL pane runs on one; an SSH
|
||||
* host is judged by its own platform.
|
||||
*/
|
||||
export function launchHostProvesAgentInFront(args: {
|
||||
isRemote: boolean
|
||||
launchPlatform: NodeJS.Platform
|
||||
hostPlatform?: NodeJS.Platform
|
||||
}): boolean {
|
||||
return args.isRemote
|
||||
? args.launchPlatform !== 'win32'
|
||||
: (args.hostPlatform ?? process.platform) !== 'win32'
|
||||
}
|
||||
@@ -213,19 +213,22 @@ describe('agent-session create operation ledger', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a prompt it could only paste, having no paste after ready', async () => {
|
||||
// Why: main started this agent with the whole prompt on its line; with no paste after ready this
|
||||
// create must not refuse what main started.
|
||||
it('keeps a prompt it cannot paste on the agent’s line, as main did', async () => {
|
||||
const runtime = createRuntime({
|
||||
supportsAgentSessionClaims: () => true,
|
||||
supportsAgentSessionCreateOperations: () => true
|
||||
})
|
||||
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal())
|
||||
|
||||
await expect(
|
||||
runtime.createAgentSession(
|
||||
request(operationId(), { agent: 'gemini', prompt: 'x'.repeat(20_000) })
|
||||
)
|
||||
).rejects.toThrow(/gemini cannot take this prompt on its command line here/)
|
||||
expect(createTerminal).not.toHaveBeenCalled()
|
||||
await runtime.createAgentSession(
|
||||
request(operationId(), { agent: 'gemini', prompt: 'x'.repeat(20_000) })
|
||||
)
|
||||
expect(createTerminal).toHaveBeenCalledWith(
|
||||
'id:worktree-1',
|
||||
expect.objectContaining({ command: expect.stringContaining('x'.repeat(20_000)) })
|
||||
)
|
||||
})
|
||||
|
||||
it('requests exact client legacy fallback before nested SSH side effects', async () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
windowsDraftRefusal
|
||||
} from '../../shared/launch-prompt-carry'
|
||||
import { resolveStartupShell } from '../../shared/tui-agent-startup-shell'
|
||||
import { thisOrcaLaunchHost } from './this-orca-launch-host'
|
||||
import type { RuntimeTerminalCreate } from '../../shared/runtime-types'
|
||||
import type {
|
||||
AgentSessionCreateOperation,
|
||||
@@ -180,7 +181,16 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe
|
||||
throw new Error(refusal ?? 'agent_session_identity_required')
|
||||
}
|
||||
} else {
|
||||
const planned = planLaunchPrompt({ ...startupArgs, prompt: request.prompt ?? '' })
|
||||
const planned = planLaunchPrompt({
|
||||
...startupArgs,
|
||||
prompt: request.prompt ?? '',
|
||||
host: thisOrcaLaunchHost({
|
||||
launchPlatform: startupArgs.platform,
|
||||
isRemote: Boolean(workspace.connectionId)
|
||||
}),
|
||||
// Why: this create returns before the agent is ready, so nothing pastes after it.
|
||||
canPasteAfterReady: false
|
||||
})
|
||||
if (!planned) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describeLaunchFileUnavailable } from '../../shared/launch-prompt-file'
|
||||
|
||||
const surface = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: unknown, response: unknown) => void>()
|
||||
return {
|
||||
handlers,
|
||||
onIpc: (channel: string, handler: (event: unknown, response: unknown) => void) =>
|
||||
handlers.set(channel, handler),
|
||||
removeIpcListener: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./orca-runtime-create-terminal-dependencies', () => ({
|
||||
randomUUID: () => 'request-1',
|
||||
ownerSurfacing: () => ({}),
|
||||
getRuntimeDesktopSurface: () => surface
|
||||
}))
|
||||
|
||||
import { createDesktopTerminal } from './orca-runtime-create-terminal-desktop'
|
||||
|
||||
describe('a desktop terminal create whose pane the host refused', () => {
|
||||
// Why: the pane never started, so closing its tab is what makes the refusal a launch without
|
||||
// effects, and lets the phone's replay be told the reason instead of "outcome unknown".
|
||||
it('closes the tab it opened and reports the refusal', async () => {
|
||||
const refusal = new Error(describeLaunchFileUnavailable('disk full'))
|
||||
const webContents = {
|
||||
send: (_channel: string, request: { requestId: string }) =>
|
||||
surface.handlers.get('terminal:tabCreateReply')?.(
|
||||
{ sender: webContents },
|
||||
{ requestId: request.requestId, tabId: 'tab-refused' }
|
||||
)
|
||||
}
|
||||
const closeTerminalTab = vi.fn(async () => {})
|
||||
const runtime = {
|
||||
assertGraphReady: () => {},
|
||||
getAuthoritativeWindow: () => ({ webContents }),
|
||||
resolveTerminalWorkspaceLaunchScope: async () => null,
|
||||
waitForTerminalHandle: vi.fn(async () => {
|
||||
throw refusal
|
||||
}),
|
||||
notifier: { closeTerminalTab }
|
||||
}
|
||||
|
||||
await expect(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create reads only the members stubbed above.
|
||||
createDesktopTerminal(runtime as never, undefined, { command: 'claude' }, undefined, null)
|
||||
).rejects.toBe(refusal)
|
||||
expect(closeTerminalTab).toHaveBeenCalledWith('tab-refused', { force: true })
|
||||
})
|
||||
|
||||
it('leaves the tab of any other failure', async () => {
|
||||
const webContents = {
|
||||
send: (_channel: string, request: { requestId: string }) =>
|
||||
surface.handlers.get('terminal:tabCreateReply')?.(
|
||||
{ sender: webContents },
|
||||
{ requestId: request.requestId, tabId: 'tab-slow' }
|
||||
)
|
||||
}
|
||||
const closeTerminalTab = vi.fn(async () => {})
|
||||
const runtime = {
|
||||
assertGraphReady: () => {},
|
||||
getAuthoritativeWindow: () => ({ webContents }),
|
||||
resolveTerminalWorkspaceLaunchScope: async () => null,
|
||||
waitForTerminalHandle: vi.fn(async () => {
|
||||
throw new Error('Timed out waiting for terminal handle after creation')
|
||||
}),
|
||||
notifier: { closeTerminalTab }
|
||||
}
|
||||
|
||||
await expect(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create reads only the members stubbed above.
|
||||
createDesktopTerminal(runtime as never, undefined, { command: 'claude' }, undefined, null)
|
||||
).rejects.toThrow('Timed out')
|
||||
expect(closeTerminalTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
import * as dependencies from './orca-runtime-create-terminal-dependencies'
|
||||
import type { OrcaRuntimeWithCreateTerminal } from './orca-runtime-create-terminal'
|
||||
import type { RuntimeTerminalPresentation } from '../../shared/runtime-types'
|
||||
import { isLaunchFileRefusal } from '../../shared/launch-prompt-file'
|
||||
|
||||
export async function createDesktopTerminal(
|
||||
runtime: OrcaRuntimeWithCreateTerminal,
|
||||
@@ -78,7 +79,18 @@ export async function createDesktopTerminal(
|
||||
...dependencies.ownerSurfacing(opts.surfaceOwner !== false)
|
||||
})
|
||||
})
|
||||
const handle = await runtime.waitForTerminalHandle(reply.tabId)
|
||||
let handle: string
|
||||
try {
|
||||
handle = await runtime.waitForTerminalHandle(reply.tabId)
|
||||
} catch (error) {
|
||||
if (isLaunchFileRefusal(error)) {
|
||||
// Why: the refused pane never started; closing its tab leaves the launch without effects.
|
||||
await Promise.resolve(
|
||||
runtime.notifier?.closeTerminalTab?.(reply.tabId, { force: true })
|
||||
).catch((closeError: unknown) => console.error('Could not close a refused tab', closeError))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
handle,
|
||||
tabId: reply.tabId,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
|
||||
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
|
||||
import { planLaunchPrompt } from '../../shared/tui-agent-startup'
|
||||
import { thisOrcaLaunchHost } from './this-orca-launch-host'
|
||||
import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry'
|
||||
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
|
||||
|
||||
@@ -48,16 +49,21 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt
|
||||
if (!isTuiAgentEnabled(opts.agent, settings.disabledTuiAgents)) {
|
||||
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
|
||||
}
|
||||
// Why: mobile may be iOS while the shell host is Windows/macOS/Linux or SSH Linux; quote for the host shell.
|
||||
const launchPlatform = this.getAgentLaunchPlatformForWorkspace(workspace)
|
||||
const isRemote = Boolean(workspace.connectionId)
|
||||
const planned = planLaunchPrompt({
|
||||
...resolveAgentStartupPlanInputs({
|
||||
agent: opts.agent,
|
||||
settings,
|
||||
// Why: mobile may be iOS while the shell host is Windows/macOS/Linux or SSH Linux; quote for the host shell.
|
||||
platform: this.getAgentLaunchPlatformForWorkspace(workspace),
|
||||
platform: launchPlatform,
|
||||
// Why: SSH runs the CLI through the relay shim (plain `orca`), so the Linux-only `orca-ide` rename must not apply.
|
||||
isRemote: Boolean(workspace.connectionId)
|
||||
isRemote
|
||||
}),
|
||||
prompt: opts.agentPrompt ?? ''
|
||||
prompt: opts.agentPrompt ?? '',
|
||||
host: thisOrcaLaunchHost({ launchPlatform, isRemote }),
|
||||
// Why: a quick command has no paste after ready.
|
||||
canPasteAfterReady: false
|
||||
})
|
||||
if (!planned) {
|
||||
throw new Error(`Could not build launch command for ${opts.agent}.`)
|
||||
|
||||
@@ -19,9 +19,9 @@ import { terminalShellOverrideRefusal } from './terminal-shell-override-host-sup
|
||||
import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd'
|
||||
import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution'
|
||||
import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution'
|
||||
import { agentPromptRidesLaunchCommand, planLaunchPrompt } from '../../shared/tui-agent-startup'
|
||||
import { planLaunchPrompt } from '../../shared/tui-agent-startup'
|
||||
import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry'
|
||||
import { launchHostProvesAgentInFront } from './launch-host-agent-in-front'
|
||||
import { thisOrcaLaunchHost } from './this-orca-launch-host'
|
||||
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
|
||||
import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
@@ -256,11 +256,6 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
|
||||
return opts
|
||||
}
|
||||
|
||||
// A prompt this launch command cannot carry has nowhere to go from here — the create returns
|
||||
// options, not a live PTY — so refuse rather than spawn the agent and drop the text.
|
||||
if (opts.startupPrompt && !agentPromptRidesLaunchCommand(agent)) {
|
||||
throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`)
|
||||
}
|
||||
const planInputs = resolveAgentStartupPlanInputs({
|
||||
agent,
|
||||
settings,
|
||||
@@ -276,7 +271,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
|
||||
...planInputs,
|
||||
prompt: opts.startupPrompt ?? '',
|
||||
...(opts.launchFile ? { launchFile: opts.launchFile } : {}),
|
||||
hostProvesAgentInFront: launchHostProvesAgentInFront({ isRemote, launchPlatform: platform })
|
||||
host: thisOrcaLaunchHost({ launchPlatform: platform, isRemote }),
|
||||
canPasteAfterReady: opts.onStartupPromptCarry !== undefined
|
||||
})
|
||||
if (!planned) {
|
||||
// Why: an explicit agent that yields no plan would otherwise spawn a bare
|
||||
|
||||
+21
-3
@@ -2,10 +2,17 @@
|
||||
import { OrcaRuntimeWithWaitForMobileTerminalSurface } from './orca-runtime-wait-for-mobile-terminal-surface'
|
||||
import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity'
|
||||
import { parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import { onTerminalLaunchRefusal, takeTerminalLaunchRefusal } from './terminal-launch-refusals'
|
||||
import {
|
||||
onTerminalLaunchRefusal,
|
||||
takeTerminalLaunchRefusal,
|
||||
terminalPaneSpawnInFlight
|
||||
} from './terminal-launch-refusals'
|
||||
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import type { RuntimeMobileSessionCreateTerminalResult } from '../../shared/runtime-types'
|
||||
|
||||
const TERMINAL_HANDLE_RECHECK_MS = 500
|
||||
const TERMINAL_HANDLE_SPAWN_IN_FLIGHT_CAP_MS = 60_000
|
||||
|
||||
export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals extends OrcaRuntimeWithWaitForMobileTerminalSurface {
|
||||
protected restoreLivePairedRendererSessionOwnedMobileTerminals(
|
||||
worktreeId: string | null,
|
||||
@@ -146,10 +153,21 @@ export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals
|
||||
this.graphSyncCallbacks.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
const startedAt = Date.now()
|
||||
const onTimeout = (): void => {
|
||||
// Why: a spawn still running (a cold WSL probe, then the spawn) answers soon; only a tab
|
||||
// with nothing in flight has timed out. Capped so a wedged spawn still ends the wait.
|
||||
if (
|
||||
terminalPaneSpawnInFlight(tabId) &&
|
||||
Date.now() - startedAt < TERMINAL_HANDLE_SPAWN_IN_FLIGHT_CAP_MS
|
||||
) {
|
||||
timer = setTimeout(onTimeout, TERMINAL_HANDLE_RECHECK_MS)
|
||||
return
|
||||
}
|
||||
stop()
|
||||
reject(new Error('Timed out waiting for terminal handle after creation'))
|
||||
}, timeoutMs)
|
||||
}
|
||||
let timer = setTimeout(onTimeout, timeoutMs)
|
||||
// Why: a host that refused the spawn's launch file never registers a handle.
|
||||
const refuse = (message: string): void => {
|
||||
stop()
|
||||
|
||||
@@ -38,6 +38,32 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: main started this agent with the prompt on its line; a quick command has no paste after
|
||||
// ready, so it keeps the line rather than refuse what main started.
|
||||
it('starts an agent with a prompt it cannot paste on its line, as main did', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent-long-prompt' })
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getSettings: () => ({ ...store.getSettings(), disabledTuiAgents: [], agentCmdOverrides: {} })
|
||||
} as never)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
agent: 'gemini',
|
||||
agentPrompt: 'g'.repeat(20_000)
|
||||
})
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: expect.stringContaining('g'.repeat(20_000)) })
|
||||
)
|
||||
})
|
||||
|
||||
it('uses portable Unix quoting for mobile agent launch commands in WSL project runtimes', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' })
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { launchFailureWithoutEffectsCode } from './agent-launch-failure-code'
|
||||
import { trackTerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started'
|
||||
import { describeLaunchFileUnavailable } from '../../../../shared/launch-prompt-file'
|
||||
|
||||
describe('a launch that failed without effects', () => {
|
||||
// Why: every host refuses a launch file before it spawns anything, so the phone's replay must
|
||||
// get the reason, not "outcome unknown", even after the spawn request left this process.
|
||||
it('includes a launch-file refusal for an existing workspace, as it arrives from any host', () => {
|
||||
const dispatched = trackTerminalSpawnDispatch()
|
||||
dispatched.onPtySpawnDispatched()
|
||||
const refusal = new Error(describeLaunchFileUnavailable("the WSL distro's home is gone"))
|
||||
expect(launchFailureWithoutEffectsCode(refusal, 'existing', dispatched)).toBe(
|
||||
'launch_file_unavailable'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves it out for a created workspace, which exists whatever the agent did', () => {
|
||||
const refusal = new Error(describeLaunchFileUnavailable('disk full'))
|
||||
expect(
|
||||
launchFailureWithoutEffectsCode(refusal, 'create-worktree', trackTerminalSpawnDispatch())
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves out any other failure after the spawn request left', () => {
|
||||
const dispatched = trackTerminalSpawnDispatch()
|
||||
dispatched.onPtySpawnDispatched()
|
||||
expect(
|
||||
launchFailureWithoutEffectsCode(new Error('spawn ENOENT'), 'existing', dispatched)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE
|
||||
} from '../../../../shared/agent-launch-session-already-exists'
|
||||
import type { TerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started'
|
||||
import {
|
||||
isLaunchFileRefusal,
|
||||
LAUNCH_FILE_UNAVAILABLE_CODE
|
||||
} from '../../../../shared/launch-prompt-file'
|
||||
|
||||
/** Long enough for every code this path raises, with room for one a later guard adds. */
|
||||
const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128
|
||||
@@ -53,5 +57,11 @@ export function launchFailureWithoutEffectsCode(
|
||||
if (terminalSpawn.failedBeforeDispatch(error) && targetKind === 'existing') {
|
||||
return agentLaunchFailureCode(error)
|
||||
}
|
||||
// Why without effects: every host refuses before it spawns anything, and a desktop window closes
|
||||
// the refused pane's tab before the error reaches here (`createDesktopTerminal`). The code, not
|
||||
// the sentence: the sentence carries a reason long enough to lose its code to the bound.
|
||||
if (isLaunchFileRefusal(error) && targetKind === 'existing') {
|
||||
return LAUNCH_FILE_UNAVAILABLE_CODE
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type AgentStartupPlan
|
||||
} from '../../shared/tui-agent-startup'
|
||||
import type { LaunchFile } from '../../shared/launch-prompt-file'
|
||||
import { launchHostProvesAgentInFront } from './launch-host-agent-in-front'
|
||||
import { thisOrcaLaunchHost } from './this-orca-launch-host'
|
||||
import {
|
||||
detectInstalledAgentsWithShellPathHydration,
|
||||
detectRemoteAgents
|
||||
@@ -158,10 +158,12 @@ export function buildWorktreeStartupForAgent(
|
||||
const planned = planLaunchPrompt({
|
||||
...planInputs,
|
||||
prompt: environment.prompt ?? '',
|
||||
hostProvesAgentInFront: launchHostProvesAgentInFront({
|
||||
host: thisOrcaLaunchHost({
|
||||
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
|
||||
})
|
||||
if (!planned) {
|
||||
throw new Error(`Could not build launch command for ${agent}.`)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { recordTerminalLaunchRefusal, takeTerminalLaunchRefusal } from './terminal-launch-refusals'
|
||||
import {
|
||||
noteTerminalPaneSpawn,
|
||||
recordTerminalLaunchRefusal,
|
||||
takeTerminalLaunchRefusal
|
||||
} from './terminal-launch-refusals'
|
||||
import { describeLaunchFileUnavailable } from '../../shared/launch-prompt-file'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -10,12 +14,16 @@ vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => '/tmp') }
|
||||
}))
|
||||
|
||||
function waitForHandle(runtime: OrcaRuntimeService, tabId: string): Promise<string> {
|
||||
function waitForHandle(
|
||||
runtime: OrcaRuntimeService,
|
||||
tabId: string,
|
||||
timeoutMs = 5_000
|
||||
): Promise<string> {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wait under test is a protected member of the runtime; the assertion names only that method.
|
||||
const internal = runtime as unknown as {
|
||||
waitForTerminalHandle: (tabId: string, timeoutMs?: number) => Promise<string>
|
||||
}
|
||||
return internal.waitForTerminalHandle(tabId, 5_000)
|
||||
return internal.waitForTerminalHandle(tabId, timeoutMs)
|
||||
}
|
||||
|
||||
describe('a pane spawn the host refused for its launch file', () => {
|
||||
@@ -43,4 +51,25 @@ describe('a pane spawn the host refused for its launch file', () => {
|
||||
recordTerminalLaunchRefusal('tab-early', refusal)
|
||||
await expect(waitForHandle(runtime, 'tab-early')).rejects.toThrow(refusal)
|
||||
})
|
||||
|
||||
// Why: a cold WSL probe plus the spawn can outlast the create's wait; the agent then starts
|
||||
// anyway, so a timeout there would be false.
|
||||
it('keeps waiting past its budget while that tab’s spawn still runs, then times out', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const settleSpawn = noteTerminalPaneSpawn('tab-cold-wsl')
|
||||
let outcome: string | null = null
|
||||
waitForHandle(runtime, 'tab-cold-wsl', 100).catch((error: Error) => {
|
||||
outcome = error.message
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(outcome).toBeNull()
|
||||
settleSpawn()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(outcome).toMatch(/Timed out waiting for terminal handle/)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,31 @@ const MAX_REFUSALS = 64
|
||||
|
||||
const refusals = new Map<string, { message: string; at: number }>()
|
||||
const listeners = new Set<(tabId: string, message: string) => void>()
|
||||
/** Pane spawns still running per tab; each `noteTerminalPaneSpawn` settles in a `finally`. */
|
||||
const spawnsInFlight = new Map<string, number>()
|
||||
|
||||
/** Marks a tab's pane spawn as running until the returned settle runs. */
|
||||
export function noteTerminalPaneSpawn(tabId: string): () => void {
|
||||
spawnsInFlight.set(tabId, (spawnsInFlight.get(tabId) ?? 0) + 1)
|
||||
let settled = false
|
||||
return () => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
const remaining = (spawnsInFlight.get(tabId) ?? 1) - 1
|
||||
if (remaining > 0) {
|
||||
spawnsInFlight.set(tabId, remaining)
|
||||
} else {
|
||||
spawnsInFlight.delete(tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a pane spawn for the tab is still running, such as one probing a cold WSL distro. */
|
||||
export function terminalPaneSpawnInFlight(tabId: string): boolean {
|
||||
return spawnsInFlight.has(tabId)
|
||||
}
|
||||
|
||||
export function recordTerminalLaunchRefusal(tabId: string, message: string): void {
|
||||
const now = Date.now()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describeLaunchHost, type LaunchHost } from '../../shared/launch-host'
|
||||
|
||||
/** The host facts for a launch this Orca runs itself, locally or over its own SSH connection. */
|
||||
export function thisOrcaLaunchHost(args: {
|
||||
launchPlatform: NodeJS.Platform
|
||||
isRemote: boolean
|
||||
}): LaunchHost {
|
||||
return describeLaunchHost({ ...args, hostPlatform: process.platform, paired: false })
|
||||
}
|
||||
@@ -128,6 +128,8 @@ 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'>
|
||||
/** 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. */
|
||||
|
||||
@@ -16,6 +16,11 @@ export const ptyStreamAndSerializationApi = {
|
||||
ipcRenderer.invoke('pty:inspectProcess', { id, ...options }),
|
||||
confirmForegroundProcess: (id: string): Promise<string | null> =>
|
||||
ipcRenderer.invoke('pty:confirmForegroundProcess', { id }),
|
||||
readLaunchedAgentForeground: (
|
||||
id: string,
|
||||
agent: string
|
||||
): Promise<'agent' | 'shell' | 'unknown'> =>
|
||||
ipcRenderer.invoke('pty:readLaunchedAgentForeground', { id, agent }),
|
||||
isCodexOnSharedServer: (id: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('pty:isCodexOnSharedServer', { id }),
|
||||
disableCodexSharedServerAutoStart: (id: string): Promise<boolean> =>
|
||||
|
||||
@@ -16,7 +16,7 @@ type BuildSourceControlAgentDeliveryPlanArgs = {
|
||||
/** Why: keep the previewed command label in sync with the real remote launch,
|
||||
* which omits the Linux-only `orca-ide` rename for SSH hosts. */
|
||||
isRemote?: boolean
|
||||
launchHostIsPaired?: boolean
|
||||
runtimeEnvironmentId?: string | null
|
||||
}
|
||||
|
||||
export function buildSourceControlAgentDeliveryPlan({
|
||||
@@ -28,7 +28,7 @@ export function buildSourceControlAgentDeliveryPlan({
|
||||
connectionUnavailable,
|
||||
launchPlatform,
|
||||
isRemote,
|
||||
launchHostIsPaired
|
||||
runtimeEnvironmentId
|
||||
}: BuildSourceControlAgentDeliveryPlanArgs): SourceControlAgentActionDeliveryPlanState {
|
||||
if (connectionUnavailable) {
|
||||
return buildSourceControlAgentConnectionErrorPlan()
|
||||
@@ -53,7 +53,7 @@ export function buildSourceControlAgentDeliveryPlan({
|
||||
terminalWindowsShell: settings?.terminalWindowsShell,
|
||||
platform: launchPlatform,
|
||||
isRemote,
|
||||
launchHostIsPaired
|
||||
runtimeEnvironmentId
|
||||
})
|
||||
if (!result.ok) {
|
||||
return { status: 'error', error: result.error }
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import { buildSourceControlAgentDeliveryPlan } from './buildSourceControlAgentDeliveryPlan'
|
||||
import { useAppStore } from '@/store'
|
||||
import { launchHostIsPaired } from '@/lib/launch-file-host'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm'
|
||||
import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart'
|
||||
@@ -120,9 +119,7 @@ export function useSourceControlAgentActionStart({
|
||||
connectionUnavailable,
|
||||
launchPlatform,
|
||||
isRemote,
|
||||
launchHostIsPaired: launchHostIsPaired(
|
||||
getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId)
|
||||
)
|
||||
runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId)
|
||||
})
|
||||
},
|
||||
[
|
||||
|
||||
@@ -21,7 +21,7 @@ import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan'
|
||||
import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab'
|
||||
import { getNewWorkspaceProjectGroupHostId } from '@/lib/new-workspace-project-options'
|
||||
import { useAppStore } from '@/store'
|
||||
import { launchHostIsPaired } from '@/lib/launch-file-host'
|
||||
import { clientLaunchHost } from '@/lib/launch-file-host'
|
||||
import {
|
||||
composerAgentStartupPlan,
|
||||
type ComposerAgentStartupPlan
|
||||
@@ -129,7 +129,12 @@ export async function submitFolderWorkspaceCreate({
|
||||
platform: launchPlatform,
|
||||
shell: launchShell,
|
||||
isRemote: launchIsRemote,
|
||||
launchHostIsPaired: launchHostIsPaired(runtimeEnvironmentId)
|
||||
host: clientLaunchHost({
|
||||
runtimeEnvironmentId,
|
||||
launchPlatform,
|
||||
isRemote: launchIsRemote
|
||||
}),
|
||||
canPasteAfterReady: true
|
||||
}),
|
||||
note
|
||||
)
|
||||
|
||||
@@ -15,7 +15,9 @@ describe('the full composer’s renderer-spawned startup', () => {
|
||||
prompt,
|
||||
cmdOverrides: {},
|
||||
platform: 'linux',
|
||||
isRemote: true
|
||||
isRemote: true,
|
||||
host: { paired: false, provesAgentInFront: true },
|
||||
canPasteAfterReady: true
|
||||
}),
|
||||
prompt
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat
|
||||
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
|
||||
import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload'
|
||||
import { tuiAgentToAgentKind } from '@/lib/telemetry'
|
||||
import { launchHostIsPaired } from '@/lib/launch-file-host'
|
||||
import { clientLaunchHost } from '@/lib/launch-file-host'
|
||||
import { composerAgentStartupPlan } from '@/lib/composer-agent-startup-plan'
|
||||
import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions'
|
||||
|
||||
@@ -205,7 +205,12 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) {
|
||||
platform: selectedRepoAgentLaunchPlatform,
|
||||
shell: selectedRepoStartupShell,
|
||||
isRemote: selectedRepoIsRemote,
|
||||
launchHostIsPaired: launchHostIsPaired(selectedRepoSettings?.activeRuntimeEnvironmentId)
|
||||
host: clientLaunchHost({
|
||||
runtimeEnvironmentId: selectedRepoSettings?.activeRuntimeEnvironmentId,
|
||||
launchPlatform: selectedRepoAgentLaunchPlatform,
|
||||
isRemote: selectedRepoIsRemote
|
||||
}),
|
||||
canPasteAfterReady: true
|
||||
}),
|
||||
submitStartupPrompt
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ import { useAppStore } from '@/store'
|
||||
import { settleComposerSubmit } from '@/lib/composer-submit-cancellation'
|
||||
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { launchHostIsPaired } from '@/lib/launch-file-host'
|
||||
import { clientLaunchHost } from '@/lib/launch-file-host'
|
||||
import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context'
|
||||
@@ -140,7 +140,11 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
|
||||
platform: selectedRepoAgentLaunchPlatform,
|
||||
shell: selectedRepoStartupShell,
|
||||
isRemote: selectedRepoIsRemote,
|
||||
launchHostIsPaired: launchHostIsPaired(selectedRepoSettings?.activeRuntimeEnvironmentId),
|
||||
host: clientLaunchHost({
|
||||
runtimeEnvironmentId: selectedRepoSettings?.activeRuntimeEnvironmentId,
|
||||
launchPlatform: selectedRepoAgentLaunchPlatform,
|
||||
isRemote: selectedRepoIsRemote
|
||||
}),
|
||||
telemetrySource
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell'
|
||||
import type { LaunchHost } from '../../../../shared/launch-host'
|
||||
import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload'
|
||||
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
|
||||
import { buildAgentDraftLaunchPlan, planLaunchPrompt } from '@/lib/tui-agent-startup'
|
||||
@@ -25,8 +26,8 @@ export type QuickComposerStartupInput = {
|
||||
platform: NodeJS.Platform
|
||||
shell: AgentStartupShell | null | undefined
|
||||
isRemote: boolean
|
||||
/** See `launchHostIsPaired`. */
|
||||
launchHostIsPaired: boolean
|
||||
/** The host the launch runs on (`clientLaunchHost`). */
|
||||
host: LaunchHost
|
||||
telemetrySource: WorktreeCreationRequest['telemetrySource']
|
||||
}
|
||||
|
||||
@@ -96,7 +97,8 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui
|
||||
platform: input.platform,
|
||||
shell: input.shell ?? undefined,
|
||||
isRemote: input.isRemote,
|
||||
launchHostIsPaired: input.launchHostIsPaired
|
||||
host: input.host,
|
||||
canPasteAfterReady: true
|
||||
}),
|
||||
prompt
|
||||
)
|
||||
|
||||
@@ -657,6 +657,11 @@
|
||||
"message": "The agent wasn't started: Orca couldn't write the file that carries your prompt. Copy the prompt and launch again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"unconfirmed": {
|
||||
"notice": {
|
||||
"message": "Orca couldn't confirm the agent received your prompt. Check the agent before sending it again."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,6 +416,11 @@
|
||||
"message": "El agente no se inició: Orca no pudo escribir el archivo que contiene tu prompt. Copia el prompt e inícialo de nuevo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"unconfirmed": {
|
||||
"notice": {
|
||||
"message": "Orca no pudo confirmar que el agente recibió tu prompt. Revisa el agente antes de enviarlo de nuevo."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,6 +626,11 @@
|
||||
"message": "L’agent n’a pas démarré : Orca n’a pas pu écrire le fichier qui contient votre prompt. Copiez le prompt et relancez."
|
||||
}
|
||||
}
|
||||
},
|
||||
"unconfirmed": {
|
||||
"notice": {
|
||||
"message": "Orca n’a pas pu confirmer que l’agent a reçu votre prompt. Vérifiez l’agent avant de le renvoyer."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,6 +527,11 @@
|
||||
"message": "Agent は起動しませんでした。プロンプトを渡すファイルを Orca が書き込めませんでした。プロンプトをコピーして、もう一度起動してください。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unconfirmed": {
|
||||
"notice": {
|
||||
"message": "Agent がプロンプトを受け取ったことを Orca で確認できませんでした。再送する前に Agent を確認してください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,6 +527,11 @@
|
||||
"message": "에이전트가 시작되지 않았습니다. Orca가 프롬프트를 담을 파일을 쓰지 못했습니다. 프롬프트를 복사한 뒤 다시 시작하세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"unconfirmed": {
|
||||
"notice": {
|
||||
"message": "에이전트가 프롬프트를 받았는지 Orca가 확인하지 못했습니다. 다시 보내기 전에 에이전트를 확인하세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,6 +527,11 @@
|
||||
"message": "代理未启动:Orca 无法写入承载你的提示词的文件。请复制提示词并重新启动。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unconfirmed": {
|
||||
"notice": {
|
||||
"message": "Orca 无法确认代理已收到你的提示词。再次发送前请先检查代理。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
clearPreHandlerPtyState,
|
||||
drainPreHandlerPtyData
|
||||
} from '@/components/terminal-pane/pty-pre-handler-buffer'
|
||||
import { waitForAgentDraftInputReady } from './agent-draft-readiness'
|
||||
import { bracketedPasteStateAfter, waitForAgentDraftInputReady } from './agent-draft-readiness'
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
observer: null as ((data: string) => void) | null,
|
||||
@@ -52,4 +52,60 @@ describe('waitForAgentDraftInputReady', () => {
|
||||
])
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
// Why: a shell running the launch line turns bracketed paste off, so its earlier prompt must not
|
||||
// read as the agent ready once the line is running.
|
||||
describe('with bracketed paste turned off revoking the quiet window', () => {
|
||||
const quietTimers = (): void => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('window', {
|
||||
setTimeout: (handler: () => void, ms: number) => globalThis.setTimeout(handler, ms),
|
||||
clearTimeout: (timer: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(timer)
|
||||
})
|
||||
}
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('waits for the agent to turn it on again', async () => {
|
||||
quietTimers()
|
||||
let ready: boolean | null = null
|
||||
void waitForAgentDraftInputReady(
|
||||
PTY_ID,
|
||||
20_000,
|
||||
'render-quiet-after-bracketed-paste',
|
||||
{},
|
||||
{
|
||||
revokeOnBracketedPasteOff: true
|
||||
}
|
||||
).then((value) => {
|
||||
ready = value
|
||||
})
|
||||
testState.observer?.('$ \x1b[?2004h')
|
||||
testState.observer?.('claude "fix it"\r\n\x1b[?2004l')
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
expect(ready).toBeNull()
|
||||
testState.observer?.('\x1b[?2004h> ')
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(ready).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a caller that did not ask for it on the shell prompt’s quiet window', async () => {
|
||||
quietTimers()
|
||||
const pending = waitForAgentDraftInputReady(
|
||||
PTY_ID,
|
||||
20_000,
|
||||
'render-quiet-after-bracketed-paste',
|
||||
{}
|
||||
)
|
||||
testState.observer?.('$ \x1b[?2004h')
|
||||
testState.observer?.('\x1b[?2004l')
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
await expect(pending).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('reads the last toggle in a chunk', () => {
|
||||
expect(bracketedPasteStateAfter(true, 'a\x1b[?2004lb')).toBe(false)
|
||||
expect(bracketedPasteStateAfter(false, '\x1b[?2004l\x1b[?2004h')).toBe(true)
|
||||
expect(bracketedPasteStateAfter(false, 'plain')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,15 @@ import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-strea
|
||||
import { createDraftPasteReadyScanner } from '../../../shared/draft-paste-ready-scanner'
|
||||
|
||||
const BRACKETED_PASTE_QUIET_MS = 1500
|
||||
const BRACKETED_PASTE_ON = '\x1b[?2004h'
|
||||
const BRACKETED_PASTE_OFF = '\x1b[?2004l'
|
||||
|
||||
/** Whether bracketed paste is on after `data`, from the last toggle in it; `previous` if none. */
|
||||
export function bracketedPasteStateAfter(previous: boolean, data: string): boolean {
|
||||
const on = data.lastIndexOf(BRACKETED_PASTE_ON)
|
||||
const off = data.lastIndexOf(BRACKETED_PASTE_OFF)
|
||||
return on === -1 && off === -1 ? previous : on > off
|
||||
}
|
||||
|
||||
/**
|
||||
* Tap the PTY data stream as a side-channel observer (does NOT take over
|
||||
@@ -22,7 +31,10 @@ export function waitForAgentDraftInputReady(
|
||||
ptyId: string,
|
||||
timeoutMs: number,
|
||||
readySignal: DraftPasteReadySignal,
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
/** A quiet window counts only while bracketed paste is still on: a shell running the launch
|
||||
* line turns it off (`2004l`), so its earlier prompt no longer reads as the agent ready. */
|
||||
options: { revokeOnBracketedPasteOff?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false
|
||||
@@ -53,13 +65,24 @@ export function waitForAgentDraftInputReady(
|
||||
quietTimer = window.setTimeout(() => finish(true), BRACKETED_PASTE_QUIET_MS)
|
||||
}
|
||||
|
||||
let toggleCarry = ''
|
||||
let bracketedPasteOn = true
|
||||
const observeData = (data: string): void => {
|
||||
const { ready, armQuietTimer: shouldArm } = scanner.observe(data)
|
||||
if (ready) {
|
||||
finish(true)
|
||||
return
|
||||
}
|
||||
if (shouldArm) {
|
||||
if (options.revokeOnBracketedPasteOff) {
|
||||
const recent = toggleCarry + data
|
||||
toggleCarry = recent.slice(-(BRACKETED_PASTE_OFF.length - 1))
|
||||
bracketedPasteOn = bracketedPasteStateAfter(bracketedPasteOn, recent)
|
||||
if (!bracketedPasteOn && quietTimer !== null) {
|
||||
window.clearTimeout(quietTimer)
|
||||
quietTimer = null
|
||||
}
|
||||
}
|
||||
if (shouldArm && bracketedPasteOn) {
|
||||
armQuietTimer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({
|
||||
}))
|
||||
vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn(async () => true) }))
|
||||
vi.mock('@/lib/agent-launch-prompt-receipt', () => ({
|
||||
waitForLaunchPromptReceipt: vi.fn(async () => true)
|
||||
waitForLaunchPromptReceipt: vi.fn(async () => 'delivered')
|
||||
}))
|
||||
vi.mock('@/lib/agent-ready-wait', () => ({
|
||||
waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' }))
|
||||
|
||||
@@ -40,7 +40,7 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({
|
||||
}))
|
||||
vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn(async () => true) }))
|
||||
vi.mock('@/lib/agent-launch-prompt-receipt', () => ({
|
||||
waitForLaunchPromptReceipt: vi.fn(async () => true)
|
||||
waitForLaunchPromptReceipt: vi.fn(async () => 'delivered')
|
||||
}))
|
||||
vi.mock('@/lib/agent-ready-wait', () => ({
|
||||
waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' }))
|
||||
|
||||
@@ -48,7 +48,7 @@ vi.mock('@/lib/agent-paste-draft', () => ({
|
||||
vi.mock('@/lib/agent-ready-wait', () => ({
|
||||
waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' }))
|
||||
}))
|
||||
const mockWaitForLaunchPromptReceipt = vi.fn(async () => true)
|
||||
const mockWaitForLaunchPromptReceipt = vi.fn(async () => 'delivered')
|
||||
vi.mock('@/lib/agent-launch-prompt-receipt', () => ({
|
||||
waitForLaunchPromptReceipt: mockWaitForLaunchPromptReceipt
|
||||
}))
|
||||
|
||||
@@ -25,6 +25,28 @@ export function showAgentLaunchPromptNotDeliveredNotice(args: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent started with the prompt on its command line, but nothing proved it received it: no hook
|
||||
* turn, and the host cannot tell what holds the terminal. Not a "wasn't sent", so the user is told
|
||||
* to check the agent before sending the prompt again.
|
||||
*/
|
||||
export function showAgentLaunchPromptUnconfirmedNotice(args: {
|
||||
agent: TuiAgent
|
||||
prompt: string
|
||||
}): void {
|
||||
showPromptCopyNotice(
|
||||
translate(
|
||||
'auto.lib.agent.launch.prompt.unconfirmed.notice.message',
|
||||
"Orca couldn't confirm the agent received your prompt. Check the agent before sending it again."
|
||||
),
|
||||
args.prompt
|
||||
)
|
||||
track('agent_error', {
|
||||
error_class: 'paste_readiness_timeout',
|
||||
agent_kind: tuiAgentToAgentKind(args.agent)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The host refused to start the agent because it could not write the launch file that carries the
|
||||
* prompt. Nothing ran, so the user is handed the text to launch again with.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type ReceiptState = {
|
||||
tabsByWorktree: Record<string, { id: string }[]>
|
||||
@@ -8,12 +8,13 @@ type ReceiptState = {
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
let state: ReceiptState = {
|
||||
const empty = (): ReceiptState => ({
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
agentStatusByPaneKey: {},
|
||||
settings: {}
|
||||
}
|
||||
})
|
||||
let state = empty()
|
||||
const listeners = new Set<(next: ReceiptState) => void>()
|
||||
return {
|
||||
getState: () => state,
|
||||
@@ -29,89 +30,114 @@ const mocks = vi.hoisted(() => {
|
||||
},
|
||||
reset: () => {
|
||||
listeners.clear()
|
||||
state = { tabsByWorktree: {}, ptyIdsByTabId: {}, agentStatusByPaneKey: {}, settings: {} }
|
||||
state = empty()
|
||||
},
|
||||
readiness: vi.fn(),
|
||||
waitForAgentReady: vi.fn()
|
||||
readForeground: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: mocks.getState, subscribe: mocks.subscribe }
|
||||
}))
|
||||
vi.mock('./agent-draft-readiness', () => ({ waitForAgentDraftInputReady: mocks.readiness }))
|
||||
vi.mock('./agent-paste-draft', () => ({
|
||||
PTY_SPAWN_TIMEOUT_MS: 8000,
|
||||
getSettingsForAgentTabRuntimeOwner: () => ({}),
|
||||
waitForAgentDraftInputReadyOnTab: mocks.readiness
|
||||
getSettingsForAgentTabRuntimeOwner: () => ({})
|
||||
}))
|
||||
vi.mock('./agent-ready-wait', () => ({ waitForAgentReady: mocks.waitForAgentReady }))
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false }))
|
||||
|
||||
import { waitForLaunchPromptReceipt } from './agent-launch-prompt-receipt'
|
||||
|
||||
const never = (): Promise<never> => new Promise(() => {})
|
||||
const PANE = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
|
||||
function receipt() {
|
||||
return waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 })
|
||||
}
|
||||
|
||||
describe('whether a prompt that rode the launch command reached the agent', () => {
|
||||
beforeEach(() => {
|
||||
mocks.reset()
|
||||
mocks.setState({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } })
|
||||
mocks.readiness.mockReset().mockImplementation(never)
|
||||
mocks.waitForAgentReady.mockReset()
|
||||
mocks.readiness.mockReset().mockResolvedValue(true)
|
||||
mocks.readForeground.mockReset().mockResolvedValue('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 } }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('is delivered when the agent’s own hook reports a turn on the tab', async () => {
|
||||
const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 })
|
||||
mocks.readiness.mockReturnValue(new Promise(() => {}))
|
||||
const pending = receipt()
|
||||
mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
|
||||
mocks.setState({
|
||||
agentStatusByPaneKey: { 'tab-1:11111111-1111-4111-8111-111111111111': { updatedAt: 150 } }
|
||||
})
|
||||
await expect(receipt).resolves.toBe(true)
|
||||
mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 150 } } })
|
||||
await expect(pending).resolves.toBe('delivered')
|
||||
})
|
||||
|
||||
it('ignores a status row older than the launch', async () => {
|
||||
mocks.setState({
|
||||
agentStatusByPaneKey: { 'tab-1:11111111-1111-4111-8111-111111111111': { updatedAt: 50 } }
|
||||
})
|
||||
mocks.readiness.mockResolvedValue(null)
|
||||
const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 })
|
||||
await expect(receipt).resolves.toBe(false)
|
||||
// Why: #24257's crash-guard predicate, asked once the agent looks ready.
|
||||
it('is delivered when a fresh read finds the launched agent in front', async () => {
|
||||
const pending = receipt()
|
||||
mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
|
||||
await expect(pending).resolves.toBe('delivered')
|
||||
expect(mocks.readForeground).toHaveBeenCalledWith('pty-1', 'claude')
|
||||
expect(mocks.readiness.mock.calls[0]?.[4]).toEqual({ revokeOnBracketedPasteOff: true })
|
||||
})
|
||||
|
||||
it('is not delivered when the PTY exits before the agent shows any sign of the prompt', async () => {
|
||||
const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 })
|
||||
// Why: a shell back at its prompt looks ready too; readiness alone never counts.
|
||||
it('is not delivered when the read finds the shell, however ready it looked', async () => {
|
||||
mocks.readForeground.mockResolvedValue('shell')
|
||||
mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 50 } } })
|
||||
const pending = receipt()
|
||||
mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
|
||||
await expect(pending).resolves.toBe('not-delivered')
|
||||
})
|
||||
|
||||
it('is unconfirmed when the host cannot tell and no hook turn arrives', async () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.readForeground.mockResolvedValue('unknown')
|
||||
const pending = receipt()
|
||||
mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await expect(pending).resolves.toBe('unconfirmed')
|
||||
})
|
||||
|
||||
it('takes a hook turn that arrives after a read that could not tell', async () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.readForeground.mockResolvedValue('unknown')
|
||||
const pending = receipt()
|
||||
mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 600 } } })
|
||||
await expect(pending).resolves.toBe('delivered')
|
||||
})
|
||||
|
||||
it('is not delivered when the PTY exits first', async () => {
|
||||
mocks.readiness.mockReturnValue(new Promise(() => {}))
|
||||
const pending = receipt()
|
||||
mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
|
||||
mocks.setState({ ptyIdsByTabId: { 'tab-1': [] } })
|
||||
await expect(receipt).resolves.toBe(false)
|
||||
await expect(pending).resolves.toBe('not-delivered')
|
||||
})
|
||||
|
||||
it('is not delivered when the PTY never spawns, as when the host refused its launch file', async () => {
|
||||
mocks.readiness.mockResolvedValue(null)
|
||||
const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'codex', launchedAt: 100 })
|
||||
await expect(receipt).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('is delivered when the agent shows the composer a paste would have waited for', async () => {
|
||||
mocks.readiness.mockResolvedValue({ ptyId: 'pty-1', ready: true })
|
||||
const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'codex', launchedAt: 100 })
|
||||
await expect(receipt).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('reports an unconfirmed delivery when only the agent process was seen', async () => {
|
||||
mocks.readiness.mockResolvedValue({ ptyId: 'pty-1', ready: false })
|
||||
mocks.waitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' })
|
||||
const onUnconfirmedDelivery = vi.fn()
|
||||
const receipt = waitForLaunchPromptReceipt({
|
||||
tabId: 'tab-1',
|
||||
agent: 'codex',
|
||||
launchedAt: 100,
|
||||
onUnconfirmedDelivery
|
||||
})
|
||||
await expect(receipt).resolves.toBe(true)
|
||||
expect(onUnconfirmedDelivery).toHaveBeenCalledOnce()
|
||||
vi.useFakeTimers()
|
||||
const pending = receipt()
|
||||
await vi.advanceTimersByTimeAsync(8_000)
|
||||
await expect(pending).resolves.toBe('not-delivered')
|
||||
expect(mocks.readForeground).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is not delivered when the tab closes first', async () => {
|
||||
const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 })
|
||||
const pending = receipt()
|
||||
mocks.setState({ tabsByWorktree: { 'wt-1': [] } })
|
||||
await expect(receipt).resolves.toBe(false)
|
||||
await expect(pending).resolves.toBe('not-delivered')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,15 +3,17 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout'
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { useAppStore } from '@/store'
|
||||
import { waitForAgentReady } from './agent-ready-wait'
|
||||
import {
|
||||
getSettingsForAgentTabRuntimeOwner,
|
||||
PTY_SPAWN_TIMEOUT_MS,
|
||||
waitForAgentDraftInputReadyOnTab
|
||||
} from './agent-paste-draft'
|
||||
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
|
||||
import { waitForAgentDraftInputReady } from './agent-draft-readiness'
|
||||
import { getSettingsForAgentTabRuntimeOwner, PTY_SPAWN_TIMEOUT_MS } from './agent-paste-draft'
|
||||
|
||||
type AppState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
export type LaunchPromptReceipt = 'delivered' | 'not-delivered' | 'unconfirmed'
|
||||
|
||||
/** How long a hook turn may still arrive after a read that could not tell. */
|
||||
const HOOK_GRACE_AFTER_UNKNOWN_READ_MS = 2000
|
||||
|
||||
function hookReportedTurnOnTab(state: AppState, tabId: string, launchedAt: number): boolean {
|
||||
return Object.entries(state.agentStatusByPaneKey).some(
|
||||
([paneKey, entry]) => parsePaneKey(paneKey)?.tabId === tabId && entry.updatedAt >= launchedAt
|
||||
@@ -22,31 +24,68 @@ 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'> {
|
||||
// A paired host's process reads do not reach this client.
|
||||
if (isRemoteRuntimePtyId(ptyId)) {
|
||||
return 'unknown'
|
||||
}
|
||||
return window.api.pty.readLaunchedAgentForeground(ptyId, agent).catch(() => 'unknown' as const)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a prompt that rode an agent's launch command reached the agent. True at the first real
|
||||
* signal: the agent's own hook reports a turn on the tab, or the agent shows the composer a paste
|
||||
* would have waited for. False when the PTY never spawns (a refused launch file included), exits
|
||||
* first, or the tab closes. Never "the tab exists".
|
||||
* 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), exits first, the tab closes, or the read finds the
|
||||
* shell. Unconfirmed when nothing proves either, as on a Windows host without hooks.
|
||||
*/
|
||||
export function waitForLaunchPromptReceipt(args: {
|
||||
tabId: string
|
||||
agent: TuiAgent
|
||||
launchedAt: number
|
||||
/** The agent process was seen, but neither its hook nor its composer. */
|
||||
onUnconfirmedDelivery?: () => void
|
||||
}): Promise<boolean> {
|
||||
}): Promise<LaunchPromptReceipt> {
|
||||
const { tabId, agent, launchedAt } = args
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
let sawPty = false
|
||||
let boundPtyId: string | null = null
|
||||
let unsubscribe: (() => void) | null = null
|
||||
const finish = (delivered: boolean): void => {
|
||||
const timers: number[] = []
|
||||
const finish = (receipt: LaunchPromptReceipt): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
unsubscribe?.()
|
||||
resolve(delivered)
|
||||
timers.forEach((timer) => window.clearTimeout(timer))
|
||||
resolve(receipt)
|
||||
}
|
||||
const readAfterReadiness = (ptyId: string): void => {
|
||||
const config = TUI_AGENT_CONFIG[agent]
|
||||
void waitForAgentDraftInputReady(
|
||||
ptyId,
|
||||
resolveDraftPasteReadyTimeoutMs(agent),
|
||||
config.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste',
|
||||
getSettingsForAgentTabRuntimeOwner(tabId),
|
||||
{ revokeOnBracketedPasteOff: true }
|
||||
)
|
||||
.then(() => readLaunchedAgentForeground(ptyId, agent))
|
||||
.then((foreground) => {
|
||||
if (foreground === 'agent') {
|
||||
finish('delivered')
|
||||
} else if (foreground === 'shell') {
|
||||
finish('not-delivered')
|
||||
} else {
|
||||
timers.push(
|
||||
window.setTimeout(() => finish('unconfirmed'), HOOK_GRACE_AFTER_UNKNOWN_READ_MS)
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => finish('unconfirmed'))
|
||||
}
|
||||
let scannedStatus: AppState['agentStatusByPaneKey'] | null = null
|
||||
const observe = (state: AppState): void => {
|
||||
@@ -54,45 +93,26 @@ export function waitForLaunchPromptReceipt(args: {
|
||||
if (state.agentStatusByPaneKey !== scannedStatus) {
|
||||
scannedStatus = state.agentStatusByPaneKey
|
||||
if (hookReportedTurnOnTab(state, tabId, launchedAt)) {
|
||||
finish(true)
|
||||
finish('delivered')
|
||||
return
|
||||
}
|
||||
}
|
||||
if ((state.ptyIdsByTabId[tabId]?.length ?? 0) > 0) {
|
||||
sawPty = true
|
||||
} else if (sawPty || !tabExists(state, tabId)) {
|
||||
finish(false)
|
||||
const ptyId = state.ptyIdsByTabId[tabId]?.[0]
|
||||
if (ptyId && !boundPtyId) {
|
||||
boundPtyId = ptyId
|
||||
readAfterReadiness(ptyId)
|
||||
} else if ((boundPtyId && !ptyId) || !tabExists(state, tabId)) {
|
||||
finish('not-delivered')
|
||||
}
|
||||
}
|
||||
unsubscribe = useAppStore.subscribe(observe)
|
||||
observe(useAppStore.getState())
|
||||
const config = TUI_AGENT_CONFIG[agent]
|
||||
void waitForAgentDraftInputReadyOnTab({
|
||||
tabId,
|
||||
spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS,
|
||||
readinessTimeoutMs: resolveDraftPasteReadyTimeoutMs(agent),
|
||||
readySignal: config.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste',
|
||||
settings: getSettingsForAgentTabRuntimeOwner(tabId)
|
||||
}).then(
|
||||
async (readiness) => {
|
||||
if (settled) {
|
||||
return
|
||||
timers.push(
|
||||
window.setTimeout(() => {
|
||||
if (!boundPtyId) {
|
||||
finish('not-delivered')
|
||||
}
|
||||
if (!readiness) {
|
||||
finish(false)
|
||||
return
|
||||
}
|
||||
if (readiness.ready) {
|
||||
finish(true)
|
||||
return
|
||||
}
|
||||
const fallback = await waitForAgentReady(tabId, config.expectedProcess, { timeoutMs: 1000 })
|
||||
if (fallback.ready && !settled) {
|
||||
args.onUnconfirmedDelivery?.()
|
||||
}
|
||||
finish(fallback.ready)
|
||||
},
|
||||
() => finish(false)
|
||||
}, PTY_SPAWN_TIMEOUT_MS)
|
||||
)
|
||||
observe(useAppStore.getState())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ async function sendBracketedPasteToAgent(args: {
|
||||
}
|
||||
}
|
||||
|
||||
export function waitForAgentDraftInputReadyOnTab(args: {
|
||||
function waitForAgentDraftInputReadyOnTab(args: {
|
||||
tabId: string
|
||||
spawnTimeoutMs: number
|
||||
readinessTimeoutMs: number
|
||||
|
||||
@@ -372,9 +372,9 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery',
|
||||
})
|
||||
|
||||
// Why: a paired host is sent a command and never a launch file, so a pointer would name nothing.
|
||||
it('pastes a prompt an old paired host would need a launch file for, instead of pointing at one', async () => {
|
||||
it('pastes a long prompt to an old paired Linux host, which may neither stage it nor read a file', async () => {
|
||||
useRemoteAgentBackgroundRuntime(state)
|
||||
mockGetAgentLaunchPlatformForRepo.mockReturnValue('win32')
|
||||
mockGetAgentLaunchPlatformForRepo.mockReturnValue('linux')
|
||||
mockRuntimeEnvironmentTransportCall.mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'status.get') {
|
||||
return Promise.resolve({
|
||||
@@ -413,9 +413,49 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves that prompt to a paired host that takes the prompt itself, without a second paste', async () => {
|
||||
// Why: a Windows host cannot prove the agent is in front to paste into, so the line carries it.
|
||||
it('keeps that prompt on an old paired Windows host’s line, as main typed it', async () => {
|
||||
useRemoteAgentBackgroundRuntime(state)
|
||||
mockGetAgentLaunchPlatformForRepo.mockReturnValue('win32')
|
||||
mockRuntimeEnvironmentTransportCall.mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'status.get') {
|
||||
return Promise.resolve({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'old-runtime',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities: []
|
||||
}
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'create',
|
||||
ok: true,
|
||||
result: { terminal: { handle: 'legacy-terminal-1' } }
|
||||
})
|
||||
})
|
||||
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
|
||||
|
||||
await launchAgentBackgroundSession({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'fix the build\nthen run the tests'
|
||||
})
|
||||
|
||||
const create = mockRuntimeEnvironmentTransportCall.mock.calls.find(
|
||||
([request]) => request.method === 'terminal.create'
|
||||
)?.[0]
|
||||
expect(create?.params?.command).not.toContain('orca-launch-file')
|
||||
expect(create?.params?.command).toContain('fix the build')
|
||||
expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves that prompt to a paired host that takes the prompt itself, without a second paste', async () => {
|
||||
useRemoteAgentBackgroundRuntime(state)
|
||||
mockGetAgentLaunchPlatformForRepo.mockReturnValue('linux')
|
||||
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
|
||||
|
||||
await launchAgentBackgroundSession({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { launchHostIsPaired } from '@/lib/launch-file-host'
|
||||
import { clientLaunchHost } from '@/lib/launch-file-host'
|
||||
import { planLaunchPrompt, type AgentStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import type { LaunchFile } from '../../../shared/launch-prompt-file'
|
||||
import type {
|
||||
@@ -70,7 +70,6 @@ export async function launchAgentBackgroundSession(
|
||||
terminalWindowsShell: store.settings?.terminalWindowsShell
|
||||
})
|
||||
const trimmedPrompt = prompt?.trim() ?? ''
|
||||
const hasPrompt = trimmedPrompt.length > 0
|
||||
const isFollowupPath = requireTuiAgentConfig(agent).promptInjectionMode === 'stdin-after-start'
|
||||
|
||||
// Route by the worktree's owner host, not the focused runtime.
|
||||
@@ -85,7 +84,12 @@ export async function launchAgentBackgroundSession(
|
||||
platform: launchPlatform,
|
||||
shell: startupShell,
|
||||
isRemote,
|
||||
launchHostIsPaired: launchHostIsPaired(ownerSettings?.activeRuntimeEnvironmentId)
|
||||
host: clientLaunchHost({
|
||||
runtimeEnvironmentId: ownerSettings?.activeRuntimeEnvironmentId,
|
||||
launchPlatform,
|
||||
isRemote
|
||||
}),
|
||||
canPasteAfterReady: true
|
||||
})
|
||||
if (!planned) {
|
||||
return null
|
||||
@@ -108,7 +112,7 @@ export async function launchAgentBackgroundSession(
|
||||
promptLeftForPaste = isFollowupPath ? null : planned.text
|
||||
break
|
||||
}
|
||||
let pasteDraftAfterLaunch = hasPrompt && isFollowupPath ? trimmedPrompt : promptLeftForPaste
|
||||
let pasteDraftAfterLaunch = trimmedPrompt && isFollowupPath ? trimmedPrompt : promptLeftForPaste
|
||||
|
||||
// A hidden run tab must never be store-visible without its PTY (#2989).
|
||||
const { reservedTabId, leafId, launchToken, launchRegistration, paneEnv } =
|
||||
@@ -174,7 +178,7 @@ export async function launchAgentBackgroundSession(
|
||||
tabId: reservedTabId,
|
||||
leafId,
|
||||
agent,
|
||||
...(hasPrompt && !isFollowupPath ? { prompt: trimmedPrompt } : {}),
|
||||
...(trimmedPrompt && !isFollowupPath ? { prompt: trimmedPrompt } : {}),
|
||||
...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}),
|
||||
legacy: {
|
||||
command: startupPlan.launchCommand,
|
||||
@@ -247,7 +251,7 @@ export async function launchAgentBackgroundSession(
|
||||
tab = adopted.tab
|
||||
paneKey = adopted.paneKey
|
||||
terminalOwnership = adopted.terminalOwnership
|
||||
if (agent === 'command-code' && hasPrompt && !isFollowupPath) {
|
||||
if (agent === 'command-code' && trimmedPrompt && !isFollowupPath) {
|
||||
// Why: Command Code does not expose a prompt-start hook; seed working for
|
||||
// hidden prompt launches so sidebar/activity surfaces do not stay idle.
|
||||
const routing = agentStatusConsumer.resolveRouting()
|
||||
|
||||
@@ -142,7 +142,7 @@ vi.mock('@/runtime/web-runtime-session', () => ({
|
||||
describe('launchAgentInNewTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWaitForLaunchPromptReceipt.mockResolvedValue(true)
|
||||
mockWaitForLaunchPromptReceipt.mockResolvedValue('delivered')
|
||||
mockIsWebRuntimeSessionActive.mockReturnValue(false)
|
||||
mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' })
|
||||
mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft.mockResolvedValue({ status: 'created' })
|
||||
@@ -867,7 +867,7 @@ describe('launchAgentInNewTab', () => {
|
||||
|
||||
it('reports a carried prompt undelivered when the agent never received it', async () => {
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
mockWaitForLaunchPromptReceipt.mockResolvedValue(false)
|
||||
mockWaitForLaunchPromptReceipt.mockResolvedValue('not-delivered')
|
||||
const onPromptDelivered = vi.fn()
|
||||
|
||||
const result = launchAgentInNewTab({
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan'
|
||||
import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order'
|
||||
import { tuiAgentToAgentKind } from '@/lib/telemetry'
|
||||
import { launchHostIsPaired } from '@/lib/launch-file-host'
|
||||
import { clientLaunchHost } from '@/lib/launch-file-host'
|
||||
import { deliverNewTabLaunchPrompt } from '@/lib/launch-agent-new-tab-prompt-delivery'
|
||||
import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode'
|
||||
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
|
||||
@@ -160,7 +160,11 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
|
||||
prompt: trimmedPrompt,
|
||||
promptDelivery,
|
||||
isFollowupPath,
|
||||
launchHostIsPaired: launchHostIsPaired(runtimeEnvironmentId)
|
||||
host: clientLaunchHost({
|
||||
runtimeEnvironmentId,
|
||||
launchPlatform: resolvedLaunchPlatform,
|
||||
isRemote
|
||||
})
|
||||
})
|
||||
let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice'
|
||||
import { waitForLaunchPromptReceipt } from '@/lib/agent-launch-prompt-receipt'
|
||||
import { showAgentLaunchPromptUnconfirmedNotice } from '@/lib/agent-launch-prompt-not-delivered-notice'
|
||||
import {
|
||||
deliverLaunchPromptToAgentTab,
|
||||
seedNativeChatLaunchDraftForAgentTab,
|
||||
@@ -60,9 +61,18 @@ export function deliverNewTabLaunchPrompt(args: {
|
||||
content
|
||||
})
|
||||
// Why the receipt for a carried prompt: callers post replies and resolve threads on this result.
|
||||
const delivery =
|
||||
const result =
|
||||
pasteDraftAfterLaunch === null
|
||||
? waitForLaunchPromptReceipt({ tabId, agent, launchedAt: args.launchedAt, ...unconfirmed })
|
||||
? waitForLaunchPromptReceipt({ tabId, agent, launchedAt: args.launchedAt }).then(
|
||||
(receipt) => {
|
||||
if (receipt !== 'unconfirmed') {
|
||||
return timeoutNotice.settle(receipt === 'delivered', args.onPromptDelivered)
|
||||
}
|
||||
// The agent may have the prompt: say so, and never invite pasting it a second time.
|
||||
showAgentLaunchPromptUnconfirmedNotice({ agent, prompt })
|
||||
return { delivered: false, failureNotified: true }
|
||||
}
|
||||
)
|
||||
: deliverLaunchPromptToAgentTab({
|
||||
tabId,
|
||||
content,
|
||||
@@ -71,10 +81,7 @@ export function deliverNewTabLaunchPrompt(args: {
|
||||
forcePaste: true,
|
||||
onTimeout: timeoutNotice.onTimeout,
|
||||
...unconfirmed
|
||||
})
|
||||
const result = delivery.then((delivered) =>
|
||||
timeoutNotice.settle(delivered, args.onPromptDelivered)
|
||||
)
|
||||
}).then((delivered) => timeoutNotice.settle(delivered, args.onPromptDelivered))
|
||||
if (promptDelivery === 'submit-after-ready') {
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type AgentStartupPlanInputs
|
||||
} from '@/lib/tui-agent-startup'
|
||||
import type { LaunchFile } from '../../../shared/launch-prompt-file'
|
||||
import type { LaunchHost } from '../../../shared/launch-host'
|
||||
|
||||
export type LaunchAgentStartupPromptPlan = {
|
||||
startupPlan: AgentStartupPlan | null
|
||||
@@ -26,8 +27,8 @@ export function planLaunchAgentStartupPrompt(args: {
|
||||
prompt: string
|
||||
promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready'
|
||||
isFollowupPath: boolean
|
||||
/** See `launchHostIsPaired`. */
|
||||
launchHostIsPaired: boolean
|
||||
/** The host the launch runs on (`clientLaunchHost`). */
|
||||
host: LaunchHost
|
||||
}): LaunchAgentStartupPromptPlan {
|
||||
const { base, prompt, promptDelivery, isFollowupPath } = args
|
||||
const pasteAfterReady = (
|
||||
@@ -65,19 +66,13 @@ export function planLaunchAgentStartupPrompt(args: {
|
||||
submitPastedPrompt: false
|
||||
}
|
||||
}
|
||||
if (prompt.length > 0 && isFollowupPath) {
|
||||
return pasteAfterReady(cleanPlan(), prompt, promptDelivery === 'submit-after-ready')
|
||||
}
|
||||
// Why: a caller that waits for delivery gets a verdict from a paste; this client cannot observe a
|
||||
// paired host's carried prompt reach its agent. Temporary, until paired hosts report that receipt.
|
||||
if (prompt.length > 0 && args.launchHostIsPaired && promptDelivery === 'submit-after-ready') {
|
||||
// Why outside the rule: this is the caller's need, not a host fact. A caller that waits for
|
||||
// delivery gets a verdict from a paste, and this client cannot observe a paired host's carried
|
||||
// prompt reach its agent. Temporary, until paired hosts report that receipt.
|
||||
if (prompt.length > 0 && args.host.paired && promptDelivery === 'submit-after-ready') {
|
||||
return pasteAfterReady(cleanPlan(), prompt, true)
|
||||
}
|
||||
const planned = planLaunchPrompt({
|
||||
...base,
|
||||
prompt,
|
||||
launchHostIsPaired: args.launchHostIsPaired
|
||||
})
|
||||
const planned = planLaunchPrompt({ ...base, prompt, host: args.host, canPasteAfterReady: true })
|
||||
if (!planned) {
|
||||
return { startupPlan: null, pasteDraftAfterLaunch: null, submitPastedPrompt: false }
|
||||
}
|
||||
@@ -93,7 +88,11 @@ export function planLaunchAgentStartupPrompt(args: {
|
||||
submitPastedPrompt: false
|
||||
}
|
||||
case 'paste-after-ready':
|
||||
// Every non-draft new-tab prompt is submitted; a draft returned above.
|
||||
return pasteAfterReady(planned.cleanPlan, planned.text, true)
|
||||
// An agent that takes text only after start keeps an auto-submit prompt as an editable draft.
|
||||
return pasteAfterReady(
|
||||
planned.cleanPlan,
|
||||
planned.text,
|
||||
!isFollowupPath || promptDelivery === 'submit-after-ready'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,10 @@ describe('the quick composer on a paired host', () => {
|
||||
draftPrompt: null,
|
||||
settings: null,
|
||||
repoConnectionId: null,
|
||||
platform: 'win32',
|
||||
shell: 'powershell',
|
||||
platform: 'linux',
|
||||
shell: null,
|
||||
isRemote: false,
|
||||
launchHostIsPaired: true,
|
||||
host: { paired: true, provesAgentInFront: true },
|
||||
telemetrySource: 'sidebar'
|
||||
})
|
||||
expect(startup.backendStartup).toBeUndefined()
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { describeLaunchHost, type LaunchHost } from '../../../shared/launch-host'
|
||||
import { isWebClientLocation } from './web-client-location'
|
||||
|
||||
/**
|
||||
* Whether a launch lands on a paired Orca: another Orca this client drives, which may be an older
|
||||
* build that neither stages a long line nor writes a launch file. `carryLaunchPrompt` then keeps
|
||||
* the line to the typed budget and pastes the rest after the agent is ready. Temporary, until paired
|
||||
* hosts advertise both.
|
||||
* build that neither stages a long line nor writes a launch file. Temporary, until paired hosts
|
||||
* advertise both.
|
||||
*/
|
||||
export function launchHostIsPaired(runtimeEnvironmentId: string | null | undefined): boolean {
|
||||
return isWebClientLocation() || isWebRuntimeSessionActive(runtimeEnvironmentId)
|
||||
}
|
||||
|
||||
/** The host facts for a launch this client starts, from the environment it targets. */
|
||||
export function clientLaunchHost(args: {
|
||||
runtimeEnvironmentId: string | null | undefined
|
||||
launchPlatform: NodeJS.Platform
|
||||
isRemote: boolean
|
||||
}): LaunchHost {
|
||||
return describeLaunchHost({
|
||||
launchPlatform: args.launchPlatform,
|
||||
isRemote: args.isRemote,
|
||||
hostPlatform: CLIENT_PLATFORM,
|
||||
paired: launchHostIsPaired(args.runtimeEnvironmentId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AppState } from '@/store/types'
|
||||
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import { isTuiAgentEnabled, pickTuiAgent } from '../../../shared/tui-agent-selection'
|
||||
import { buildDirectWorkItemAgentStartupPlan } from '@/lib/launch-work-item-direct-agent'
|
||||
import { clientLaunchHost } from '@/lib/launch-file-host'
|
||||
import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan'
|
||||
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
|
||||
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
|
||||
@@ -18,7 +19,7 @@ export function buildDirectWorkItemStartup(args: {
|
||||
launchConnectionId: string | null
|
||||
worktreePath: string
|
||||
repoProjectRuntime?: Parameters<typeof resolveSourceControlLaunchPlatform>[0]['projectRuntime']
|
||||
launchHostIsPaired: boolean
|
||||
runtimeEnvironmentId: string | null
|
||||
}): ReturnType<typeof buildDirectWorkItemAgentStartupPlan> {
|
||||
const launchPlatform =
|
||||
args.launchPlatform ??
|
||||
@@ -39,7 +40,11 @@ export function buildDirectWorkItemStartup(args: {
|
||||
),
|
||||
// Why: SSH hosts run the plain `orca` shim, so the Linux-only `orca-ide` rename is not applied.
|
||||
isRemote: typeof args.launchConnectionId === 'string',
|
||||
launchHostIsPaired: args.launchHostIsPaired
|
||||
host: clientLaunchHost({
|
||||
runtimeEnvironmentId: args.runtimeEnvironmentId,
|
||||
launchPlatform,
|
||||
isRemote: typeof args.launchConnectionId === 'string'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ const settings = {
|
||||
describe('buildDirectWorkItemAgentStartupPlan', () => {
|
||||
it('omits native-chat preferences when the new workspace opens in terminal mode', () => {
|
||||
const result = buildDirectWorkItemAgentStartupPlan({
|
||||
launchHostIsPaired: false,
|
||||
host: { paired: false, provesAgentInFront: true },
|
||||
agent: 'codex',
|
||||
draftContent: 'Review issue 42',
|
||||
promptDelivery: 'draft',
|
||||
@@ -98,7 +98,7 @@ describe('buildDirectWorkItemAgentStartupPlan', () => {
|
||||
|
||||
it('applies native-chat preferences when the new workspace opens in chat', () => {
|
||||
const result = buildDirectWorkItemAgentStartupPlan({
|
||||
launchHostIsPaired: false,
|
||||
host: { paired: false, provesAgentInFront: true },
|
||||
agent: 'codex',
|
||||
draftContent: 'Review issue 42',
|
||||
promptDelivery: 'draft',
|
||||
@@ -147,7 +147,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () =>
|
||||
|
||||
it('resolves the global Agents arguments when the launch names none', () => {
|
||||
const result = buildDirectWorkItemAgentStartupPlan({
|
||||
launchHostIsPaired: false,
|
||||
host: { paired: false, provesAgentInFront: true },
|
||||
agent: 'codex',
|
||||
draftContent: 'Fix the broken checks',
|
||||
promptDelivery: 'draft',
|
||||
@@ -161,7 +161,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () =>
|
||||
|
||||
it('lets an explicit per-action value win over the global one', () => {
|
||||
const result = buildDirectWorkItemAgentStartupPlan({
|
||||
launchHostIsPaired: false,
|
||||
host: { paired: false, provesAgentInFront: true },
|
||||
agent: 'codex',
|
||||
agentArgs: '--model gpt-5',
|
||||
draftContent: 'Fix the broken checks',
|
||||
@@ -177,9 +177,9 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () =>
|
||||
})
|
||||
|
||||
describe('buildDirectWorkItemAgentStartupPlan submitted prompts', () => {
|
||||
const submit = (agent: 'claude' | 'gemini', draftContent: string, launchHostIsPaired = false) =>
|
||||
const submit = (agent: 'claude' | 'gemini', draftContent: string, paired = false) =>
|
||||
buildDirectWorkItemAgentStartupPlan({
|
||||
launchHostIsPaired,
|
||||
host: { paired, provesAgentInFront: true },
|
||||
agent,
|
||||
draftContent,
|
||||
promptDelivery: 'submit-after-ready',
|
||||
|
||||
@@ -19,6 +19,7 @@ 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 { LaunchHost } from '../../../shared/launch-host'
|
||||
|
||||
export function buildDirectWorkItemAgentStartupPlan(args: {
|
||||
agent: TuiAgent | null
|
||||
@@ -41,8 +42,8 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
|
||||
/** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only
|
||||
* `orca-ide` rename must not be applied for remote launches. */
|
||||
isRemote?: boolean
|
||||
/** See `launchHostIsPaired`. */
|
||||
launchHostIsPaired: boolean
|
||||
/** The host the launch runs on (`clientLaunchHost`). */
|
||||
host: LaunchHost
|
||||
}): {
|
||||
startupPlan: AgentStartupPlan | null
|
||||
launchFile?: LaunchFile
|
||||
@@ -81,7 +82,8 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
|
||||
const planned = planLaunchPrompt({
|
||||
...planInputs,
|
||||
prompt: args.draftContent,
|
||||
launchHostIsPaired: args.launchHostIsPaired
|
||||
host: args.host,
|
||||
canPasteAfterReady: true
|
||||
})
|
||||
if (!planned) {
|
||||
return { startupPlan: null, promptOnLaunchCommand: false, startupPlanFailed: true }
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 { launchHostIsPaired } from '@/lib/launch-file-host'
|
||||
import type { LaunchFile } from '../../../shared/launch-prompt-file'
|
||||
import type {
|
||||
AgentSessionLaunchPlan,
|
||||
@@ -93,9 +92,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: {
|
||||
CLIENT_PLATFORM
|
||||
) ?? args.repoProjectRuntime)
|
||||
: undefined,
|
||||
launchHostIsPaired: launchHostIsPaired(
|
||||
getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId)
|
||||
)
|
||||
runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId)
|
||||
})
|
||||
|
||||
const plan =
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type AgentStartupPlan
|
||||
} from '@/lib/tui-agent-startup'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { clientLaunchHost } from '@/lib/launch-file-host'
|
||||
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
@@ -45,8 +46,8 @@ export function planSourceControlAgentActionLaunch(args: {
|
||||
/** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only
|
||||
* `orca-ide` rename must not be applied for remote launches. */
|
||||
isRemote?: boolean
|
||||
/** See `launchHostIsPaired`. */
|
||||
launchHostIsPaired?: boolean
|
||||
/** The runtime environment the launch targets, for its host facts (`clientLaunchHost`). */
|
||||
runtimeEnvironmentId?: string | null
|
||||
}): SourceControlLaunchPlanResult {
|
||||
const agent = args.agent
|
||||
if (!agent) {
|
||||
@@ -104,24 +105,7 @@ export function planSourceControlAgentActionLaunch(args: {
|
||||
let startupPlan: AgentStartupPlan | null = null
|
||||
let delivery: SourceControlLaunchPlanDelivery
|
||||
|
||||
// Only an agent that takes no launch prompt waits for readiness; the rest carry it on argv.
|
||||
if (
|
||||
args.promptDelivery === 'submit-after-ready' &&
|
||||
TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start'
|
||||
) {
|
||||
startupPlan = buildAgentStartupPlan({
|
||||
agent,
|
||||
prompt: '',
|
||||
cmdOverrides,
|
||||
platform,
|
||||
shell,
|
||||
isRemote,
|
||||
agentArgs: args.agentArgs,
|
||||
sessionOptions: args.sessionOptions,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
delivery = 'paste-submit'
|
||||
} else if (args.promptDelivery === 'draft') {
|
||||
if (args.promptDelivery === 'draft') {
|
||||
const draftLaunchPlan = buildAgentDraftLaunchPlan({
|
||||
agent,
|
||||
draft: trimmedInput,
|
||||
@@ -161,19 +145,6 @@ export function planSourceControlAgentActionLaunch(args: {
|
||||
})
|
||||
delivery = 'draft-paste'
|
||||
}
|
||||
} else if (TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start') {
|
||||
startupPlan = buildAgentStartupPlan({
|
||||
agent,
|
||||
prompt: '',
|
||||
cmdOverrides,
|
||||
platform,
|
||||
shell,
|
||||
isRemote,
|
||||
agentArgs: args.agentArgs,
|
||||
sessionOptions: args.sessionOptions,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
delivery = 'draft-paste'
|
||||
} else {
|
||||
const planned = planLaunchPrompt({
|
||||
agent,
|
||||
@@ -184,7 +155,12 @@ export function planSourceControlAgentActionLaunch(args: {
|
||||
isRemote,
|
||||
agentArgs: args.agentArgs,
|
||||
sessionOptions: args.sessionOptions,
|
||||
launchHostIsPaired: args.launchHostIsPaired ?? false
|
||||
host: clientLaunchHost({
|
||||
runtimeEnvironmentId: args.runtimeEnvironmentId,
|
||||
launchPlatform: platform,
|
||||
isRemote
|
||||
}),
|
||||
canPasteAfterReady: true
|
||||
})
|
||||
switch (planned?.carry) {
|
||||
case undefined:
|
||||
@@ -201,7 +177,12 @@ export function planSourceControlAgentActionLaunch(args: {
|
||||
break
|
||||
case 'paste-after-ready':
|
||||
startupPlan = planned.cleanPlan
|
||||
delivery = 'paste-submit'
|
||||
// An agent that takes text only after start keeps an auto-submit prompt as a draft.
|
||||
delivery =
|
||||
TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' &&
|
||||
args.promptDelivery !== 'submit-after-ready'
|
||||
? 'draft-paste'
|
||||
: 'paste-submit'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
|
||||
inspectProcess: () => Promise.reject(new Error('terminal_liveness_unavailable')),
|
||||
// Why: paired web panes cannot provide a local post-boundary process scan.
|
||||
confirmForegroundProcess: () => Promise.resolve(null),
|
||||
readLaunchedAgentForeground: () => Promise.resolve('unknown' as const),
|
||||
// Why: a paired client's terminals belong to the host, whose Codex settings this client does not own.
|
||||
isCodexOnSharedServer: () => Promise.resolve(false),
|
||||
disableCodexSharedServerAutoStart: () => Promise.resolve(false),
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { runProcess } from './child-process/run-process'
|
||||
import {
|
||||
LAUNCH_FILE_STALE_MS,
|
||||
@@ -187,7 +187,39 @@ describePosix('a launch line naming a file under an unusual home directory', ()
|
||||
})
|
||||
|
||||
describe('sweepStaleLaunchFiles', () => {
|
||||
it('removes only launch file directories older than a day', () => {
|
||||
const ageOut = (directory: string, now: number): void => {
|
||||
const staleSeconds = (now - LAUNCH_FILE_STALE_MS - 1000) / 1000
|
||||
utimesSync(directory, staleSeconds, staleSeconds)
|
||||
}
|
||||
|
||||
// Why: a daemon session outlives app restarts; its agent may re-read its task file any time.
|
||||
it('never removes a folder whose owning process is alive, however old', () => {
|
||||
const now = Date.now()
|
||||
const live = join(baseDirectory, `orca-launch-file-${process.pid}-aaaaaa`)
|
||||
mkdirSync(live)
|
||||
ageOut(live, now)
|
||||
sweepStaleLaunchFiles({ baseDirectory, now })
|
||||
expect(readdirSync(baseDirectory)).toEqual([`orca-launch-file-${process.pid}-aaaaaa`])
|
||||
})
|
||||
|
||||
it('removes a folder whose owning process is gone, however new', () => {
|
||||
const deadPid = 2_147_483_000
|
||||
const kill = vi.spyOn(process, 'kill').mockImplementation((pid) => {
|
||||
if (pid === deadPid) {
|
||||
throw Object.assign(new Error('no such process'), { code: 'ESRCH' })
|
||||
}
|
||||
return true
|
||||
})
|
||||
try {
|
||||
mkdirSync(join(baseDirectory, `orca-launch-file-${deadPid}-bbbbbb`))
|
||||
sweepStaleLaunchFiles({ baseDirectory })
|
||||
expect(readdirSync(baseDirectory)).toEqual([])
|
||||
} finally {
|
||||
kill.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the age backstop for a folder that names no owner', () => {
|
||||
const now = Date.now()
|
||||
const stale = join(baseDirectory, 'orca-launch-file-aaaaaa')
|
||||
const fresh = join(baseDirectory, 'orca-launch-file-bbbbbb')
|
||||
@@ -195,10 +227,19 @@ describe('sweepStaleLaunchFiles', () => {
|
||||
for (const directory of [stale, fresh, unrelated]) {
|
||||
mkdirSync(directory)
|
||||
}
|
||||
const staleSeconds = (now - LAUNCH_FILE_STALE_MS - 1000) / 1000
|
||||
utimesSync(stale, staleSeconds, staleSeconds)
|
||||
utimesSync(unrelated, staleSeconds, staleSeconds)
|
||||
ageOut(stale, now)
|
||||
ageOut(unrelated, now)
|
||||
sweepStaleLaunchFiles({ baseDirectory, now })
|
||||
expect(readdirSync(baseDirectory).sort()).toEqual(['orca-launch-file-bbbbbb', 'unrelated'])
|
||||
})
|
||||
|
||||
it('names the owning process in each folder it writes', () => {
|
||||
const { launchFile } = carryInLaunchFile('x', true)
|
||||
const written = writeLaunchFile({
|
||||
launchFile: { ...launchFile, quoting: 'posix' },
|
||||
baseDirectory
|
||||
})
|
||||
expect(written.directory).toMatch(new RegExp(`orca-launch-file-${process.pid}-[^/\\\\]+$`))
|
||||
removeLaunchFile(written)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,10 +25,22 @@ import type { WslLaunchDirectory } from './wsl-launch-directory'
|
||||
|
||||
const LAUNCH_FILE_DIR_PREFIX = 'orca-launch-file-'
|
||||
const LAUNCH_FILE_NAME = 'task-context.md'
|
||||
/** `orca-launch-file-<owner pid>-<random>`: the process whose sessions read the file. */
|
||||
const OWNED_LAUNCH_FILE_DIR = /^orca-launch-file-(\d+)-/
|
||||
|
||||
/** A running agent may re-read its task file, so leftovers get a day, not an hour. */
|
||||
/** Backstop for a folder that names no owner: a running agent may re-read its task file. */
|
||||
export const LAUNCH_FILE_STALE_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/** Whether `pid` is a live process; one this process may not signal still counts as live. */
|
||||
function processIsAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
return error instanceof Error && 'code' in error && error.code === 'EPERM'
|
||||
}
|
||||
}
|
||||
|
||||
// Characters one of the three quotings ends or expands on: `'` (POSIX, PowerShell, which also
|
||||
// honours the typographic single quotes), `"` and `%` (cmd).
|
||||
const QUOTE_SENSITIVE_PATH_CHARACTER = /['"%\u2018-\u201b]/
|
||||
@@ -111,7 +123,9 @@ export function writeLaunchFile(args: {
|
||||
// Why realpath: an agent matches its read grant against the resolved path (macOS $TMPDIR sits
|
||||
// under /var -> /private/var), so the pointer and the granted directory both name that form.
|
||||
// A UNC path into WSL is named by its Linux path instead, which the distro resolves itself.
|
||||
const created = mkdtempSync(join(baseDirectory, LAUNCH_FILE_DIR_PREFIX))
|
||||
// Why the pid: this process owns the sessions that read the file, which it removes when they
|
||||
// end; the sweep leaves it alone while this process lives, however long that is.
|
||||
const created = mkdtempSync(join(baseDirectory, `${LAUNCH_FILE_DIR_PREFIX}${process.pid}-`))
|
||||
directory = wsl ? created : realpathSync(created)
|
||||
const launchDirectory = wsl ? posix.join(wsl.linuxPath, basename(created)) : directory
|
||||
const path = (wsl ? posix.join : join)(launchDirectory, LAUNCH_FILE_NAME)
|
||||
@@ -219,7 +233,11 @@ export function removeLaunchFile(written: WrittenLaunchFile | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes launch files a crashed host left behind; age-gated so a live agent's file survives. */
|
||||
/**
|
||||
* Removes launch files a crashed host left behind. A folder whose owner process is alive is never
|
||||
* touched: that process removes it when the session reading it ends. A dead owner's sessions died
|
||||
* with it. A folder that names no owner falls back to the age backstop.
|
||||
*/
|
||||
export function sweepStaleLaunchFiles(args: { baseDirectory?: string; now?: number }): void {
|
||||
const baseDirectory = args.baseDirectory ?? tmpdir()
|
||||
const now = args.now ?? Date.now()
|
||||
@@ -234,8 +252,12 @@ export function sweepStaleLaunchFiles(args: { baseDirectory?: string; now?: numb
|
||||
continue
|
||||
}
|
||||
const directory = join(baseDirectory, name)
|
||||
const owner = OWNED_LAUNCH_FILE_DIR.exec(name)?.[1]
|
||||
try {
|
||||
if (now - statSync(directory).mtimeMs > LAUNCH_FILE_STALE_MS) {
|
||||
const orphaned = owner
|
||||
? !processIsAlive(Number(owner))
|
||||
: now - statSync(directory).mtimeMs > LAUNCH_FILE_STALE_MS
|
||||
if (orphaned) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describeLaunchHost } from './launch-host'
|
||||
|
||||
describe('whether a launch host can prove its agent is in front before a paste', () => {
|
||||
it.each([
|
||||
['a local macOS host', false, false, 'darwin', 'darwin', true],
|
||||
['a local Linux host', false, false, 'linux', 'linux', true],
|
||||
['a local Windows host', false, false, 'win32', 'win32', false],
|
||||
// The pane runs in the distro, but the reads run on the Windows host.
|
||||
['a local WSL pane', false, false, 'linux', 'win32', false],
|
||||
['an SSH Linux host from Windows', true, false, 'linux', 'win32', true],
|
||||
['an SSH Windows host', true, false, 'win32', 'darwin', false],
|
||||
['a paired Linux Orca from Windows', false, true, 'linux', 'win32', true],
|
||||
['a paired Windows Orca from macOS', false, true, 'win32', 'darwin', false]
|
||||
] as const)('%s', (_label, isRemote, paired, launchPlatform, hostPlatform, proves) => {
|
||||
expect(describeLaunchHost({ isRemote, paired, launchPlatform, hostPlatform })).toEqual({
|
||||
paired,
|
||||
provesAgentInFront: proves
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
/** What the host a launch runs on can do with its prompt, derived in one place from where it runs. */
|
||||
export type LaunchHost = {
|
||||
/** Another Orca this client drives, possibly an older one: it is sent a command it may neither
|
||||
* 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. */
|
||||
provesAgentInFront: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The launch host's facts. An SSH or paired host is judged by its own platform, a local one (a WSL
|
||||
* pane included, whose process reads run on Windows) by the machine running this Orca.
|
||||
*/
|
||||
export function describeLaunchHost(args: {
|
||||
/** The platform of the shell that types the launch line. */
|
||||
launchPlatform: NodeJS.Platform
|
||||
isRemote: boolean
|
||||
/** The platform of the machine this Orca runs on. */
|
||||
hostPlatform: NodeJS.Platform
|
||||
paired: boolean
|
||||
}): LaunchHost {
|
||||
const runsElsewhere = args.isRemote || args.paired
|
||||
return {
|
||||
paired: args.paired,
|
||||
provesAgentInFront: (runsElsewhere ? args.launchPlatform : args.hostPlatform) !== 'win32'
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,14 @@ describe('the one launch-prompt decision every launch path builds through', () =
|
||||
...base,
|
||||
prompt: 'fix it',
|
||||
platform: 'linux',
|
||||
launchHostIsPaired: true
|
||||
host: { paired: true, provesAgentInFront: true }
|
||||
})
|
||||
expect(short?.carry).toBe('on-line')
|
||||
const long = planLaunchForTest({
|
||||
...base,
|
||||
prompt: 'fix it\nthen run the tests',
|
||||
platform: 'linux',
|
||||
launchHostIsPaired: true
|
||||
host: { paired: true, provesAgentInFront: true }
|
||||
})
|
||||
expect(long?.carry).toBe('paste-after-ready')
|
||||
expect(long?.pasteAfterReady).toBe('fix it\nthen run the tests')
|
||||
|
||||
@@ -5,13 +5,31 @@ import type { TuiAgent } from './tui-agent'
|
||||
import { RUNTIME_CAPABILITIES } from './protocol-version'
|
||||
import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability'
|
||||
import { TYPED_STARTUP_LINE_BUDGET_BYTES, typedStartupLineFits } from './typed-startup-line'
|
||||
import { describeLaunchHost } from './launch-host'
|
||||
import { WINDOWS_TYPED_LINE_MAX_CHARS } from './launch-prompt-carry'
|
||||
|
||||
function plan(
|
||||
agent: TuiAgent,
|
||||
prompt: string,
|
||||
extra: Partial<Omit<AgentLaunchPromptArgs, 'agent' | 'prompt'>> = {}
|
||||
) {
|
||||
return planLaunchPrompt({ agent, prompt, cmdOverrides: {}, platform: 'darwin', ...extra })
|
||||
const platform = extra.platform ?? 'darwin'
|
||||
// A local launch on that platform, by a caller that pastes, unless the test says otherwise.
|
||||
const host = describeLaunchHost({
|
||||
launchPlatform: platform,
|
||||
isRemote: false,
|
||||
hostPlatform: platform,
|
||||
paired: false
|
||||
})
|
||||
return planLaunchPrompt({
|
||||
agent,
|
||||
prompt,
|
||||
cmdOverrides: {},
|
||||
platform,
|
||||
host,
|
||||
canPasteAfterReady: true,
|
||||
...extra
|
||||
})
|
||||
}
|
||||
|
||||
describe('where a launch prompt rides', () => {
|
||||
@@ -62,6 +80,15 @@ describe('where a launch prompt rides', () => {
|
||||
expect(planned.cleanPlan.launchCommand).not.toContain('gggg')
|
||||
})
|
||||
|
||||
// Why: main started such an agent with the whole prompt on its line; a caller with no paste
|
||||
// (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 })
|
||||
expect(planned?.carry).toBe('on-line')
|
||||
expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toContain('gggg')
|
||||
})
|
||||
|
||||
it('leaves a stdin-after-start agent’s prompt for the paste', () => {
|
||||
const planned = plan('aider', 'fix it')
|
||||
expect(planned).toMatchObject({ carry: 'paste-after-ready', text: 'fix it' })
|
||||
@@ -78,48 +105,58 @@ describe('where a launch prompt rides', () => {
|
||||
})
|
||||
|
||||
describe('a host that types the line raw', () => {
|
||||
// Why: the user's own words stay in the agent's history wherever a Windows line carries them.
|
||||
it.each<['cmd' | 'powershell']>([['cmd'], ['powershell']])(
|
||||
'keeps a %s line to the typed budget and points Claude at a file past it',
|
||||
'keeps an exact %s line up to cmd’s cap and points Claude at a file past it',
|
||||
(shell) => {
|
||||
const short = plan('claude', 'fix it', { platform: 'win32', shell })
|
||||
expect(short?.carry).toBe('on-line')
|
||||
const long = plan('claude', 'y'.repeat(600), { platform: 'win32', shell })
|
||||
expect(long?.carry).toBe('launch-file')
|
||||
expect(plan('claude', 'fix it', { platform: 'win32', shell })?.carry).toBe('on-line')
|
||||
expect(plan('claude', 'y'.repeat(4_000), { platform: 'win32', shell })?.carry).toBe('on-line')
|
||||
const past = plan('claude', 'y'.repeat(WINDOWS_TYPED_LINE_MAX_CHARS), {
|
||||
platform: 'win32',
|
||||
shell
|
||||
})
|
||||
expect(past?.carry).toBe('launch-file')
|
||||
}
|
||||
)
|
||||
|
||||
it('points Codex at a file for a Windows-damaged prompt, and pastes it for Gemini', () => {
|
||||
// The measured matrix (#23962 W-1): PowerShell splits at `"`, turns a trailing `\` into `"`, and
|
||||
// a .cmd shim expands `%NAME%`; everything else arrives byte for byte.
|
||||
it.each([
|
||||
['a double quote', 'say "hi"', 'launch-file'],
|
||||
['a trailing backslash', 'see C:\\dir\\', 'launch-file'],
|
||||
['a %NAME% pair', 'echo %PATH%', 'launch-file'],
|
||||
['a lone percent and an apostrophe', "it's 100% done", 'on-line']
|
||||
] as const)(
|
||||
'carries a PowerShell prompt with %s by the measured matrix',
|
||||
(_label, text, carry) => {
|
||||
expect(plan('claude', text, { platform: 'win32', shell: 'powershell' })?.carry).toBe(carry)
|
||||
}
|
||||
)
|
||||
|
||||
it('points Codex at a file for a Windows-damaged prompt, and keeps it on Gemini’s line', () => {
|
||||
const prompt = 'fix the build\nthen run the tests'
|
||||
expect(plan('codex', prompt, { platform: 'win32', shell: 'cmd' })?.carry).toBe('launch-file')
|
||||
expect(plan('gemini', prompt, { platform: 'win32', shell: 'cmd' })).toMatchObject({
|
||||
carry: 'paste-after-ready',
|
||||
text: prompt
|
||||
})
|
||||
})
|
||||
|
||||
it('points a PowerShell prompt with a double quote at a file', () => {
|
||||
expect(plan('claude', 'say "hi"', { platform: 'win32', shell: 'powershell' })?.carry).toBe(
|
||||
'launch-file'
|
||||
)
|
||||
// A Windows host cannot prove Gemini is in front to paste into, so it types it as main did.
|
||||
expect(plan('gemini', prompt, { platform: 'win32', shell: 'cmd' })?.carry).toBe('on-line')
|
||||
})
|
||||
|
||||
// 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', () => {
|
||||
// A local WSL pane: a Linux line, on a Windows host that cannot read what holds it.
|
||||
const extra = {
|
||||
platform: 'win32' as const,
|
||||
shell: 'cmd' as const,
|
||||
hostProvesAgentInFront: false
|
||||
platform: 'linux' as const,
|
||||
host: { paired: false, provesAgentInFront: false }
|
||||
}
|
||||
const prompt = 'fix the build\nthen run the tests'
|
||||
expect(plan('gemini', prompt, extra)?.carry).toBe('on-line')
|
||||
expect(plan('gemini', 'y'.repeat(600), extra)?.carry).toBe('on-line')
|
||||
expect(plan('claude', prompt, extra)?.carry).toBe('launch-file')
|
||||
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')
|
||||
})
|
||||
|
||||
it('pastes on a paired host what its line cannot carry typed, even for Claude', () => {
|
||||
const extra = { platform: 'linux' as const, launchHostIsPaired: true }
|
||||
const extra = { platform: 'linux' as const, host: { paired: true, provesAgentInFront: true } }
|
||||
expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line')
|
||||
expect(plan('claude', 'first line\nsecond line', extra)?.carry).toBe('paste-after-ready')
|
||||
expect(plan('claude', 'z'.repeat(20_000), extra)?.carry).toBe('paste-after-ready')
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './tui-agent-startup-shell'
|
||||
import { hasControlByte, typedStartupLineFits } from './typed-startup-line'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
import type { LaunchHost } from './launch-host'
|
||||
|
||||
/**
|
||||
* Whether a Windows shell would damage `prompt` as one quoted argument. No quoting keeps a control
|
||||
@@ -75,26 +76,41 @@ export type CarriedPlanArgs = {
|
||||
shell?: AgentStartupShell
|
||||
/** The file `prompt` already points at, when the caller wrote its own. */
|
||||
launchFile?: LaunchFile
|
||||
/** Another Orca this client drives, possibly an older one: it is sent a command it may neither
|
||||
* stage nor accompany with a launch file. Temporary, until paired hosts advertise both. */
|
||||
launchHostIsPaired?: boolean
|
||||
/** False where the host cannot prove the launched agent holds its terminal (a Windows host),
|
||||
* so it refuses a paste (`launched-agent-foreground`). */
|
||||
hostProvesAgentInFront?: boolean
|
||||
/** 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
|
||||
}
|
||||
|
||||
export function agentReadsLaunchFile(agent: TuiAgent): boolean {
|
||||
return TUI_AGENT_CONFIG[agent].readsLaunchFile === true
|
||||
}
|
||||
|
||||
/** cmd.exe's documented line cap, the smallest of the Windows shells Orca types into. */
|
||||
export const WINDOWS_TYPED_LINE_MAX_CHARS = 8191
|
||||
|
||||
/**
|
||||
* The one carry rule. The prompt rides the agent's line, which a host that stages (POSIX, SSH, WSL)
|
||||
* stages when it is long or multi-line. It rides a launch file when it is past the argv ceiling,
|
||||
* when a Windows shell would damage it, or when the host types the line raw (a Windows host, a
|
||||
* paired Orca) and the line is past the typed budget. A launch file goes only to an agent measured
|
||||
* reading one, on a host that writes it; otherwise the agent starts clean and the prompt is pasted
|
||||
* once it is ready, except where the host cannot prove the agent is in front to paste into: there
|
||||
* the line carries it, as main typed it.
|
||||
* Whether a Windows shell carries `prompt` exactly on `line`. Nothing stages a Windows line, so it
|
||||
* must hold no control byte, fit cmd's cap, and survive the shell's quoting (measured matrix:
|
||||
* `windowsShellDamagesPrompt`).
|
||||
*/
|
||||
function windowsLineCarriesExactly(prompt: string, line: string, shell: AgentStartupShell) {
|
||||
return (
|
||||
!hasControlByte(line) &&
|
||||
line.length <= WINDOWS_TYPED_LINE_MAX_CHARS &&
|
||||
!windowsShellDamagesPrompt(prompt, shell)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchCommand: string }>(
|
||||
args: A,
|
||||
@@ -120,12 +136,13 @@ export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchC
|
||||
return pasteAfterReady()
|
||||
}
|
||||
const lineOrPaste = (): LaunchPromptPlan<P> | null => {
|
||||
const plan = args.hostProvesAgentInFront === false ? buildLine(args) : null
|
||||
const pasteIsSafe = args.host.provesAgentInFront && args.canPasteAfterReady
|
||||
const plan = pasteIsSafe ? null : buildLine(args)
|
||||
return plan ? { carry: 'on-line', plan } : pasteAfterReady()
|
||||
}
|
||||
const viaLaunchFile = (): LaunchPromptPlan<P> | null => {
|
||||
// Why: an agent not measured reading the file would stop on an approval or refuse the path.
|
||||
if (args.launchHostIsPaired === true || !agentReadsLaunchFile(args.agent)) {
|
||||
if (args.host.paired || !agentReadsLaunchFile(args.agent)) {
|
||||
return lineOrPaste()
|
||||
}
|
||||
const pointer = carryInLaunchFile(text, false)
|
||||
@@ -134,23 +151,25 @@ export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchC
|
||||
plan && { carry: 'launch-file', plan, launchFile: withQuoting(pointer.launchFile, shell) }
|
||||
)
|
||||
}
|
||||
const readsEnv = mode === 'hermes-query'
|
||||
if (
|
||||
text.length > MAX_INLINE_LAUNCH_PROMPT_CHARS ||
|
||||
(!readsEnv && windowsShellDamagesPrompt(text, shell))
|
||||
) {
|
||||
if (text.length > MAX_INLINE_LAUNCH_PROMPT_CHARS) {
|
||||
return viaLaunchFile()
|
||||
}
|
||||
const plan = buildLine(args)
|
||||
const readsEnv = mode === 'hermes-query'
|
||||
if (!plan) {
|
||||
// Hermes reads its prompt from the env and refuses one past that budget, counted in bytes; a
|
||||
// one-character query building proves the budget, not the command, refused it.
|
||||
return readsEnv && buildLine({ ...args, prompt: '.' }) ? viaLaunchFile() : null
|
||||
}
|
||||
// Why the typed budget (#24257's): nothing stages a line the host types raw, and a long typed
|
||||
// line is truncated or submitted early. Hermes's line never holds the text.
|
||||
const hostTypesLineRaw = args.launchHostIsPaired === true || args.platform === 'win32'
|
||||
if (!readsEnv && hostTypesLineRaw && !typedStartupLineFits(plan.launchCommand)) {
|
||||
// Hermes's line never holds the text.
|
||||
if (readsEnv) {
|
||||
return { carry: 'on-line', plan }
|
||||
}
|
||||
if (args.platform === 'win32' && !windowsLineCarriesExactly(text, plan.launchCommand, shell)) {
|
||||
return viaLaunchFile()
|
||||
}
|
||||
// Why #24257's typed budget: an older paired host may type the line raw, truncated past it.
|
||||
if (args.host.paired && !typedStartupLineFits(plan.launchCommand)) {
|
||||
return viaLaunchFile()
|
||||
}
|
||||
return { carry: 'on-line', plan }
|
||||
|
||||
@@ -87,7 +87,7 @@ export function parseLaunchFile(value: unknown): LaunchFile | undefined {
|
||||
return { placeholder, content, sensitive, ...(knownQuoting ? { quoting: knownQuoting } : {}) }
|
||||
}
|
||||
|
||||
const LAUNCH_FILE_UNAVAILABLE_CODE = 'launch_file_unavailable'
|
||||
export const LAUNCH_FILE_UNAVAILABLE_CODE = 'launch_file_unavailable'
|
||||
|
||||
/** The refusal a host sends when it could not write what carries the prompt (a launch file, or the
|
||||
* staged script holding a long line); it reaches the user as is. */
|
||||
@@ -106,3 +106,8 @@ export function describeLaunchFileUnavailable(
|
||||
export function isLaunchFileUnavailableMessage(message: string): boolean {
|
||||
return message.includes(LAUNCH_FILE_UNAVAILABLE_CODE)
|
||||
}
|
||||
|
||||
/** The refusal as it reaches main from any host: its code is its type across process boundaries. */
|
||||
export function isLaunchFileRefusal(error: unknown): boolean {
|
||||
return error instanceof Error && isLaunchFileUnavailableMessage(error.message)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LaunchFile } from './launch-prompt-file'
|
||||
import { describeLaunchHost } from './launch-host'
|
||||
import type { LaunchPromptCarry } from './launch-prompt-carry'
|
||||
import {
|
||||
planLaunchPrompt,
|
||||
@@ -17,13 +18,28 @@ 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: AgentLaunchPromptArgs & { allowEmptyPromptLaunch?: boolean }
|
||||
args: Omit<AgentLaunchPromptArgs, 'host' | 'canPasteAfterReady'> &
|
||||
Partial<Pick<AgentLaunchPromptArgs, 'host' | 'canPasteAfterReady'>> & {
|
||||
allowEmptyPromptLaunch?: boolean
|
||||
}
|
||||
): PlannedLaunchForTest | null {
|
||||
const { allowEmptyPromptLaunch, ...launchArgs } = args
|
||||
if (allowEmptyPromptLaunch !== true && !args.prompt.trim()) {
|
||||
return null
|
||||
}
|
||||
const planned = planLaunchPrompt(launchArgs)
|
||||
const planned = planLaunchPrompt({
|
||||
...launchArgs,
|
||||
// A local launch on the platform under test, by a caller that pastes, unless the test says.
|
||||
host:
|
||||
args.host ??
|
||||
describeLaunchHost({
|
||||
launchPlatform: args.platform,
|
||||
isRemote: args.isRemote === true,
|
||||
hostPlatform: args.platform,
|
||||
paired: false
|
||||
}),
|
||||
canPasteAfterReady: args.canPasteAfterReady ?? true
|
||||
})
|
||||
if (!planned) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type AgentStartupShell
|
||||
} from './tui-agent-startup-shell'
|
||||
import type { LaunchFile } from './launch-prompt-file'
|
||||
import type { LaunchHost } from './launch-host'
|
||||
import {
|
||||
carryLaunchPrompt,
|
||||
launchFileDirectoryGrant,
|
||||
@@ -72,10 +73,10 @@ export type AgentLaunchPromptArgs = AgentStartupPlanInputs & {
|
||||
prompt: string
|
||||
/** The file `prompt` already points at, when the caller wrote its own. */
|
||||
launchFile?: LaunchFile
|
||||
/** Another Orca this client drives; see `CarriedPlanArgs`. */
|
||||
launchHostIsPaired?: boolean
|
||||
/** See `CarriedPlanArgs`. */
|
||||
hostProvesAgentInFront?: boolean
|
||||
host: LaunchHost
|
||||
/** See `CarriedPlanArgs`. */
|
||||
canPasteAfterReady: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,7 +90,9 @@ export function planLaunchPrompt(
|
||||
}
|
||||
|
||||
/** Builds the line with `prompt` on it; the carry rule decides whether it should. */
|
||||
function buildPlanWithPromptOnLine(args: AgentLaunchPromptArgs): AgentStartupPlan | null {
|
||||
function buildPlanWithPromptOnLine(
|
||||
args: AgentStartupPlanInputs & { prompt: string; launchFile?: LaunchFile }
|
||||
): AgentStartupPlan | null {
|
||||
const { agent, prompt, cmdOverrides, platform } = args
|
||||
const shell = resolveStartupShell(platform, args.shell)
|
||||
const trimmedPrompt = prompt.trim()
|
||||
|
||||
@@ -76,18 +76,18 @@ describe('a prompt a Windows shell would damage on the launch line', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('launches clean and leaves the prompt for the paste when the host writes no launch file', () => {
|
||||
it('keeps the prompt on a paired Windows host’s line, which writes no file and cannot paste', () => {
|
||||
const startup = planLaunchForTest({
|
||||
agent: 'claude',
|
||||
prompt: 'fix the build\nthen run the tests',
|
||||
cmdOverrides: {},
|
||||
platform: 'win32',
|
||||
shell: 'powershell',
|
||||
launchHostIsPaired: true
|
||||
host: { paired: true, provesAgentInFront: false }
|
||||
})
|
||||
expect(startup?.launchFile).toBeUndefined()
|
||||
expect(startup?.launchCommand).toBe('claude')
|
||||
expect(startup?.pasteAfterReady).toBe('fix the build\nthen run the tests')
|
||||
expect(startup?.launchCommand).toContain('fix the build')
|
||||
expect(startup?.pasteAfterReady).toBeNull()
|
||||
})
|
||||
|
||||
it('tells its own pointer apart from a prompt that merely mentions one', () => {
|
||||
|
||||
Reference in New Issue
Block a user