Files
orca/src/cli/agent-launch-request.ts
T
Brennan Benson 6e973fa6d4 A launch can name chat or terminal: agent.launch gets a surface pick and the CLI gets --surface chat|terminal (#31191)
* feat(agent.launch): a launch can name chat or terminal, and the CLI gets --surface chat|terminal

agent.launch gains an optional `surface` field, read by hosts advertising
agent.launch.surface.v1. `terminal` always runs the agent's CLI; `chat` runs a
chat even when the user's default is a terminal, and one the host can prove it
cannot open is refused with a typed no-effects error before anything is
created, so a retry answers with the same refusal. The pick is part of the
replay fingerprint. `orca agent launch` and `orca worktree create --agent`
accept --surface chat|terminal and refuse it on a host that would drop it,
including the legacy worktree.create fallback.

* test(agent.launch): pin the surface pick beside the other pre-start failures

* fix(agent.launch): settle requested surfaces from resolved workspace facts

* fix(agent.launch): describe fallback boundaries and correct test types
2026-10-20 16:45:05 -07:00

179 lines
7.4 KiB
TypeScript

/**
* How the CLI starts an agent: through `agent.launch`, the host's one launch point that the desktop
* and phone use too. `--surface chat|terminal` picks a chat or a terminal for this launch; without
* it the user's Chat UI setting does. Either way the receipt says which ran and why.
*/
import { randomUUID } from 'node:crypto'
import type { AgentLaunchPrompt, AgentLaunchResult } from '../shared/agent-launch-intent'
import { AGENT_LAUNCH_SURFACES, type AgentLaunchSurface } from '../shared/agent-launch-surface'
import { checkAgentLaunchModelPreferences } from '../shared/agent-launch-model-preferences'
import {
AGENT_LAUNCH_RUNTIME_CAPABILITY,
AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY,
AGENT_LAUNCH_LOCAL_PRESENTATION_RUNTIME_CAPABILITY,
AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY,
AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY
} from '../shared/agent-launch-runtime-capability'
import { createStructuredAgentSessionOperationId } from '../shared/structured-agent-session-mutation'
import { parseAgentSessionOperationTimestamp } from '../shared/agent-session-host-authority'
import type { RuntimeStatus } from '../shared/runtime-types'
import type { TuiAgent } from '../shared/tui-agent'
import { isTuiAgent } from '../shared/tui-agent-config'
import {
getOptionalStringFlag,
getRequiredStringFlag,
getRequiredStringFlagAllowingEmpty,
rejectValuelessFlag
} from './flags'
import { RuntimeClientError, type RuntimeClient, type RuntimeRpcSuccess } from './runtime-client'
import { withCliAgentLaunchRecovery } from './agent-launch-recovery'
type Flags = Map<string, string | boolean>
/** `create` is the `worktree.create` request the CLI already builds; the host strips agent fields. */
export type CliAgentLaunchTarget =
| { kind: 'existing'; worktree: string }
| { kind: 'create-worktree'; create: Record<string, unknown> }
export type CliAgentLaunch = {
agent: TuiAgent
prompt?: AgentLaunchPrompt
sessionOptions?: Record<string, string>
operationId?: string
surface?: AgentLaunchSurface
}
function readSurfaceFlag(flags: Flags): AgentLaunchSurface | undefined {
if (!flags.has('surface')) {
return undefined
}
const surface = getRequiredStringFlag(flags, 'surface')
const known = AGENT_LAUNCH_SURFACES.find((candidate) => candidate === surface)
if (!known) {
throw new RuntimeClientError('invalid_argument', '--surface must be one of: chat, terminal')
}
return known
}
export function parseCliAgent(value: string): TuiAgent {
if (!isTuiAgent(value)) {
throw new RuntimeClientError('invalid_argument', `Unknown TUI agent "${value}"`)
}
return value
}
/** The agent, prompt, model and effort a CLI start asks for; refuses a model or effort the launch
* could not apply, by the same rule as `orchestration worker-start`. */
export function readCliAgentLaunch(
flags: Flags,
agent: TuiAgent,
target: { createsWorktree: boolean }
): CliAgentLaunch {
const checked = checkAgentLaunchModelPreferences({
agent,
model: getOptionalStringFlag(flags, 'model'),
effort: getOptionalStringFlag(flags, 'effort'),
createsWorktree: target.createsWorktree,
// The execution host verifies an OpenCode model itself before it starts one.
openCodeModelLaunchSupported: true
})
if ('refusal' in checked) {
throw new RuntimeClientError(checked.refusal.code, checked.refusal.message)
}
const prompt = flags.get('prompt')
rejectValuelessFlag(prompt, 'prompt')
const operationId = flags.has('retry-launch')
? getRequiredStringFlagAllowingEmpty(flags, 'retry-launch')
: undefined
if (operationId !== undefined && parseAgentSessionOperationTimestamp(operationId) === null) {
throw new RuntimeClientError('invalid_argument', 'Malformed --retry-launch operation ID')
}
const surface = readSurfaceFlag(flags)
return {
agent,
...(surface ? { surface } : {}),
...(operationId ? { operationId } : {}),
// A blank prompt is no prompt: the agent starts with nothing sent.
...(typeof prompt === 'string' && prompt.trim()
? { prompt: { text: prompt, delivery: 'submit' as const } }
: {}),
...(checked.preferences ? { sessionOptions: { ...checked.preferences } } : {})
}
}
export function hasLaunchModelFlags(flags: Flags): boolean {
return flags.has('model') || flags.has('effort')
}
// An older host strips `surface` and follows its own setting, so an explicit pick never reaches it.
export const SURFACE_UNSUPPORTED_MESSAGE =
'This Orca host cannot start an agent as a chat or a terminal on request. Update Orca on the execution host, or omit --surface.'
export async function getAgentLaunchCapabilities(
client: RuntimeClient
): Promise<readonly string[] | null> {
const status = await client.call<RuntimeStatus>('status.get')
const capabilities = status.result.capabilities ?? []
return capabilities.includes(AGENT_LAUNCH_RUNTIME_CAPABILITY) ? capabilities : null
}
export async function callAgentLaunch(
client: RuntimeClient,
launch: CliAgentLaunch,
request: { target: CliAgentLaunchTarget; presentation?: 'focused' | 'background' },
capabilities: readonly string[],
rawArgs?: readonly string[]
): Promise<RuntimeRpcSuccess<AgentLaunchResult & { operationId?: string }>> {
const replaySupported = capabilities.includes(AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY)
if (launch.operationId && !replaySupported) {
throw new RuntimeClientError(
'agent_session_operation_unknown',
'This host cannot replay the earlier launch. Return to the original execution host; do not retry without --retry-launch.'
)
}
if (launch.prompt && !capabilities.includes(AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY)) {
throw new RuntimeClientError(
'incompatible_runtime',
'This Orca host cannot safely carry launch prompts. Update Orca on the execution host.'
)
}
if (launch.surface && !capabilities.includes(AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY)) {
throw new RuntimeClientError('incompatible_runtime', SURFACE_UNSUPPORTED_MESSAGE)
}
if (!capabilities.includes(AGENT_LAUNCH_LOCAL_PRESENTATION_RUNTIME_CAPABILITY)) {
throw new RuntimeClientError(
'incompatible_runtime',
'This Orca host cannot honor CLI launch focus or background behavior. Update Orca on the execution host.'
)
}
const operationId = replaySupported
? (launch.operationId ?? createStructuredAgentSessionOperationId(randomUUID))
: undefined
const params = {
...request,
...launch,
...(operationId ? { operationId } : {}),
launchSource: 'cli'
}
const result = await withCliAgentLaunchRecovery(
client.call<AgentLaunchResult>(operationId ? 'agent.launchReplay' : 'agent.launch', params),
{ operationId, replayed: launch.operationId !== undefined, rawArgs, params }
)
return { ...result, result: { ...result.result, ...(operationId ? { operationId } : {}) } }
}
export function formatAgentLaunch(result: AgentLaunchResult & { operationId?: string }): string {
const { outcome, receipt } = result
return [
`agent: ${outcome.kind === 'structured' ? 'chat session' : 'terminal'}`,
`handle: ${outcome.handle}`,
...(outcome.kind === 'structured' ? [`sessionId: ${outcome.sessionId}`] : []),
`worktreeId: ${result.worktreeId}`,
...(result.operationId ? [`operationId: ${result.operationId}`] : []),
`why: ${receipt.detail}`,
...(result.prompt ? [`prompt: ${result.prompt.outcome}`] : []),
...(result.warning ? [`warning: ${result.warning}`] : [])
].join('\n')
}