mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(cli): launch agents in existing workspaces (#27794)
This commit is contained in:
@@ -217,6 +217,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/runtime/orchestration/orchestration-party-location.test.ts',
|
||||
'src/main/runtime/orchestration/structured-worker-journal-page.test.ts',
|
||||
'src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts',
|
||||
'src/main/runtime/orca-runtime.test.ts',
|
||||
'src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts',
|
||||
'src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts',
|
||||
'src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts',
|
||||
|
||||
@@ -61,6 +61,12 @@ Existing-terminal handoff:
|
||||
ORCA terminal send --terminal <handle> --text "<task brief>" --enter --json
|
||||
```
|
||||
|
||||
## Starting an agent in an existing workspace
|
||||
|
||||
Use `ORCA agent launch --agent <id> [--worktree <selector>] [--prompt <text>] [--model <id>] [--effort <level>] [--focus] --json`. Without `--worktree`, it uses the current worktree or folder workspace. Orca follows the user's Chat UI setting, opening a chat when supported and a terminal otherwise. The result reports `outcome.kind`, `outcome.handle`, and `receipt.detail`.
|
||||
|
||||
If the launch reply is lost, re-issue the exact same command from the same directory and execution host with the returned `--retry-launch <operationId>`. Keep that ID on every retry. If replay cannot establish the outcome, inspect the workspace before deliberately starting another agent.
|
||||
|
||||
## Worktrees
|
||||
|
||||
An Orca worktree is Orca's tracked view of a repo checkout, its metadata, terminals, browser tabs, and UI state.
|
||||
@@ -132,7 +138,7 @@ ORCA worktree create --name task --run-hooks --json
|
||||
- `--activate` and `--run-hooks` reveal the new worktree. `--agent` alone stays in the background.
|
||||
- Let Orca choose setup terminal placement from repo settings, including tab vs split behavior.
|
||||
- If an older installed CLI rejects `--agent`, `--prompt`, or `--setup`, create the worktree normally, then run `ORCA terminal create --worktree <selector> --command "<requested-agent>"` and `ORCA terminal send` if a prompt is needed. This can leave a fallback shell when no default tabs are configured; close it only after confirming it is unused.
|
||||
- `worktree create` makes a new checkout. For a fresh agent in the **current** checkout, use `ORCA terminal create --worktree active --command "codex" --json`.
|
||||
- `worktree create` makes a new checkout. For a fresh agent in the **current** checkout, use `ORCA agent launch --agent codex --json`.
|
||||
|
||||
## Review and issue references
|
||||
|
||||
@@ -195,7 +201,7 @@ ORCA terminal wait --terminal <handle> --for exit --timeout-ms 5000 --json
|
||||
ORCA terminal wait --terminal <handle> --for tui-idle --timeout-ms 300000 --json
|
||||
ORCA terminal create --json
|
||||
ORCA terminal create --title "Worker" --json
|
||||
ORCA terminal create --worktree active --command "codex" --json
|
||||
ORCA terminal create --worktree active --command "pnpm dev" --json
|
||||
ORCA terminal split --terminal <handle> --direction vertical --json
|
||||
ORCA terminal split --terminal <handle> --direction horizontal --command "npm test" --json
|
||||
ORCA terminal rename --terminal <handle> --title "New Name" --json
|
||||
@@ -219,7 +225,7 @@ Terminal rules:
|
||||
- `--wait-submit <seconds>` only observes the same accepted prompt. A timeout returns queued/input-accepted truth without resending; after an ambiguous transport failure, repeat the exact command with the reported `--retry-request <id>`. Both text and `--json` receipts carry the same `warnings`.
|
||||
- An older host reports a legacy `old-host` fallback for an ordinary send and refuses `--wait-submit` or `--retry-request` before input, because it cannot provide durable replay.
|
||||
- For structured coordination, invoke the `orchestration` skill; it uses `orca orchestration ...` commands for messages, handoffs, task DAGs, dispatches, inbox/reply flows, and coordinator loops. A receiving agent can run `orca orchestration check --peek --format --json` to render its unread mail in agent-readable form; this checks the caller's inbox and does not remotely deliver input to another terminal.
|
||||
- Use `terminal create --worktree active --command "<agent>"` for a fresh agent in the current worktree. Use `worktree create --agent <agent>` only for a separate checkout (agent in the first terminal — do not also `terminal create` the same agent).
|
||||
- Use `agent launch` for a fresh agent in the current workspace. Use `worktree create --agent` for a separate checkout (agent in the first terminal — do not also `terminal create` the same agent).
|
||||
- Use `terminal wait --for tui-idle` for agent CLIs such as Claude Code, Gemini, Codex, OMP, Pi, and Grok; always pass `--timeout-ms`.
|
||||
- For long output, use cursor reads. After a limited tail preview, page from `oldestCursor`; after a cursor read, continue with `nextCursor` while `limited` is true and `nextCursor !== latestCursor`.
|
||||
- `--direction horizontal` splits left/right. `--direction vertical` splits top/bottom.
|
||||
|
||||
@@ -40,8 +40,9 @@ absence included, is a checkpoint.
|
||||
| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |
|
||||
| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |
|
||||
|
||||
Model or effort selection does not make a handoff supervised. Never substitute a
|
||||
non-Orca subagent tool when Orca orchestration provenance was requested.
|
||||
Model or effort selection does not make a handoff supervised. For an existing
|
||||
workspace, `orca agent launch` accepts `--model` and `--effort`. Never substitute
|
||||
a non-Orca subagent tool when Orca orchestration provenance was requested.
|
||||
|
||||
## Authority and safety floor
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
|
||||
import { classifyAgentLaunchReplayRefusal } from '../shared/agent-launch-replay-refusal'
|
||||
import {
|
||||
recoverableOrchestrationArgs,
|
||||
resolveOrchestrationCliExecutable
|
||||
} from './runtime/orchestration-recovery-command'
|
||||
|
||||
/** A lost reply never authorizes a new launch, even on hosts without a replay ledger. */
|
||||
export async function withCliAgentLaunchRecovery<T>(
|
||||
launch: Promise<T>,
|
||||
recovery: {
|
||||
operationId?: string
|
||||
replayed?: boolean
|
||||
rawArgs?: readonly string[]
|
||||
params: unknown
|
||||
}
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await launch
|
||||
} catch (error) {
|
||||
// A replay method's ordinary refusal proves no effects; legacy errors may follow a spawn.
|
||||
if (
|
||||
recovery.operationId &&
|
||||
error instanceof RuntimeRpcFailureError &&
|
||||
error.code !== 'runtime_error' &&
|
||||
classifyAgentLaunchReplayRefusal(error, recovery.replayed === true) !== 'unknown'
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
const args = recovery.rawArgs && recoverableOrchestrationArgs(recovery.rawArgs)
|
||||
const retryArgs: string[] = []
|
||||
for (let index = 0; index < (args?.length ?? 0); index++) {
|
||||
const arg = args?.[index]
|
||||
if (arg === '--retry-launch') {
|
||||
index++
|
||||
} else if (arg && !arg.startsWith('--retry-launch=')) {
|
||||
retryArgs.push(arg)
|
||||
}
|
||||
}
|
||||
const recoveryCommand =
|
||||
args && recovery.operationId
|
||||
? [
|
||||
resolveOrchestrationCliExecutable(),
|
||||
...retryArgs,
|
||||
'--retry-launch',
|
||||
recovery.operationId
|
||||
]
|
||||
: undefined
|
||||
const nextSteps = recovery.operationId
|
||||
? [
|
||||
`Re-issue the exact same command from the same directory against the same execution host with --retry-launch ${recovery.operationId}. Keep that ID on every retry; never retry as a fresh launch.`,
|
||||
'If replay reports an unknown or expired outcome, inspect the workspace and agent without sending input before deciding what to do.'
|
||||
]
|
||||
: [
|
||||
'Do not repeat the launch: this older host cannot prove whether it started the agent or sent the prompt.',
|
||||
'Inspect worktree list and terminal list on the execution host, then read the agent without sending input. Confirm the outcome before deliberately starting another agent.'
|
||||
]
|
||||
// Replace transport restart-and-retry advice: the accepted launch may still be running.
|
||||
throw new RuntimeClientError(
|
||||
'agent_session_operation_unknown',
|
||||
'Orca could not confirm the launch outcome; the agent may already be running and the prompt may already be submitted.',
|
||||
{
|
||||
launchOutcome: 'unknown',
|
||||
retrySafe: Boolean(recovery.operationId),
|
||||
...(recovery.operationId ? { operationId: recovery.operationId } : {}),
|
||||
launchRequest: recovery.params,
|
||||
...(recoveryCommand ? { recoveryCommand } : {}),
|
||||
nextSteps,
|
||||
cause:
|
||||
error instanceof Error
|
||||
? error.message.replace(/\bRestart Orca and try again\./g, '')
|
||||
: String(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* How the CLI starts an agent: through `agent.launch`, the host's one launch point that the desktop
|
||||
* and phone use too. The user's Chat UI setting picks a chat or a terminal, and the receipt says
|
||||
* which ran and why; the CLI never picks the surface itself.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentLaunchPrompt, AgentLaunchResult } from '../shared/agent-launch-intent'
|
||||
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
|
||||
} 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,
|
||||
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>
|
||||
|
||||
export type CliAgentLaunchTarget = { kind: 'existing'; worktree: string }
|
||||
|
||||
export type CliAgentLaunch = {
|
||||
agent: TuiAgent
|
||||
prompt?: AgentLaunchPrompt
|
||||
sessionOptions?: Record<string, string>
|
||||
operationId?: string
|
||||
}
|
||||
|
||||
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): CliAgentLaunch {
|
||||
const checked = checkAgentLaunchModelPreferences({
|
||||
agent,
|
||||
model: getOptionalStringFlag(flags, 'model'),
|
||||
effort: getOptionalStringFlag(flags, 'effort'),
|
||||
// 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')
|
||||
}
|
||||
return {
|
||||
agent,
|
||||
...(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 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 (!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')
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { AGENT_LAUNCH_RUNTIME_CAPABILITIES } from '../shared/agent-launch-runtime-capability'
|
||||
import { okFixture } from './test-fixtures'
|
||||
|
||||
/** A host that runs `agent.launch`, as `status.get` reports it. */
|
||||
export function agentLaunchHostFixture() {
|
||||
return okFixture('req_status', { capabilities: [...AGENT_LAUNCH_RUNTIME_CAPABILITIES] })
|
||||
}
|
||||
|
||||
/** What `agent.launch` answers when the user's Chat UI setting opened a chat. */
|
||||
export function agentLaunchFixture(worktreeId: string) {
|
||||
return okFixture('req_launch', {
|
||||
outcome: { kind: 'structured', sessionId: 'codex_1', handle: 'agent_1' },
|
||||
worktreeId,
|
||||
receipt: {
|
||||
mode: 'structured',
|
||||
preferred: 'structured',
|
||||
reason: 'user_default',
|
||||
detail: 'Started a structured chat session, the default for new agent tabs in your settings.'
|
||||
},
|
||||
prompt: { delivery: 'submit', outcome: 'journaled', messageId: 'msg_1' }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { FolderWorkspace } from '../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../shared/project-group-types'
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
import { resolveFolderWorkspaceHost } from '../shared/folder-workspace-execution-host'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
|
||||
import {
|
||||
getCallerExecutionHostId,
|
||||
selectEnclosingWorktreeOnHost
|
||||
} from './caller-host-worktree-selection'
|
||||
import { RuntimeRpcFailureError, type RuntimeClient } from './runtime-client'
|
||||
import { resolveCurrentWorktreeSelector } from './selectors'
|
||||
|
||||
/** Native folders take precedence over enclosing Git checkouts; remote cwd keeps its host rules. */
|
||||
export async function resolveCurrentAgentWorkspaceSelector(
|
||||
cwd: string,
|
||||
client: RuntimeClient
|
||||
): Promise<string> {
|
||||
if (!client.isRemote && getCallerExecutionHostId() === LOCAL_EXECUTION_HOST_ID) {
|
||||
try {
|
||||
const folders = await client.call<{ folderWorkspaces: FolderWorkspace[] }>(
|
||||
'folderWorkspace.list'
|
||||
)
|
||||
const catalog = folders.result.folderWorkspaces.map((folder) => ({
|
||||
id: `folder:${folder.id}`,
|
||||
path: folder.folderPath
|
||||
}))
|
||||
if (selectEnclosingWorktreeOnHost(catalog, cwd, LOCAL_EXECUTION_HOST_ID)) {
|
||||
const [groups, repos] = await Promise.all([
|
||||
client.call<{ groups: ProjectGroup[] }>('projectGroup.list'),
|
||||
client.call<{ repos: Repo[] }>('repo.list')
|
||||
])
|
||||
const state = {
|
||||
folderWorkspaces: folders.result.folderWorkspaces,
|
||||
projectGroups: groups.result.groups,
|
||||
repos: repos.result.repos
|
||||
}
|
||||
const nativeCatalog = catalog.filter(
|
||||
(row) =>
|
||||
resolveFolderWorkspaceHost(state, row.id.slice('folder:'.length)).kind === 'local'
|
||||
)
|
||||
const callerWorkspace = process.env.ORCA_WORKSPACE_ID ?? process.env.ORCA_WORKTREE_ID
|
||||
const callerCatalog = nativeCatalog.filter((row) => row.id === callerWorkspace)
|
||||
const selector =
|
||||
selectEnclosingWorktreeOnHost(callerCatalog, cwd, LOCAL_EXECUTION_HOST_ID) ??
|
||||
selectEnclosingWorktreeOnHost(nativeCatalog, cwd, LOCAL_EXECUTION_HOST_ID)
|
||||
if (selector) {
|
||||
return selector
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// An older host may lack the folder catalog; a failed catalog must not select a wrong checkout.
|
||||
if (!(error instanceof RuntimeRpcFailureError) || error.code !== 'method_not_found') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolveCurrentWorktreeSelector(cwd, client)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -205,6 +205,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
keys: ['agent hooks prepare-codex', 'agent hooks status', 'agent hooks off', 'agent hooks on'],
|
||||
load: async () => (await import('./handlers/agent-hooks.js')).AGENT_HOOK_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'agent-launch',
|
||||
keys: ['agent launch'],
|
||||
load: async () => (await import('./handlers/agent-launch.js')).AGENT_LAUNCH_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'profile-state',
|
||||
keys: ['profile state exports', 'profile state rollback'],
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import {
|
||||
callAgentLaunch,
|
||||
formatAgentLaunch,
|
||||
getAgentLaunchCapabilities,
|
||||
parseCliAgent,
|
||||
readCliAgentLaunch
|
||||
} from '../agent-launch-request'
|
||||
import { getRequiredStringFlag } from '../flags'
|
||||
import { printResult } from '../format'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { getRequiredWorktreeSelector } from '../selectors'
|
||||
import { resolveCurrentAgentWorkspaceSelector } from '../agent-workspace-selector'
|
||||
|
||||
export const AGENT_LAUNCH_HANDLERS: Record<string, CommandHandler> = {
|
||||
'agent launch': async ({ flags, client, cwd, json, rawArgs }) => {
|
||||
const launch = readCliAgentLaunch(flags, parseCliAgent(getRequiredStringFlag(flags, 'agent')))
|
||||
if (client.isRemote && !flags.has('worktree')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Remote agent launch requires --worktree because the client cwd cannot identify a server worktree.'
|
||||
)
|
||||
}
|
||||
const selector = flags.get('worktree')
|
||||
const current = selector === undefined || selector === 'active' || selector === 'current'
|
||||
const worktree = current
|
||||
? await resolveCurrentAgentWorkspaceSelector(cwd, client)
|
||||
: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client)
|
||||
const capabilities = await getAgentLaunchCapabilities(client)
|
||||
if (!capabilities) {
|
||||
if (launch.operationId) {
|
||||
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.'
|
||||
)
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'This Orca host cannot start agents from the CLI. Update Orca on the execution host.'
|
||||
)
|
||||
}
|
||||
const result = await callAgentLaunch(
|
||||
client,
|
||||
launch,
|
||||
{
|
||||
target: { kind: 'existing', worktree },
|
||||
presentation: flags.get('focus') === true ? 'focused' : 'background'
|
||||
},
|
||||
capabilities,
|
||||
rawArgs
|
||||
)
|
||||
printResult(result, json, formatAgentLaunch)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
} = vi.hoisted(() => ({
|
||||
callMock: vi.fn(),
|
||||
runtimeClientConstructorMock: vi.fn(),
|
||||
serveOrcaAppMock: vi.fn(),
|
||||
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
|
||||
addEnvironmentFromPairingCodeMock: vi.fn(),
|
||||
listEnvironmentsMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-client', async () => {
|
||||
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
|
||||
return createRuntimeClientModuleMock({
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('./runtime/environments', () => ({
|
||||
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
|
||||
listEnvironments: listEnvironmentsMock,
|
||||
removeEnvironment: vi.fn(),
|
||||
resolveEnvironment: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', async () => {
|
||||
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
|
||||
return createChildProcessModuleMock(spawnMock)
|
||||
})
|
||||
|
||||
import { main } from './index'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
|
||||
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
|
||||
import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
import { agentLaunchFixture, agentLaunchHostFixture } from './agent-launch-test-fixtures'
|
||||
|
||||
const WORKTREE_ID = 'repo-1::/tmp/repo'
|
||||
|
||||
async function runRefused(argv: string[]): Promise<string> {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
await main(argv, '/tmp/repo')
|
||||
expect(process.exitCode).toBe(1)
|
||||
process.exitCode = priorExitCode
|
||||
return [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')
|
||||
}
|
||||
|
||||
function launchCalls(): unknown[][] {
|
||||
return callMock.mock.calls.filter(
|
||||
([method]) =>
|
||||
method === 'agent.launchReplay' || method === 'agent.launch' || method === 'worktree.create'
|
||||
)
|
||||
}
|
||||
|
||||
describe('orca cli agent launch', () => {
|
||||
useWorktreeAwarenessEnvironment({
|
||||
callMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
})
|
||||
|
||||
it('starts an agent in the current worktree through agent.launch', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_folders', { folderWorkspaces: [] }),
|
||||
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
|
||||
agentLaunchHostFixture(),
|
||||
agentLaunchFixture(WORKTREE_ID)
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'agent',
|
||||
'launch',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--model',
|
||||
'gpt-5.5',
|
||||
'--effort',
|
||||
'high',
|
||||
'--prompt',
|
||||
'Write the design doc',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo/src'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(4, 'agent.launchReplay', {
|
||||
target: { kind: 'existing', worktree: `id:${WORKTREE_ID}` },
|
||||
presentation: 'background',
|
||||
agent: 'codex',
|
||||
prompt: { text: 'Write the design doc', delivery: 'submit' },
|
||||
sessionOptions: { model: 'gpt-5.5', effort: 'high' },
|
||||
launchSource: 'cli',
|
||||
operationId: expect.any(String)
|
||||
})
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0])).result.receipt.reason).toBe('user_default')
|
||||
})
|
||||
|
||||
it('switches to the new tab only with --focus', async () => {
|
||||
queueFixtures(callMock, agentLaunchHostFixture(), agentLaunchFixture(WORKTREE_ID))
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['agent', 'launch', '--agent', 'claude', '--worktree', `id:${WORKTREE_ID}`, '--focus'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('agent.launchReplay', {
|
||||
target: { kind: 'existing', worktree: `id:${WORKTREE_ID}` },
|
||||
presentation: 'focused',
|
||||
agent: 'claude',
|
||||
launchSource: 'cli',
|
||||
operationId: expect.any(String)
|
||||
})
|
||||
})
|
||||
|
||||
it('honors the paired execution host for agent launch', async () => {
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'server-1')
|
||||
queueFixtures(callMock, agentLaunchHostFixture(), agentLaunchFixture('folder:server-folder'))
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await main(
|
||||
[
|
||||
'agent',
|
||||
'launch',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--environment',
|
||||
'server-1',
|
||||
'--worktree',
|
||||
'id:folder:server-folder',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(undefined, 'server-1')
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'agent.launchReplay',
|
||||
expect.objectContaining({ target: { kind: 'existing', worktree: 'id:folder:server-folder' } })
|
||||
)
|
||||
expect(callMock).not.toHaveBeenCalledWith('folderWorkspace.list')
|
||||
})
|
||||
|
||||
it('returns an exact recovery command and removes blind retry advice after a lost reply', async () => {
|
||||
queueFixtures(callMock, agentLaunchHostFixture())
|
||||
callMock.mockRejectedValueOnce(
|
||||
new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
'The Orca runtime closed the connection before responding. Restart Orca and try again.'
|
||||
)
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const previousExitCode = process.exitCode
|
||||
const args = [
|
||||
'agent',
|
||||
'launch',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--worktree',
|
||||
`id:${WORKTREE_ID}`,
|
||||
'--prompt',
|
||||
'Fix it',
|
||||
'--json'
|
||||
]
|
||||
await main(args, '/tmp/repo')
|
||||
process.exitCode = previousExitCode
|
||||
const error = JSON.parse(String(logSpy.mock.calls[0]?.[0])).error
|
||||
expect(error.code).toBe('agent_session_operation_unknown')
|
||||
expect(error.data.retrySafe).toBe(true)
|
||||
expect(error.data.recoveryCommand).toEqual([
|
||||
expect.any(String),
|
||||
...args,
|
||||
'--retry-launch',
|
||||
error.data.operationId
|
||||
])
|
||||
expect(JSON.stringify(error)).not.toContain('Restart Orca and try again')
|
||||
expect(launchCalls()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([['--retry-launch'], ['--retry-launch', ' ']])(
|
||||
'rejects an invalid retry identity before launching: %j',
|
||||
async (...retryArgs) => {
|
||||
const output = await runRefused([
|
||||
'agent',
|
||||
'launch',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--worktree',
|
||||
`id:${WORKTREE_ID}`,
|
||||
...retryArgs,
|
||||
'--json'
|
||||
])
|
||||
expect(JSON.parse(output).error).toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: expect.stringContaining('--retry-launch')
|
||||
})
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps recovery identity when an older replacement host refuses a replay method', async () => {
|
||||
const operationId = `${Date.now()}-${'c'.repeat(32)}`
|
||||
queueFixtures(callMock, agentLaunchHostFixture())
|
||||
callMock.mockRejectedValueOnce(
|
||||
new RuntimeRpcFailureError({
|
||||
id: 'refused',
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method' },
|
||||
_meta: { runtimeId: 'replacement' }
|
||||
})
|
||||
)
|
||||
const output = await runRefused([
|
||||
'agent',
|
||||
'launch',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--worktree',
|
||||
`id:${WORKTREE_ID}`,
|
||||
'--retry-launch',
|
||||
operationId,
|
||||
'--json'
|
||||
])
|
||||
const error = JSON.parse(output).error
|
||||
expect(error.code).toBe('agent_session_operation_unknown')
|
||||
expect(error.data.operationId).toBe(operationId)
|
||||
expect(error.data.nextSteps.join(' ')).toContain('never retry as a fresh launch')
|
||||
expect(launchCalls()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses the caller folder identity after validating its native host and enclosing path', async () => {
|
||||
process.env.ORCA_WORKSPACE_ID = 'folder:caller'
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('folders', {
|
||||
folderWorkspaces: [
|
||||
{ id: 'caller', folderPath: '/tmp/folder', projectGroupId: 'local' },
|
||||
{ id: 'nested', folderPath: '/tmp/folder/src', projectGroupId: 'local' },
|
||||
{ id: 'remote', folderPath: '/tmp/folder', projectGroupId: 'ssh' }
|
||||
]
|
||||
}),
|
||||
okFixture('groups', { groups: [{ id: 'local' }, { id: 'ssh', connectionId: 'ssh-1' }] }),
|
||||
okFixture('repos', { repos: [] }),
|
||||
agentLaunchHostFixture(),
|
||||
agentLaunchFixture('folder:caller')
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await main(['agent', 'launch', '--agent', 'codex', '--json'], '/tmp/folder/src/nested')
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'agent.launchReplay',
|
||||
expect.objectContaining({ target: { kind: 'existing', worktree: 'id:folder:caller' } })
|
||||
)
|
||||
expect(callMock).not.toHaveBeenCalledWith('worktree.list', expect.anything())
|
||||
})
|
||||
|
||||
it('ignores an inherited remote folder identity and uses the local Git fallback', async () => {
|
||||
process.env.ORCA_WORKSPACE_ID = 'folder:remote'
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('folders', {
|
||||
folderWorkspaces: [{ id: 'remote', folderPath: '/tmp/repo', projectGroupId: 'ssh' }]
|
||||
}),
|
||||
okFixture('groups', { groups: [{ id: 'ssh', connectionId: 'ssh-1' }] }),
|
||||
okFixture('repos', { repos: [] }),
|
||||
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
|
||||
agentLaunchHostFixture(),
|
||||
agentLaunchFixture(WORKTREE_ID)
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await main(['agent', 'launch', '--agent', 'codex', '--json'], '/tmp/repo/src')
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'agent.launchReplay',
|
||||
expect.objectContaining({ target: { kind: 'existing', worktree: `id:${WORKTREE_ID}` } })
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses on a host older than agent.launch instead of opening a terminal', async () => {
|
||||
queueFixtures(callMock, okFixture('req_status', { capabilities: [] }))
|
||||
|
||||
const output = await runRefused([
|
||||
'agent',
|
||||
'launch',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--worktree',
|
||||
`id:${WORKTREE_ID}`,
|
||||
'--json'
|
||||
])
|
||||
|
||||
expect(output).toContain('cannot start agents from the CLI')
|
||||
expect(launchCalls()).toEqual([])
|
||||
})
|
||||
})
|
||||
+4
-10
@@ -529,10 +529,7 @@ describe('orca root help', () => {
|
||||
)
|
||||
expect(logSpy.mock.calls[0][0]).toContain('Agent Sessions And Worktrees:')
|
||||
expect(logSpy.mock.calls[0][0]).toContain(
|
||||
'`worktree create --agent` creates a new checkout with an agent.'
|
||||
)
|
||||
expect(logSpy.mock.calls[0][0]).toContain(
|
||||
'orca terminal create --worktree active --command "codex"'
|
||||
'orca agent launch --agent codex --model gpt-6-astra --effort high'
|
||||
)
|
||||
expect(logSpy.mock.calls[0][0]).toContain(
|
||||
'orchestration worker-start Start a supervised worker locally or on a connected Orca server'
|
||||
@@ -723,7 +720,7 @@ describe('orca root help', () => {
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('distinguishes new worktrees from fresh agent terminals in command help', async () => {
|
||||
it('points both create commands at the agent launch for starting agents', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
logSpy.mockClear()
|
||||
|
||||
@@ -731,17 +728,14 @@ describe('orca root help', () => {
|
||||
|
||||
expect(String(logSpy.mock.calls[0][0])).toContain('This creates a new checkout.')
|
||||
expect(String(logSpy.mock.calls[0][0])).toContain(
|
||||
'orca terminal create --worktree active --command "codex"'
|
||||
'For a fresh agent in an existing worktree, use `orca agent launch`'
|
||||
)
|
||||
|
||||
logSpy.mockClear()
|
||||
await main(['terminal', 'create', '--help'], '/tmp/repo')
|
||||
|
||||
const terminalHelp = String(logSpy.mock.calls[0][0])
|
||||
expect(terminalHelp).toContain('Use this, not worktree create')
|
||||
expect(terminalHelp).toContain(
|
||||
'orca terminal create --worktree active --command "codex" --json'
|
||||
)
|
||||
expect(terminalHelp).toContain('use `orca agent launch`')
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+3
-2
@@ -37,7 +37,7 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
|
||||
// so routing the other half produced one listing describing two machines at once.
|
||||
commandPath.join(' ') === 'host list' ||
|
||||
commandPath[0] === 'serve' ||
|
||||
commandPath[0] === 'agent' ||
|
||||
(commandPath[0] === 'agent' && commandPath[1] !== 'launch') ||
|
||||
commandPath[0] === 'vm' ||
|
||||
commandPath[0] === 'agent-context' ||
|
||||
commandPath[0] === 'profile'
|
||||
@@ -180,7 +180,8 @@ export async function main(
|
||||
return client
|
||||
},
|
||||
cwd,
|
||||
json
|
||||
json,
|
||||
rawArgs: argv
|
||||
})
|
||||
} catch (error) {
|
||||
const worktreeSelector = parsed.flags.get('worktree')
|
||||
|
||||
@@ -84,6 +84,9 @@ export const ROOT_HELP_TEXT_PRIMARY = [
|
||||
' worktree rm Remove a worktree from Orca and git',
|
||||
' worktree ps Show a compact orchestration summary across worktrees',
|
||||
'',
|
||||
'Agents:',
|
||||
' agent launch Start an agent in a worktree, as a chat or terminal per your settings',
|
||||
'',
|
||||
'Files:',
|
||||
' file open Open a file as a tab, view stays put (--focus brings you to it)',
|
||||
' file diff Open a file diff as a tab, view stays put (--focus brings you to it)',
|
||||
|
||||
@@ -64,6 +64,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
|
||||
' orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]',
|
||||
' orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--wait-submit <seconds>] [--retry-request <id>] [--json]',
|
||||
' orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]',
|
||||
' orca agent launch --agent <id> [--worktree <selector>] [--prompt <text>] [--model <id>] [--effort <level>] [--focus] [--json]',
|
||||
' orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--shell <shell>] [--focus] [--json]',
|
||||
' orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]',
|
||||
' orca terminal switch [--terminal <handle>] [--json]',
|
||||
@@ -117,7 +118,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
|
||||
'Agent Sessions And Worktrees:',
|
||||
' `worktree create --agent` creates a new checkout with an agent.',
|
||||
' To start a fresh agent in the current worktree, use:',
|
||||
' orca terminal create --worktree active --command "codex"',
|
||||
' orca agent launch --agent codex --model gpt-6-astra --effort high',
|
||||
'',
|
||||
'Browser Workflow:',
|
||||
' 1. Create or navigate: orca tab create --url https://example.com',
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
export const AGENT_LAUNCH_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['agent', 'launch'],
|
||||
summary: 'Start an agent in an existing workspace, as a chat or a terminal per your settings',
|
||||
usage:
|
||||
'orca agent launch --agent <id> [--worktree <selector>] [--prompt <text>] [--model <id>] [--effort <level>] [--focus] [--retry-launch <operationId>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'agent',
|
||||
'worktree',
|
||||
'prompt',
|
||||
'model',
|
||||
'effort',
|
||||
'focus',
|
||||
'retry-launch'
|
||||
],
|
||||
notes: [
|
||||
'Starts the agent the way a new agent tab does: as a chat when your Chat UI setting is on and the agent and host support it, otherwise as a terminal. The result says which (`outcome.kind`) and why (`receipt.detail`).',
|
||||
'--worktree defaults to the native folder workspace or worktree containing the current directory. For a new checkout with its agent, use `orca worktree create --agent`.',
|
||||
'--model takes a provider model id and --effort a reasoning effort for that model; --effort requires --model. They apply to either surface.',
|
||||
'The tab opens without switching focus; pass --focus to switch to it.',
|
||||
'If the launch reply is lost, re-issue the same command from the same directory on the same host with --retry-launch <operationId> from the error. Never retry as a fresh launch; older hosts require inspecting the workspace first.'
|
||||
],
|
||||
examples: [
|
||||
'orca agent launch --agent codex --json',
|
||||
'orca agent launch --agent codex --model gpt-6-astra --effort high --prompt "Write the design doc" --json',
|
||||
'orca agent launch --agent claude --worktree path:/projects/myapp --focus'
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -93,7 +93,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
],
|
||||
notes: [
|
||||
'Repeat --reference to attach full issue or review URLs before setup or agent startup. Cannot combine with legacy link flags.',
|
||||
'This creates a new checkout. For a fresh agent in an existing worktree, use `orca terminal create --worktree active --command "codex"` instead.',
|
||||
'This creates a new checkout. For a fresh agent in an existing worktree, use `orca agent launch` instead.',
|
||||
'By default, Orca records the new worktree as a child of the caller context when it can infer one from the Orca terminal or current directory.',
|
||||
'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.',
|
||||
'Use --project with --host to create on a ready project host setup without spelling the backing repo id.',
|
||||
@@ -213,15 +213,15 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'command', 'shell', 'title', 'focus'],
|
||||
notes: [
|
||||
'Creates a visible terminal tab without switching focus when possible; falls back to a background handle if the UI cannot adopt it. Pass --focus to switch to it.',
|
||||
'Use this, not worktree create, for a fresh agent in the current checkout.',
|
||||
'This creates a terminal and runs --command in it. To start an agent in an existing workspace, use `orca agent launch`, which follows your Chat UI setting.',
|
||||
'--shell picks the shell the terminal IS on a Windows host (cmd.exe, powershell.exe, pwsh.exe, wsl.exe, bash.exe, git-bash); --command is typed into whatever shell the host started, so `--command cmd.exe` leaves a cmd running INSIDE the default shell and exiting it drops back to that shell.',
|
||||
'A host that cannot apply --shell refuses the create rather than quietly spawning its default shell: macOS and Linux execution hosts spawn the login shell, terminals routed over SSH resolve their shell on the SSH host, a --shell that contradicts the project execution runtime (WSL vs Windows host) is refused, and an Orca host older than --shell is refused by the CLI.'
|
||||
],
|
||||
examples: [
|
||||
'orca terminal create --json',
|
||||
'orca terminal create --worktree active --command "codex" --json',
|
||||
'orca terminal create --worktree path:/projects/myapp --title "RUNNER" --command "opencode"',
|
||||
'orca terminal create --worktree path:/projects/myapp --command "opencode" --focus',
|
||||
'orca terminal create --worktree active --command "pnpm dev" --json',
|
||||
'orca terminal create --worktree path:/projects/myapp --title "RUNNER" --command "npm test"',
|
||||
'orca terminal create --worktree path:/projects/myapp --command "pnpm dev" --focus',
|
||||
'orca terminal create --worktree path:C:/src/app --shell cmd.exe --json'
|
||||
]
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import { COMPUTER_COMMAND_SPECS } from './computer'
|
||||
import { ENVIRONMENT_COMMAND_SPECS } from './environment'
|
||||
import { MANAGED_SERVER_COMMAND_SPECS } from './managed-server'
|
||||
import { AGENT_HOOK_COMMAND_SPECS } from './agent-hooks'
|
||||
import { AGENT_LAUNCH_COMMAND_SPECS } from './agent-launch'
|
||||
import { DIAGNOSTICS_COMMAND_SPECS } from './diagnostics'
|
||||
import { EMULATOR_COMMAND_SPECS } from './emulator'
|
||||
import { INTROSPECTION_COMMAND_SPECS } from './introspection'
|
||||
@@ -35,6 +36,7 @@ export const COMMAND_SPECS: CommandSpec[] = [
|
||||
...ORCHESTRATION_COMMAND_SPECS,
|
||||
...COMPUTER_COMMAND_SPECS,
|
||||
...AGENT_HOOK_COMMAND_SPECS,
|
||||
...AGENT_LAUNCH_COMMAND_SPECS,
|
||||
...DIAGNOSTICS_COMMAND_SPECS,
|
||||
...INTROSPECTION_COMMAND_SPECS,
|
||||
...ENVIRONMENT_COMMAND_SPECS,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
|
||||
import { ownerSurfacing } from './orca-runtime-core'
|
||||
import type { SpawnIntent } from '../../shared/host-spawn-admission'
|
||||
|
||||
export type { TerminalCreateOptions }
|
||||
@@ -52,3 +53,12 @@ export function callerSpawnScope(
|
||||
...(opts.spawnIntent ? { spawnIntent: opts.spawnIntent } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function terminalRevealViewerOptions(
|
||||
opts: Pick<TerminalCreateOptions, 'surfaceOwner' | 'agentLaunchViewer'>
|
||||
) {
|
||||
return {
|
||||
...ownerSurfacing(opts.surfaceOwner !== false),
|
||||
...(opts.agentLaunchViewer ? { agentLaunchViewer: opts.agentLaunchViewer() } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null
|
||||
const shouldCreateInBackground =
|
||||
worktreeSelector !== undefined &&
|
||||
(Boolean(opts.agentSessionClaim) ||
|
||||
(Boolean(opts.agentSessionClaim || opts.requireFreshPane) ||
|
||||
(!requiresRendererFocus && opts.rendererBacked !== true) ||
|
||||
availableAuthoritativeWindow === null)
|
||||
if (shouldCreateInBackground) {
|
||||
@@ -260,7 +260,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
|
||||
activate: presentation === 'focused',
|
||||
...(presentation ? { presentation } : {}),
|
||||
...dependencies.ownerSurfacing(opts.surfaceOwner !== false),
|
||||
...dependencies.terminalRevealViewerOptions(opts),
|
||||
tabId,
|
||||
leafId
|
||||
})
|
||||
|
||||
+187
-4
@@ -1,11 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
|
||||
import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec'
|
||||
import {
|
||||
TEST_WORKTREE_ID,
|
||||
TEST_WORKTREE_PATH,
|
||||
store,
|
||||
syncSinglePty
|
||||
} from '../orca-runtime-test-fixtures.spec'
|
||||
import { agentLaunchSurfaceFactory } from '../rpc/methods/agent-launch-surfaces'
|
||||
import { publishAgentLaunchTabEarly } from '../rpc/methods/agent-launch-tab-publication'
|
||||
import { AgentLaunch } from '../../../shared/rpc-contract/agent-launch-params'
|
||||
import { DESKTOP_RPC_CALLER } from '../rpc/rpc-caller-identity'
|
||||
import type { RpcContext } from '../rpc/core'
|
||||
import type { AgentLaunchTabPublished } from '../../../shared/agent-launch-tab-publication'
|
||||
|
||||
const RESERVED_TAB_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
|
||||
const RESERVED_LEAF_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'
|
||||
@@ -17,13 +23,19 @@ const LIVE_PANE_SPAWN = {
|
||||
}
|
||||
|
||||
function runtimeWithDesktopWindow(spawnResult: Record<string, unknown> = { id: 'pty-launch' }) {
|
||||
const requestTabCreate = vi.fn()
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents: { isDestroyed: () => false, send: requestTabCreate }
|
||||
})
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: RESERVED_TAB_ID })
|
||||
const releaseStablePaneCreate = vi.fn()
|
||||
const kill = vi.fn(() => true)
|
||||
const spawn = vi.fn().mockResolvedValue(spawnResult)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
claimStablePaneCreate: () => releaseStablePaneCreate,
|
||||
spawn: vi.fn().mockResolvedValue(spawnResult),
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill,
|
||||
getForegroundProcess: async () => null
|
||||
@@ -44,7 +56,7 @@ function runtimeWithDesktopWindow(spawnResult: Record<string, unknown> = { id: '
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
return { runtime, revealTerminalSession, releaseStablePaneCreate, kill }
|
||||
return { runtime, spawn, requestTabCreate, revealTerminalSession, releaseStablePaneCreate, kill }
|
||||
}
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
@@ -139,6 +151,176 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(created.paneKey).toBe(`${RESERVED_TAB_ID}:${RESERVED_LEAF_ID}`)
|
||||
})
|
||||
|
||||
it.each(['focused', 'background'] as const)(
|
||||
'keeps a fresh %s pane host-owned when a desktop window is available',
|
||||
async (presentation) => {
|
||||
const { runtime, spawn, requestTabCreate, revealTerminalSession } = runtimeWithDesktopWindow()
|
||||
|
||||
const created = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
startupAgent: 'claude',
|
||||
tabId: RESERVED_TAB_ID,
|
||||
leafId: RESERVED_LEAF_ID,
|
||||
requireFreshPane: true,
|
||||
presentation
|
||||
})
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tabId: RESERVED_TAB_ID, leafId: RESERVED_LEAF_ID })
|
||||
)
|
||||
expect(created.paneKey).toBe(`${RESERVED_TAB_ID}:${RESERVED_LEAF_ID}`)
|
||||
expect(requestTabCreate).not.toHaveBeenCalled()
|
||||
if (presentation === 'focused') {
|
||||
expect(revealTerminalSession).toHaveBeenCalledExactlyOnceWith(
|
||||
TEST_WORKTREE_ID,
|
||||
expect.objectContaining({
|
||||
tabId: RESERVED_TAB_ID,
|
||||
leafId: RESERVED_LEAF_ID,
|
||||
presentation,
|
||||
activate: true
|
||||
})
|
||||
)
|
||||
} else {
|
||||
expect(revealTerminalSession).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['pending', 'completed', 'failed'] as const)(
|
||||
'keeps desktop publication %s separate from the reserved host spawn',
|
||||
async (phase) => {
|
||||
const { runtime, spawn, requestTabCreate, revealTerminalSession } = runtimeWithDesktopWindow()
|
||||
let publish!: (reply: AgentLaunchTabPublished) => void
|
||||
const publishing = new Promise<AgentLaunchTabPublished>((resolve) => {
|
||||
publish = resolve
|
||||
})
|
||||
vi.spyOn(runtime, 'canPublishAgentLaunchTab').mockReturnValue(true)
|
||||
vi.spyOn(runtime, 'publishAgentLaunchTab').mockReturnValue(
|
||||
phase === 'failed' ? Promise.reject(new Error('renderer_unavailable')) : publishing
|
||||
)
|
||||
const context: RpcContext = { runtime, caller: DESKTOP_RPC_CALLER, clientKind: 'runtime' }
|
||||
const early = await publishAgentLaunchTabEarly(
|
||||
AgentLaunch.parse({
|
||||
agent: 'claude',
|
||||
target: { kind: 'existing', worktree: `id:${TEST_WORKTREE_ID}` },
|
||||
paneKey: `${RESERVED_TAB_ID}:${RESERVED_LEAF_ID}`,
|
||||
presentation: 'focused'
|
||||
}),
|
||||
context
|
||||
)
|
||||
expect(early).not.toBeNull()
|
||||
const reply = { tabId: RESERVED_TAB_ID, created: true, placement: { groupId: 'group-1' } }
|
||||
if (phase === 'completed') {
|
||||
publish(reply)
|
||||
await publishing
|
||||
}
|
||||
const surface = await agentLaunchSurfaceFactory(
|
||||
context,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
{ early, presentation: 'focused' }
|
||||
).createTerminalAgent({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
agent: 'claude',
|
||||
paneKey: `${RESERVED_TAB_ID}:${RESERVED_LEAF_ID}`,
|
||||
startupPrompt: 'fix the build'
|
||||
})
|
||||
expect(surface.paneKey).toBe(`${RESERVED_TAB_ID}:${RESERVED_LEAF_ID}`)
|
||||
expect(requestTabCreate).not.toHaveBeenCalled()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tabId: RESERVED_TAB_ID,
|
||||
leafId: RESERVED_LEAF_ID,
|
||||
env: expect.objectContaining({ ORCA_PANE_KEY: surface.paneKey })
|
||||
})
|
||||
)
|
||||
expect(spawn.mock.calls[0]?.[0]?.command).toContain('fix the build')
|
||||
expect(revealTerminalSession).toHaveBeenCalledExactlyOnceWith(
|
||||
TEST_WORKTREE_ID,
|
||||
expect.objectContaining({
|
||||
tabId: RESERVED_TAB_ID,
|
||||
leafId: RESERVED_LEAF_ID,
|
||||
activate: false,
|
||||
surfaceOwner: false,
|
||||
agentLaunchViewer: phase === 'failed' ? 'focus-in-workspace' : 'none'
|
||||
})
|
||||
)
|
||||
expect(runtime.publishAgentLaunchTab).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ viewer: 'focus-in-workspace' })
|
||||
)
|
||||
if (phase === 'pending') {
|
||||
publish(reply)
|
||||
}
|
||||
early?.surfacePublished({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
outcome: { kind: 'terminal', ...surface },
|
||||
receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: 'test' }
|
||||
})
|
||||
early?.finish()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ desktop: false, viewer: 'focus-window' },
|
||||
{ desktop: true, viewer: 'focus-in-workspace' }
|
||||
])('rechecks failed publication during the $viewer host spawn', async ({ desktop, viewer }) => {
|
||||
const { runtime, spawn, requestTabCreate, revealTerminalSession } = runtimeWithDesktopWindow()
|
||||
const publishing = Promise.withResolvers<AgentLaunchTabPublished>()
|
||||
const spawning = Promise.withResolvers<{ id: string }>()
|
||||
const started = Promise.withResolvers<void>()
|
||||
spawn.mockImplementation(() => {
|
||||
started.resolve()
|
||||
return spawning.promise
|
||||
})
|
||||
vi.spyOn(runtime, 'canPublishAgentLaunchTab').mockReturnValue(true)
|
||||
vi.spyOn(runtime, 'publishAgentLaunchTab').mockReturnValue(publishing.promise)
|
||||
const context: RpcContext = {
|
||||
runtime,
|
||||
...(desktop ? { caller: DESKTOP_RPC_CALLER, clientKind: 'runtime' as const } : {})
|
||||
}
|
||||
const paneKey = `${RESERVED_TAB_ID}:${RESERVED_LEAF_ID}`
|
||||
const early = await publishAgentLaunchTabEarly(
|
||||
AgentLaunch.parse({
|
||||
agent: 'claude',
|
||||
target: { kind: 'existing', worktree: `id:${TEST_WORKTREE_ID}` },
|
||||
paneKey,
|
||||
presentation: 'focused'
|
||||
}),
|
||||
context
|
||||
)
|
||||
expect(early?.windowOwnsPresentation()).toBe(true)
|
||||
const creating = agentLaunchSurfaceFactory(context, undefined, undefined, false, undefined, {
|
||||
early,
|
||||
presentation: 'focused'
|
||||
}).createTerminalAgent({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
agent: 'claude',
|
||||
paneKey,
|
||||
startupPrompt: 'fix the build'
|
||||
})
|
||||
await started.promise
|
||||
publishing.reject(new Error('agent_launch_tab_publish_timeout'))
|
||||
await Promise.resolve()
|
||||
expect(early?.windowOwnsPresentation()).toBe(false)
|
||||
spawning.resolve({ id: 'pty-launch' })
|
||||
const terminal = await creating
|
||||
expect(terminal.paneKey).toBe(paneKey)
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(spawn.mock.calls[0]?.[0]?.command).toContain('fix the build')
|
||||
expect(requestTabCreate).not.toHaveBeenCalled()
|
||||
expect(revealTerminalSession).toHaveBeenCalledExactlyOnceWith(
|
||||
TEST_WORKTREE_ID,
|
||||
expect.objectContaining({
|
||||
tabId: RESERVED_TAB_ID,
|
||||
leafId: RESERVED_LEAF_ID,
|
||||
agentLaunchViewer: viewer,
|
||||
activate: false,
|
||||
surfaceOwner: false
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('mints its own pane when the reserved leaf is malformed, and reports the one it used', async () => {
|
||||
const { runtime, revealTerminalSession } = runtimeWithDesktopWindow()
|
||||
|
||||
@@ -163,7 +345,8 @@ describe('OrcaRuntimeService', () => {
|
||||
startupAgent: 'claude',
|
||||
tabId: RESERVED_TAB_ID,
|
||||
leafId: RESERVED_LEAF_ID,
|
||||
requireFreshPane: true
|
||||
requireFreshPane: true,
|
||||
presentation: 'focused'
|
||||
})
|
||||
).rejects.toThrow('agent_launch_pane_already_live')
|
||||
|
||||
|
||||
@@ -169,6 +169,28 @@ describe('a paired client launching into an existing workspace', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['focused', 'background'] as const)(
|
||||
'keeps a paired %s chat inactive on the host',
|
||||
async (presentation) => {
|
||||
const runtime = selectionRuntime({ settings: STRUCTURED_PREFERENCE })
|
||||
await launch({ ...EXISTING_LAUNCH, presentation }, runtime)
|
||||
expect(chatActivation()).toBe(false)
|
||||
expect(runtime.selectCreatedMobileSessionTabForClient).toHaveBeenCalledTimes(
|
||||
presentation === 'focused' ? 1 : 0
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('focuses a paired terminal only for the caller', async () => {
|
||||
const runtime = selectionRuntime({ settings: {}, terminalPaneKey: PANE_KEY })
|
||||
await launch({ ...EXISTING_LAUNCH, presentation: 'focused' }, runtime)
|
||||
expect(runtime.createTerminal).toHaveBeenCalledWith(
|
||||
'id:wt-7',
|
||||
expect.objectContaining({ presentation: 'background', surfaceOwner: false })
|
||||
)
|
||||
expect(runtime.selectCreatedMobileSessionTabForClient).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('selects nothing when the runtime reported no pane for the terminal', async () => {
|
||||
const runtime = selectionRuntime({ settings: {} })
|
||||
|
||||
|
||||
@@ -267,16 +267,22 @@ describe('the instant tab', () => {
|
||||
await replayLaunch(runtime, { presentation: 'focused' }, CLI)
|
||||
|
||||
expect(terminalOptions(runtime)).toMatchObject({ surfaceOwner: false })
|
||||
expect(terminalOptions(runtime)).not.toHaveProperty('presentation')
|
||||
})
|
||||
|
||||
it('reveals the spawn as before when the window never said it showed the tab', async () => {
|
||||
it('defers viewer policy to the host reveal when early publication failed', async () => {
|
||||
const runtime = hostWithWindow({ reply: new Error('renderer_unavailable') })
|
||||
|
||||
const result = await replayLaunch(runtime, {}, CLI)
|
||||
|
||||
expect(result.outcome.kind).toBe('terminal')
|
||||
expect(result.placement).toBeUndefined()
|
||||
expect(terminalOptions(runtime)).not.toHaveProperty('surfaceOwner')
|
||||
expect(terminalOptions(runtime)).toMatchObject({ surfaceOwner: false })
|
||||
const viewer = terminalOptions(runtime).agentLaunchViewer
|
||||
expect(typeof viewer).toBe('function')
|
||||
if (typeof viewer === 'function') {
|
||||
expect(viewer()).toBe('reveal-owner')
|
||||
}
|
||||
})
|
||||
|
||||
it('records the pane it showed with the launch, and lets the pane attach once the agent runs', async () => {
|
||||
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
import { AgentLaunchTabClosedError } from '../../../../shared/agent-launch-tab-closed'
|
||||
import {
|
||||
agentLaunchMovesHostWindow,
|
||||
type EarlyAgentLaunchTab
|
||||
agentLaunchTabViewerRule,
|
||||
type AgentLaunchView
|
||||
} from './agent-launch-tab-publication'
|
||||
|
||||
/** Replay-safe launches keep the nested attach in the same stable caller namespace as the launch. */
|
||||
@@ -52,9 +53,10 @@ export function agentLaunchSurfaceFactory(
|
||||
terminalSpawn: TerminalSpawnDispatch = trackTerminalSpawnDispatch(),
|
||||
// The tab shown before the launch ran; asked at spawn, since the window's answer and the user's
|
||||
// close can both land after admission.
|
||||
earlyTab: Pick<EarlyAgentLaunchTab, 'windowShowsTab' | 'closedByUser'> | null = null
|
||||
view: AgentLaunchView = { early: null, presentation: undefined }
|
||||
): AgentLaunchSurfaceFactory {
|
||||
const movesHostWindow = agentLaunchMovesHostWindow(context)
|
||||
const { early: earlyTab, presentation } = view
|
||||
return {
|
||||
createStructuredSession: async ({
|
||||
worktreeId,
|
||||
@@ -88,8 +90,9 @@ export function agentLaunchSurfaceFactory(
|
||||
agent,
|
||||
...(seeded ? { options: seeded } : {}),
|
||||
...(tabId ? { tabId } : {}),
|
||||
// The user asked for this chat, so it takes the surface — unlike a dispatched worker.
|
||||
activate: !callerPresentsSurface
|
||||
activate:
|
||||
!callerPresentsSurface &&
|
||||
(presentation === undefined || (presentation === 'focused' && movesHostWindow))
|
||||
})
|
||||
if (!created.ok) {
|
||||
// The caller named this session, so a taken id is its answer, not an opaque refusal; and not
|
||||
@@ -132,6 +135,7 @@ export function agentLaunchSurfaceFactory(
|
||||
}) => {
|
||||
const launchPreferences = toAgentLaunchPreferences(options)
|
||||
let promptRodeLaunchCommand = false
|
||||
const hasEarlyTab = earlyTab !== null
|
||||
if (earlyTab?.closedByUser()) {
|
||||
// The user closed its tab while it waited: nothing is spawned, and that is the answer.
|
||||
terminalSpawn.rethrow(new AgentLaunchTabClosedError())
|
||||
@@ -158,7 +162,18 @@ export function agentLaunchSurfaceFactory(
|
||||
...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}),
|
||||
...(launchSource ? { launchSource } : {}),
|
||||
...(viewMode ? { viewMode } : {}),
|
||||
...(earlyTab?.windowShowsTab() || !movesHostWindow ? { surfaceOwner: false as const } : {}),
|
||||
...(presentation && (!movesHostWindow || !hasEarlyTab || presentation === 'background')
|
||||
? { presentation: movesHostWindow ? presentation : ('background' as const) }
|
||||
: {}),
|
||||
...(hasEarlyTab || !movesHostWindow ? { surfaceOwner: false as const } : {}),
|
||||
...(earlyTab
|
||||
? {
|
||||
agentLaunchViewer: () =>
|
||||
earlyTab.windowOwnsPresentation()
|
||||
? ('none' as const)
|
||||
: agentLaunchTabViewerRule(context, presentation)
|
||||
}
|
||||
: {}),
|
||||
onPtySpawnDispatched: terminalSpawn.onPtySpawnDispatched
|
||||
})
|
||||
const terminal = await created.catch(terminalSpawn.rethrow)
|
||||
|
||||
@@ -97,8 +97,8 @@ export type EarlyAgentLaunchTab = {
|
||||
executing(): void
|
||||
/** The surface exists; an agent in this pane lets the pane's waiting spawn attach to it now. */
|
||||
surfacePublished(result: AgentLaunchResult): void
|
||||
/** The window has said it shows the tab, so the spawn must bind it without a second reveal. */
|
||||
windowShowsTab(): boolean
|
||||
/** Pending or completed publication owns selection; a failed publication leaves it to spawn. */
|
||||
windowOwnsPresentation(): boolean
|
||||
/** The user closed the tab or pane: the launch must not start its agent, or must stop it. */
|
||||
closedByUser(): boolean
|
||||
/** The launch is over, however it ended. A tab this request made goes when nothing will ever run
|
||||
@@ -252,6 +252,7 @@ function trackEarlyAgentLaunchTab(args: {
|
||||
unspawnedVerdict: () => AgentLaunchPaneVerdict
|
||||
}): EarlyAgentLaunchTab {
|
||||
let reply: AgentLaunchTabPublished | null = null
|
||||
let publicationFailed = false
|
||||
let executing = false
|
||||
let ranHere: boolean | null = null
|
||||
const published = args.publishing.then(
|
||||
@@ -260,6 +261,7 @@ function trackEarlyAgentLaunchTab(args: {
|
||||
return answer
|
||||
},
|
||||
(error: unknown) => {
|
||||
publicationFailed = true
|
||||
// The tab did not appear; the launch still runs and its tab appears when it spawns.
|
||||
console.warn('[agent-launch] the window did not show the launch tab early', error)
|
||||
return null
|
||||
@@ -278,7 +280,7 @@ function trackEarlyAgentLaunchTab(args: {
|
||||
args.agentBound()
|
||||
}
|
||||
},
|
||||
windowShowsTab: () => reply !== null,
|
||||
windowOwnsPresentation: () => !publicationFailed,
|
||||
closedByUser: args.closedByUser,
|
||||
finish: () => {
|
||||
if (ranHere === true) {
|
||||
|
||||
@@ -128,7 +128,7 @@ async function runAgentLaunch(
|
||||
replaySafe?.callerKey,
|
||||
callerNavigationId !== null,
|
||||
replaySafe?.terminalSpawn,
|
||||
view.early
|
||||
view
|
||||
),
|
||||
workspaces: agentLaunchWorkspaceFactory(context, intent.agent),
|
||||
...(callerRendersLaunchedChat(context, intent.agent) ? {} : { callerRendersStructured: false }),
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority'
|
||||
import {
|
||||
findCatalogModel,
|
||||
findCatalogOption
|
||||
} from '../../../../../../shared/agent-session-option-catalog'
|
||||
import {
|
||||
getAgentSessionOptionLaunchCatalog,
|
||||
resolveAgentSessionOptionLaunch
|
||||
} from '../../../../../../shared/agent-session-option-launch'
|
||||
import { checkAgentLaunchModelPreferences } from '../../../../../../shared/agent-launch-model-preferences'
|
||||
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version'
|
||||
import type { TuiAgent } from '../../../../../../shared/tui-agent'
|
||||
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
|
||||
@@ -60,72 +53,11 @@ export function resolveWorkerLaunchPreferences(args: {
|
||||
preferences: AgentLaunchPreferences | undefined
|
||||
receipt: OrchestrationWorkerLaunchReceipt
|
||||
} {
|
||||
if (args.effort && !args.model) {
|
||||
throw new OrchestrationError('invalid_argument', '--effort requires --model.')
|
||||
const checked = checkAgentLaunchModelPreferences(args)
|
||||
if ('refusal' in checked) {
|
||||
throw new OrchestrationError(checked.refusal.code, checked.refusal.message)
|
||||
}
|
||||
if (!args.model) {
|
||||
return {
|
||||
preferences: undefined,
|
||||
receipt: createWorkerLaunchReceipt({ agent: args.agent })
|
||||
}
|
||||
}
|
||||
|
||||
if (args.agent === 'opencode' && args.createsWorktree) {
|
||||
throw new OrchestrationError(
|
||||
'capability_unsupported',
|
||||
'OpenCode model selection requires an existing worktree. Use --worktree current or an existing worktree selector, or omit --model.'
|
||||
)
|
||||
}
|
||||
|
||||
if (args.agent === 'opencode' && args.openCodeModelLaunchSupported !== true) {
|
||||
throw new OrchestrationError(
|
||||
'capability_unsupported',
|
||||
'This OpenCode TUI cannot verify launch-time model selection. Omit --model or use a supported OpenCode CLI.'
|
||||
)
|
||||
}
|
||||
|
||||
const catalog = getAgentSessionOptionLaunchCatalog(args.agent)
|
||||
if (!catalog?.supportsWorkerLaunchPreferences || !catalog.modelApply.launchArgs) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
`Agent ${args.agent} does not support launch-time model selection. Omit --model to run the model from its own config.`
|
||||
)
|
||||
}
|
||||
|
||||
if (args.effort) {
|
||||
const model = findCatalogModel(catalog, args.model)
|
||||
const option =
|
||||
findCatalogOption(model, 'effort') ??
|
||||
(!model
|
||||
? catalog.unknownModelOptions?.find((candidate) => candidate.id === 'effort')
|
||||
: undefined)
|
||||
if (
|
||||
option?.kind.type !== 'select' ||
|
||||
!option.kind.choices.some((choice) => choice.value === args.effort)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
`Agent ${args.agent} model ${args.model} does not support effort ${args.effort}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const requested = {
|
||||
model: args.model,
|
||||
...(args.effort ? { effort: args.effort } : {})
|
||||
}
|
||||
const resolved = resolveAgentSessionOptionLaunch(args.agent, requested, [], false)
|
||||
if (
|
||||
resolved.appliedValues.model !== args.model ||
|
||||
resolved.appliedValues.effort !== args.effort
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
`Agent ${args.agent} cannot apply the requested worker launch preferences.`
|
||||
)
|
||||
}
|
||||
|
||||
const preferences: AgentLaunchPreferences = requested
|
||||
const { preferences } = checked
|
||||
return {
|
||||
preferences,
|
||||
receipt: createWorkerLaunchReceipt({ agent: args.agent, ...preferences })
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane
|
||||
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
|
||||
import type {
|
||||
AgentLaunchTabPublished,
|
||||
AgentLaunchTabViewerRule,
|
||||
AgentLaunchTabPublishRequest
|
||||
} from '../../shared/agent-launch-tab-publication'
|
||||
import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry'
|
||||
@@ -70,6 +71,7 @@ export type RuntimeNotifier = {
|
||||
activate?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
surfaceOwner?: false
|
||||
agentLaunchViewer?: AgentLaunchTabViewerRule
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
splitFromLeafId?: string
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentLaunchTabViewerRule } from '../../shared/agent-launch-tab-publication'
|
||||
import type { SpawnIntent } from '../../shared/host-spawn-admission'
|
||||
import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types'
|
||||
import type {
|
||||
@@ -81,6 +82,8 @@ export type TerminalCreateOptions = {
|
||||
activate?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
surfaceOwner?: false
|
||||
/** Main-internal: recheck early publication when the host is ready to reveal its pane. */
|
||||
agentLaunchViewer?: () => AgentLaunchTabViewerRule
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
/** Refuse, rather than attach, when `tabId`/`leafId` name a pane whose PTY is already live. */
|
||||
|
||||
@@ -130,6 +130,7 @@ export function registerRuntimeWindowLifecycle(
|
||||
activate: opts.activate !== false,
|
||||
...(opts.presentation ? { presentation: opts.presentation } : {}),
|
||||
...(opts.surfaceOwner === false ? { surfaceOwner: false } : {}),
|
||||
...(opts.agentLaunchViewer ? { agentLaunchViewer: opts.agentLaunchViewer } : {}),
|
||||
// Why: pre-minted tabId aligns the renderer tab id with the paneKey baked into the PTY env, so hook events route right.
|
||||
...(opts.tabId !== undefined ? { tabId: opts.tabId } : {}),
|
||||
...(opts.leafId !== undefined ? { leafId: opts.leafId } : {}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TerminalPaneSplitSource } from '../../shared/feature-education-tel
|
||||
import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity'
|
||||
import type {
|
||||
AgentLaunchTabPublishReply,
|
||||
AgentLaunchTabViewerRule,
|
||||
AgentLaunchTabPublishRequest
|
||||
} from '../../shared/agent-launch-tab-publication'
|
||||
import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict'
|
||||
@@ -38,6 +39,7 @@ export const uiTerminalAndSessionTabsApi = {
|
||||
focus?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
surfaceOwner?: false
|
||||
agentLaunchViewer?: AgentLaunchTabViewerRule
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
splitFromLeafId?: string
|
||||
@@ -64,6 +66,7 @@ export const uiTerminalAndSessionTabsApi = {
|
||||
focus?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
surfaceOwner?: false
|
||||
agentLaunchViewer?: AgentLaunchTabViewerRule
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
splitFromLeafId?: string
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type {
|
||||
AgentLaunchTabPublishReply,
|
||||
AgentLaunchTabViewerRule,
|
||||
AgentLaunchTabPublishRequest
|
||||
} from '../../shared/agent-launch-tab-publication'
|
||||
import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict'
|
||||
@@ -171,6 +172,7 @@ export type UiCommandEventApi = {
|
||||
focus?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
surfaceOwner?: false
|
||||
agentLaunchViewer?: AgentLaunchTabViewerRule
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
splitFromLeafId?: string
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { presentAgentLaunchTab } from '@/lib/agent-launch-tab-presentation'
|
||||
import { releaseAgentLaunchPaneSpawn } from '@/lib/agent-launch-pane-spawn-hold'
|
||||
import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount'
|
||||
import { hasRegisteredRuntimeTerminalTab } from '@/runtime/mounted-terminal-tab-registry'
|
||||
@@ -38,6 +39,7 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v
|
||||
focus,
|
||||
presentation,
|
||||
surfaceOwner,
|
||||
agentLaunchViewer,
|
||||
tabId,
|
||||
leafId,
|
||||
splitFromLeafId,
|
||||
@@ -51,8 +53,12 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v
|
||||
activate,
|
||||
focus
|
||||
})
|
||||
const shouldActivate = terminalPresentation === 'focused'
|
||||
const shouldSurfaceOwner = terminalPresentation !== 'background' && surfaceOwner !== false
|
||||
const shouldActivate =
|
||||
agentLaunchViewer === undefined && terminalPresentation === 'focused'
|
||||
const shouldSurfaceOwner =
|
||||
agentLaunchViewer === undefined &&
|
||||
terminalPresentation !== 'background' &&
|
||||
surfaceOwner !== false
|
||||
if (shouldActivate) {
|
||||
activateTerminalInitiatedWorktree(store, worktreeId)
|
||||
}
|
||||
@@ -96,7 +102,10 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v
|
||||
(ptyId
|
||||
? store.createTab(worktreeId, undefined, undefined, {
|
||||
initialPtyId: ptyId,
|
||||
activate: shouldActivate,
|
||||
activate:
|
||||
shouldActivate ||
|
||||
agentLaunchViewer === 'focus-window' ||
|
||||
agentLaunchViewer === 'focus-in-workspace',
|
||||
...(launchAgent
|
||||
? {
|
||||
launchAgent,
|
||||
@@ -136,6 +145,9 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v
|
||||
store.revealWorktreeInSidebar(worktreeId)
|
||||
focusTerminalInitiatedTab(tab.id, leafId, worktreeId)
|
||||
}
|
||||
if (agentLaunchViewer) {
|
||||
presentAgentLaunchTab(agentLaunchViewer, worktreeId, tab.id, leafId)
|
||||
}
|
||||
// Why: only stamp the runtime title on fresh tabs; reused tabs may have a user customTitle it would overwrite on focus.
|
||||
if (title && !reusedTab) {
|
||||
store.setTabCustomTitle(tab.id, title, { recordInteraction: false })
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AgentLaunchTabViewerRule } from '../../../shared/agent-launch-tab-publication'
|
||||
import {
|
||||
activateTerminalInitiatedWorktree,
|
||||
focusTerminalInitiatedTab
|
||||
} from '../hooks/ipc-events/terminal-command-state'
|
||||
import { useAppStore } from '../store'
|
||||
|
||||
/** Early publication and host reveal move the same viewer, under the same policy. */
|
||||
export function presentAgentLaunchTab(
|
||||
viewer: AgentLaunchTabViewerRule,
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
leafId?: string
|
||||
): void {
|
||||
if (viewer === 'focus-window') {
|
||||
activateTerminalInitiatedWorktree(useAppStore.getState(), worktreeId, [tabId])
|
||||
}
|
||||
const current = useAppStore.getState()
|
||||
const windowShowsTab =
|
||||
viewer === 'focus-window' ||
|
||||
(viewer === 'focus-in-workspace' && current.activeWorktreeId === worktreeId)
|
||||
if (windowShowsTab) {
|
||||
current.setActiveTabType('terminal', worktreeId)
|
||||
current.setActiveTab(tabId)
|
||||
}
|
||||
if (windowShowsTab || viewer === 'reveal-owner') {
|
||||
if (viewer !== 'focus-in-workspace') {
|
||||
current.revealWorktreeInSidebar(worktreeId)
|
||||
}
|
||||
focusTerminalInitiatedTab(tabId, leafId, worktreeId)
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,7 @@ import type {
|
||||
AgentLaunchTabPublishRequest
|
||||
} from '../../../shared/agent-launch-tab-publication'
|
||||
import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree'
|
||||
import {
|
||||
activateTerminalInitiatedWorktree,
|
||||
focusTerminalInitiatedTab
|
||||
} from '../hooks/ipc-events/terminal-command-state'
|
||||
import { presentAgentLaunchTab } from './agent-launch-tab-presentation'
|
||||
import { useAppStore } from '../store'
|
||||
import { resolveUnifiedTabCreatePlacement } from '../store/slices/tabs/tabs-create-placement'
|
||||
import { insertUnifiedTabAfterAnchor } from './unified-tab-anchor-insertion'
|
||||
@@ -122,25 +119,7 @@ export function publishAgentLaunchTab(
|
||||
insertUnifiedTabAfterAnchor(worktreeId, unifiedTabId, placement.anchorTabId)
|
||||
}
|
||||
}
|
||||
if (request.viewer === 'focus-window') {
|
||||
// After the tab lands: activating prunes the workspace's empty groups, the requested split too.
|
||||
activateTerminalInitiatedWorktree(useAppStore.getState(), worktreeId, [tab.id])
|
||||
}
|
||||
const current = useAppStore.getState()
|
||||
// The desktop's own launch stays in its workspace: the window follows only if it is still there.
|
||||
const windowShowsTab =
|
||||
request.viewer === 'focus-window' ||
|
||||
(request.viewer === 'focus-in-workspace' && current.activeWorktreeId === worktreeId)
|
||||
if (windowShowsTab) {
|
||||
current.setActiveTabType('terminal', worktreeId)
|
||||
current.setActiveTab(tab.id)
|
||||
}
|
||||
if (windowShowsTab || request.viewer === 'reveal-owner') {
|
||||
if (request.viewer !== 'focus-in-workspace') {
|
||||
current.revealWorktreeInSidebar(worktreeId)
|
||||
}
|
||||
focusTerminalInitiatedTab(tab.id, leafId, worktreeId)
|
||||
}
|
||||
presentAgentLaunchTab(request.viewer, worktreeId, tab.id, leafId)
|
||||
const landed = landedGroupId(worktreeId, tab.id) ?? placement.groupId ?? ''
|
||||
const fallback = placementFallback(request, landed, placement.anchorTabId !== undefined)
|
||||
return {
|
||||
|
||||
@@ -118,3 +118,68 @@ describe('a local agent create watched to completion', () => {
|
||||
expect(focusRuntimeTerminalSurface).toHaveBeenCalledWith(AGENT_TAB_ID, null, worktree.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('host reveal after early launch publication fails', () => {
|
||||
it.each([
|
||||
{ viewer: 'focus-in-workspace', moved: true, follows: false },
|
||||
{ viewer: 'focus-in-workspace', moved: false, follows: true },
|
||||
{ viewer: 'focus-window', moved: true, follows: true },
|
||||
{ viewer: 'none', moved: true, follows: false }
|
||||
] as const)(
|
||||
'honors $viewer after navigation=$moved for new and existing panes',
|
||||
({ viewer, moved, follows }) => {
|
||||
const worktree = makeCreatedAgentWorktree()
|
||||
const other = { ...worktree, id: `${worktree.id}-other` }
|
||||
const { revealWorktreeInSidebar } = seedEmptyActivatableWorktree(worktree, {
|
||||
extraWorktrees: [other]
|
||||
})
|
||||
const activeWorkspace = moved ? other.id : worktree.id
|
||||
useAppStore.setState({ activeWorktreeId: activeWorkspace, activeTabId: null })
|
||||
focusRuntimeTerminalSurface.mockClear()
|
||||
const payload: CreateTerminalPayload = {
|
||||
requestId: 'reveal-agent',
|
||||
worktreeId: worktree.id,
|
||||
ptyId: 'pty-agent',
|
||||
launchAgent: 'codex',
|
||||
activate: false,
|
||||
surfaceOwner: false,
|
||||
agentLaunchViewer: viewer,
|
||||
tabId: AGENT_TAB_ID,
|
||||
leafId: AGENT_LEAF_ID
|
||||
}
|
||||
revealTerminal(payload)
|
||||
expect(useAppStore.getState().activeWorktreeId).toBe(follows ? worktree.id : activeWorkspace)
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id]?.map((tab) => tab.id)).toEqual([
|
||||
AGENT_TAB_ID
|
||||
])
|
||||
expect(useAppStore.getState().terminalLayoutsByTabId[AGENT_TAB_ID]?.ptyIdsByLeafId).toEqual({
|
||||
[AGENT_LEAF_ID]: 'pty-agent'
|
||||
})
|
||||
if (follows) {
|
||||
expect(useAppStore.getState().activeTabId).toBe(AGENT_TAB_ID)
|
||||
expect(focusRuntimeTerminalSurface).toHaveBeenCalledWith(
|
||||
AGENT_TAB_ID,
|
||||
AGENT_LEAF_ID,
|
||||
worktree.id
|
||||
)
|
||||
} else {
|
||||
expect(focusRuntimeTerminalSurface).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().activeTabId).toBeNull()
|
||||
}
|
||||
if (viewer === 'focus-in-workspace') {
|
||||
expect(revealWorktreeInSidebar).not.toHaveBeenCalled()
|
||||
}
|
||||
// A reveal can find a tab restored while its earlier publication reply was lost.
|
||||
useAppStore.setState({ activeWorktreeId: activeWorkspace, activeTabId: null })
|
||||
focusRuntimeTerminalSurface.mockClear()
|
||||
revealTerminal(payload)
|
||||
expect(useAppStore.getState().activeWorktreeId).toBe(follows ? worktree.id : activeWorkspace)
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1)
|
||||
if (follows) {
|
||||
expect(focusRuntimeTerminalSurface).toHaveBeenCalledOnce()
|
||||
} else {
|
||||
expect(focusRuntimeTerminalSurface).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Whether a launch can start `agent` on the model and effort its caller named, by the same catalog
|
||||
* the launch itself applies. One rule for every caller that takes `--model`/`--effort`, so an
|
||||
* orchestration worker and a plain CLI launch refuse the same requests with the same words.
|
||||
*/
|
||||
|
||||
import type { AgentLaunchPreferences } from './agent-session-host-authority'
|
||||
import { findCatalogModel, findCatalogOption } from './agent-session-option-catalog'
|
||||
import {
|
||||
getAgentSessionOptionLaunchCatalog,
|
||||
resolveAgentSessionOptionLaunch
|
||||
} from './agent-session-option-launch'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
|
||||
export type AgentLaunchModelPreferenceRefusal = {
|
||||
code: 'invalid_argument' | 'capability_unsupported'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type AgentLaunchModelPreferenceCheck =
|
||||
| { preferences: AgentLaunchPreferences | undefined }
|
||||
| { refusal: AgentLaunchModelPreferenceRefusal }
|
||||
|
||||
export function checkAgentLaunchModelPreferences(args: {
|
||||
agent: TuiAgent
|
||||
/** OpenCode needs the execution host to verify its CLI before it can start on a chosen model. */
|
||||
openCodeModelLaunchSupported?: boolean
|
||||
createsWorktree?: boolean
|
||||
model?: string
|
||||
effort?: string
|
||||
}): AgentLaunchModelPreferenceCheck {
|
||||
if (args.effort && !args.model) {
|
||||
return refuse('invalid_argument', '--effort requires --model.')
|
||||
}
|
||||
if (!args.model) {
|
||||
return { preferences: undefined }
|
||||
}
|
||||
if (args.agent === 'opencode' && args.createsWorktree) {
|
||||
return refuse(
|
||||
'capability_unsupported',
|
||||
'OpenCode model selection requires an existing worktree. Use --worktree current or an existing worktree selector, or omit --model.'
|
||||
)
|
||||
}
|
||||
if (args.agent === 'opencode' && args.openCodeModelLaunchSupported !== true) {
|
||||
return refuse(
|
||||
'capability_unsupported',
|
||||
'This OpenCode TUI cannot verify launch-time model selection. Omit --model or use a supported OpenCode CLI.'
|
||||
)
|
||||
}
|
||||
|
||||
const catalog = getAgentSessionOptionLaunchCatalog(args.agent)
|
||||
if (!catalog?.supportsWorkerLaunchPreferences || !catalog.modelApply.launchArgs) {
|
||||
return refuse(
|
||||
'invalid_argument',
|
||||
`Agent ${args.agent} does not support launch-time model selection. Omit --model to run the model from its own config.`
|
||||
)
|
||||
}
|
||||
|
||||
if (args.effort) {
|
||||
const model = findCatalogModel(catalog, args.model)
|
||||
const option =
|
||||
findCatalogOption(model, 'effort') ??
|
||||
(!model
|
||||
? catalog.unknownModelOptions?.find((candidate) => candidate.id === 'effort')
|
||||
: undefined)
|
||||
if (
|
||||
option?.kind.type !== 'select' ||
|
||||
!option.kind.choices.some((choice) => choice.value === args.effort)
|
||||
) {
|
||||
return refuse(
|
||||
'invalid_argument',
|
||||
`Agent ${args.agent} model ${args.model} does not support effort ${args.effort}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const requested: AgentLaunchPreferences = {
|
||||
model: args.model,
|
||||
...(args.effort ? { effort: args.effort } : {})
|
||||
}
|
||||
const resolved = resolveAgentSessionOptionLaunch(args.agent, requested, [], false)
|
||||
if (
|
||||
resolved.appliedValues.model !== args.model ||
|
||||
resolved.appliedValues.effort !== args.effort
|
||||
) {
|
||||
return refuse(
|
||||
'invalid_argument',
|
||||
`Agent ${args.agent} cannot apply the requested launch preferences.`
|
||||
)
|
||||
}
|
||||
return { preferences: requested }
|
||||
}
|
||||
|
||||
function refuse(
|
||||
code: AgentLaunchModelPreferenceRefusal['code'],
|
||||
message: string
|
||||
): AgentLaunchModelPreferenceCheck {
|
||||
return { refusal: { code, message } }
|
||||
}
|
||||
@@ -38,6 +38,10 @@ export const AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY =
|
||||
// may publish the launch's tab before it admits the launch.
|
||||
export const AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY = 'agent.launch.placement.v1' as const
|
||||
|
||||
// Host-advertised: presentation reaches local chat and terminal creation, including CLI launches.
|
||||
export const AGENT_LAUNCH_LOCAL_PRESENTATION_RUNTIME_CAPABILITY =
|
||||
'agent.launch.local-presentation.v1' as const
|
||||
|
||||
// Host-advertised: accepts `target.kind: 'create-folder-workspace'`. An older host refuses the
|
||||
// unknown target kind, so a client offers the new-folder-workspace launch only on this. It is not
|
||||
// enough on its own: the target also needs `folderWorkspace.create` authorization, which a
|
||||
@@ -65,5 +69,6 @@ export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [
|
||||
AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_LOCAL_PRESENTATION_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
|
||||
Reference in New Issue
Block a user