mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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
This commit is contained in:
@@ -60,7 +60,7 @@ ORCA terminal send --terminal <handle> --text "<task brief>" --enter --json
|
||||
|
||||
## Starting agents
|
||||
|
||||
`worktree create --agent` (new checkout) and `agent launch` (existing worktree) are how to start an agent. They go through Orca's agent launch, the same one the app's new-agent tab uses: the user's Chat UI setting decides whether the agent opens as a chat or a terminal, and the result reports which (`outcome.kind`) and why (`receipt.detail`). When a chat is not possible, such as for an agent without chat support, the launch starts a terminal and the receipt says so. `terminal create` makes a terminal for shells and commands.
|
||||
`worktree create --agent` (new checkout) and `agent launch` (existing worktree) are how to start an agent. They go through Orca's agent launch, the same one the app's new-agent tab uses: the user's Chat UI setting decides whether the agent opens as a chat or a terminal, and the result reports which (`outcome.kind`) and why (`receipt.detail`). When a chat is not possible, such as for an agent without chat support, the launch starts a terminal and the receipt says so. `--surface chat|terminal` picks one for that launch instead of the setting; pass it only when the user asked for a chat or a terminal. `--surface chat` is refused when Orca can establish before starting anything that the agent cannot open as a chat. If support cannot be checked, the launch can fall back to a terminal. If chat becomes unavailable only after a new workspace is created, Orca keeps that workspace, its setup and default tabs, then opens a terminal; the receipt explains the fallback. `terminal create` makes a terminal for shells and commands.
|
||||
|
||||
If a launch reply is lost, use the returned `operationId` to re-issue the exact same command from the same directory against the same execution host with `--retry-launch <operationId>`. Keep that ID on every retry: the host replays the accepted launch instead of starting another agent or submitting the brief twice. Do not retry as a fresh launch. If replay says the outcome is unknown or expired, or an older host gives no replay ID, inspect the workspace and agent without sending input before deciding whether another launch is needed. A host that cannot safely carry prompts or honor background/focus requests is refused before launch; update Orca on that execution host.
|
||||
|
||||
@@ -127,7 +127,7 @@ ORCA worktree create --name task --setup skip --json
|
||||
ORCA worktree create --name task --run-hooks --json
|
||||
```
|
||||
|
||||
- `--agent <id>` starts that agent with the worktree, as a chat or a terminal per the user's Chat UI setting (see Starting agents); `--prompt <text>` sends initial work to it, and `--model <id>` / `--effort <level>` pick its model and reasoning effort. Known ids include `claude`, `codex`, `omp`, `pi`, `grok`, and other installed agents.
|
||||
- `--agent <id>` starts that agent with the worktree, as a chat or a terminal per the user's Chat UI setting (see Starting agents); `--prompt <text>` sends initial work to it, and `--model <id>` / `--effort <level>` pick its model and reasoning effort, and `--surface chat|terminal` picks chat or terminal for this launch. Known ids include `claude`, `codex`, `omp`, `pi`, `grok`, and other installed agents.
|
||||
- Repo setup or default-terminal settings may still add tabs or splits. Configured default tabs are intentional; never close one without verifying it is an unused shell.
|
||||
- Address the agent through exactly one handle: `launch.outcome.handle`. Handles are runtime-scoped: after an Orca restart or a `terminal_handle_stale` error, re-list and continue with the replacement only; never dual-send to old and replacement handles. `--agent` already started the agent, so do not start it again.
|
||||
- `--setup run|skip|inherit` controls repo setup hooks. Default is `inherit`, which follows the repo's setup policy.
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { createServer } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
AGENT_LAUNCH_RUNTIME_CAPABILITIES,
|
||||
AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY
|
||||
} from '../shared/agent-launch-runtime-capability'
|
||||
import { agentLaunchFixture } from './agent-launch-test-fixtures'
|
||||
|
||||
it('real CLI processes carry surface choices and refuse invalid or unsupported picks before mutation', async () => {
|
||||
const manifest = createRequire(resolve('package.json')).resolve('bun/package.json')
|
||||
const { bin } = z
|
||||
.object({ bin: z.object({ bun: z.string() }) })
|
||||
.parse(JSON.parse(readFileSync(manifest, 'utf8')))
|
||||
const program = join(dirname(manifest), bin.bun)
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-launch-process-'))
|
||||
const endpoint =
|
||||
process.platform === 'win32' ? `\\\\.\\pipe\\launch-${randomUUID()}` : join(root, 'rpc')
|
||||
const requests: { method: string; params: Record<string, unknown> }[] = []
|
||||
let capabilities: readonly string[] = AGENT_LAUNCH_RUNTIME_CAPABILITIES
|
||||
const server = createServer((socket) => {
|
||||
let buffer = ''
|
||||
socket.setEncoding('utf8')
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffer += chunk
|
||||
if (!buffer.includes('\n')) {
|
||||
return
|
||||
}
|
||||
const request = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
method: z.string(),
|
||||
authToken: z.literal('fixture-token'),
|
||||
params: z.record(z.string(), z.unknown()).default({})
|
||||
})
|
||||
.parse(JSON.parse(buffer.slice(0, buffer.indexOf('\n'))))
|
||||
requests.push(request)
|
||||
socket.end(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result:
|
||||
request.method === 'status.get'
|
||||
? { capabilities }
|
||||
: request.method === 'worktree.show'
|
||||
? { worktree: { id: 'wt-fixture', lineage: null } }
|
||||
: agentLaunchFixture('wt-fixture').result,
|
||||
_meta: { runtimeId: 'fixture-host' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
try {
|
||||
await new Promise<void>((ready) => server.listen(endpoint, ready))
|
||||
writeFileSync(
|
||||
join(root, 'orca-runtime.json'),
|
||||
JSON.stringify({
|
||||
runtimeId: 'fixture-host',
|
||||
pid: process.pid,
|
||||
startedAt: 1,
|
||||
authToken: 'fixture-token',
|
||||
transports: [{ kind: process.platform === 'win32' ? 'named-pipe' : 'unix', endpoint }]
|
||||
})
|
||||
)
|
||||
const env = {
|
||||
...Object.fromEntries(
|
||||
'HOME USERPROFILE APPDATA LOCALAPPDATA XDG_CONFIG_HOME XDG_CACHE_HOME XDG_DATA_HOME XDG_STATE_HOME ORCA_E2E_HOME_DIR ORCA_E2E_USER_DATA_DIR ORCA_USER_DATA_PATH TEMP TMP TMPDIR'
|
||||
.split(' ')
|
||||
.map((key) => [key, root])
|
||||
),
|
||||
CODEX_HOME: join(root, 'codex'),
|
||||
CLAUDE_CONFIG_DIR: join(root, 'claude'),
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
ORCA_DISABLE_MACOS_LOGIN_SHELL: '1',
|
||||
PATH: process.env.PATH,
|
||||
SystemRoot: process.env.SystemRoot,
|
||||
ComSpec: process.env.ComSpec
|
||||
}
|
||||
const mutate = () =>
|
||||
requests.filter(
|
||||
({ method }) => method.startsWith('agent.launch') || method === 'worktree.create'
|
||||
)
|
||||
for (const command of [
|
||||
['agent', 'launch', '--worktree', 'id:wt-fixture'],
|
||||
['worktree', 'create', '--repo', 'id:repo-fixture', '--name', 'task', '--no-parent']
|
||||
]) {
|
||||
const run = (flags: string[]) =>
|
||||
runProcess({
|
||||
program,
|
||||
args: [resolve('src/cli/index.ts'), ...command, '--agent', 'claude', '--json', ...flags],
|
||||
cwd: root,
|
||||
env,
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
for (const surface of ['chat', 'terminal']) {
|
||||
const response = await run(['--surface', surface])
|
||||
expect(response.code, response.stderr + response.stdout).toBe(0)
|
||||
expect(mutate().at(-1)).toMatchObject({
|
||||
method: 'agent.launchReplay',
|
||||
params: {
|
||||
surface,
|
||||
agent: 'claude',
|
||||
launchSource: 'cli',
|
||||
presentation: 'background',
|
||||
target:
|
||||
command[0] === 'agent'
|
||||
? { kind: 'existing', worktree: 'id:wt-fixture' }
|
||||
: { kind: 'create-worktree', create: { repo: 'id:repo-fixture', name: 'task' } }
|
||||
}
|
||||
})
|
||||
}
|
||||
const beforeInvalid = requests.length
|
||||
for (const flags of [['--surface='], ['--surface'], ['--surface', 'invalid']]) {
|
||||
const response = await run(flags)
|
||||
expect(response.code).toBe(1)
|
||||
expect(response.stdout).toContain('invalid_argument')
|
||||
}
|
||||
expect(requests).toHaveLength(beforeInvalid)
|
||||
const beforeUnsupported = mutate().length
|
||||
for (const oldCapabilities of [
|
||||
[],
|
||||
AGENT_LAUNCH_RUNTIME_CAPABILITIES.filter(
|
||||
(capability) => capability !== AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY
|
||||
)
|
||||
]) {
|
||||
capabilities = oldCapabilities
|
||||
const response = await run(['--surface', 'chat'])
|
||||
expect(response.code).toBe(1)
|
||||
expect(response.stdout).toContain('incompatible_runtime')
|
||||
}
|
||||
expect(mutate()).toHaveLength(beforeUnsupported)
|
||||
capabilities = AGENT_LAUNCH_RUNTIME_CAPABILITIES
|
||||
}
|
||||
} finally {
|
||||
await new Promise<void>((done) => server.close(() => done()))
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
@@ -1,17 +1,19 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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_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'
|
||||
@@ -20,6 +22,7 @@ import type { TuiAgent } from '../shared/tui-agent'
|
||||
import { isTuiAgent } from '../shared/tui-agent-config'
|
||||
import {
|
||||
getOptionalStringFlag,
|
||||
getRequiredStringFlag,
|
||||
getRequiredStringFlagAllowingEmpty,
|
||||
rejectValuelessFlag
|
||||
} from './flags'
|
||||
@@ -38,6 +41,19 @@ export type CliAgentLaunch = {
|
||||
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 {
|
||||
@@ -73,8 +89,10 @@ export function readCliAgentLaunch(
|
||||
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()
|
||||
@@ -88,6 +106,10 @@ 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> {
|
||||
@@ -116,6 +138,9 @@ export async function callAgentLaunch(
|
||||
'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',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -63,6 +63,8 @@ export const FLAG_HELP_TEXT: Record<string, string> = {
|
||||
'restore-window': '--restore-window Bring the target app/window forward before the operation',
|
||||
session: '--session <id> Snapshot namespace for a related computer-use workflow',
|
||||
setup: '--setup run|skip|inherit Setup policy for repo-defined setup hooks',
|
||||
surface:
|
||||
'--surface chat|terminal Start the agent as a chat or in a terminal, instead of your setting',
|
||||
shell: '--shell <shell> Windows shell the terminal itself runs as',
|
||||
terminal: '--terminal <handle> Runtime-issued terminal handle',
|
||||
text: '--text <text> Text payload to send or type',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* `worktree create --agent`: the worktree and its agent in one `agent.launch`, so the agent follows
|
||||
* the user's Chat UI setting like every other launch. A host older than `agent.launch` gets the
|
||||
* create's own startup terminal, which cannot take a model or effort.
|
||||
* `--surface`, else the user's Chat UI setting, like every other launch. A host older than
|
||||
* `agent.launch` gets the create's own startup terminal, which cannot take a model, effort or surface.
|
||||
*/
|
||||
|
||||
import type { AgentLaunchResult } from '../../shared/agent-launch-intent'
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
formatAgentLaunch,
|
||||
hasLaunchModelFlags,
|
||||
getAgentLaunchCapabilities,
|
||||
type CliAgentLaunch
|
||||
type CliAgentLaunch,
|
||||
SURFACE_UNSUPPORTED_MESSAGE
|
||||
} from '../agent-launch-request'
|
||||
import { formatWorktreeShow, printResult } from '../format'
|
||||
import { RuntimeClientError, type RuntimeClient } from '../runtime-client'
|
||||
@@ -59,6 +60,9 @@ export async function createWorktreeWithAgent(args: {
|
||||
'This Orca host cannot start an agent on a chosen model or effort. Update Orca on the execution host, or omit --model and --effort.'
|
||||
)
|
||||
}
|
||||
if (launch.surface) {
|
||||
throw new RuntimeClientError('incompatible_runtime', SURFACE_UNSUPPORTED_MESSAGE)
|
||||
}
|
||||
const params = {
|
||||
...create,
|
||||
startupAgent: launch.agent,
|
||||
|
||||
@@ -70,7 +70,9 @@ function getPresentStringFlag(
|
||||
function getOptionalStartupAgent(flags: Map<string, string | boolean>): TuiAgent | undefined {
|
||||
const agent = getPresentStringFlag(flags, 'agent')
|
||||
if (agent === undefined) {
|
||||
const needsAgent = ['prompt', 'model', 'effort', 'retry-launch'].find((name) => flags.has(name))
|
||||
const needsAgent = ['prompt', 'model', 'effort', 'surface', 'retry-launch'].find((name) =>
|
||||
flags.has(name)
|
||||
)
|
||||
if (needsAgent) {
|
||||
throw new RuntimeClientError('invalid_argument', `--${needsAgent} requires --agent`)
|
||||
}
|
||||
|
||||
@@ -332,7 +332,8 @@ describe('orca cli agent launch', () => {
|
||||
it.each([
|
||||
[['--agent', 'codex', '--effort', 'high'], '--effort requires --model'],
|
||||
[['--model', 'gpt-5.5'], '--model requires --agent'],
|
||||
[['--agent', 'codex', '--model', 'gpt-5.5', '--effort', 'turbo'], 'does not support effort']
|
||||
[['--agent', 'codex', '--model', 'gpt-5.5', '--effort', 'turbo'], 'does not support effort'],
|
||||
[['--surface', 'terminal'], '--surface requires --agent']
|
||||
])('refuses %j before creating anything', async (flags, message) => {
|
||||
const output = await runRefused([
|
||||
'worktree',
|
||||
|
||||
@@ -4,9 +4,9 @@ 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',
|
||||
summary: 'Start an agent in an existing workspace, as a chat or a terminal',
|
||||
usage:
|
||||
'orca agent launch --agent <id> [--worktree <selector>] [--prompt <text>] [--model <id>] [--effort <level>] [--focus] [--retry-launch <operationId>] [--json]',
|
||||
'orca agent launch --agent <id> [--worktree <selector>] [--prompt <text>] [--model <id>] [--effort <level>] [--surface chat|terminal] [--focus] [--retry-launch <operationId>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'agent',
|
||||
@@ -14,11 +14,13 @@ export const AGENT_LAUNCH_COMMAND_SPECS: CommandSpec[] = [
|
||||
'prompt',
|
||||
'model',
|
||||
'effort',
|
||||
'surface',
|
||||
'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`).',
|
||||
'--surface chat|terminal picks for this launch instead of your setting. --surface terminal always runs the agent CLI; --surface chat is refused when Orca can establish before starting anything that this agent cannot open as a chat there. If support cannot be checked, the launch can fall back to a terminal. If chat becomes unavailable after creating a workspace, Orca keeps the workspace, setup and default tabs, then opens a terminal; the receipt explains the fallback.',
|
||||
'--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.',
|
||||
|
||||
@@ -68,7 +68,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
repeatableFlags: ['reference'],
|
||||
summary: 'Create a new Orca-managed worktree',
|
||||
usage:
|
||||
'orca worktree create --name <name> [--repo <selector>|--project <id-or-name> [--host <host>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--model <id>] [--effort <level>] [--setup run|skip|inherit] [--base-branch <ref>] [--reference <url-or-jira-key>...] [--reference-site <id>] [--issue <number>] [--pr <number>] [--linear-issue <identifier-or-url>] [--gitlab-issue <number-or-url>] [--gitlab-mr <number-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--retry-launch <operationId>] [--json]',
|
||||
'orca worktree create --name <name> [--repo <selector>|--project <id-or-name> [--host <host>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--model <id>] [--effort <level>] [--surface chat|terminal] [--setup run|skip|inherit] [--base-branch <ref>] [--reference <url-or-jira-key>...] [--reference-site <id>] [--issue <number>] [--pr <number>] [--linear-issue <identifier-or-url>] [--gitlab-issue <number-or-url>] [--gitlab-mr <number-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--retry-launch <operationId>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'repo',
|
||||
@@ -80,6 +80,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
'prompt',
|
||||
'model',
|
||||
'effort',
|
||||
'surface',
|
||||
'retry-launch',
|
||||
'base-branch',
|
||||
'reference',
|
||||
@@ -108,7 +109,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
'Use --no-parent when the new worktree should be independent of the current context.',
|
||||
'--no-parent only affects Orca lineage; omit --base-branch to use the repo default base, or pass the default base ref explicitly for independent top-level work.',
|
||||
'By default this creates the worktree and its first terminal without switching the active Orca view.',
|
||||
'Pass --agent to start an agent with the worktree, as a chat or a terminal per your Chat UI setting, exactly as a new agent tab does; --prompt sends initial work to it. --model and --effort pick its model and reasoning effort.',
|
||||
'Pass --agent to start an agent with the worktree, as a chat or a terminal per your Chat UI setting, exactly as a new agent tab does; --prompt sends initial work to it. --model and --effort pick its model and reasoning effort, and --surface chat|terminal picks chat or terminal for this launch instead of your setting.',
|
||||
'After a lost agent launch reply, 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.',
|
||||
'With --agent --json, result.launch says what started: launch.outcome.kind (structured chat or terminal), launch.outcome.handle, and launch.receipt.detail for why. An Orca host older than this returns the terminal agent handle in result.agentTerminalHandle instead.',
|
||||
'Repo-defined setup hooks follow the repository setup policy; pass --setup run to force them.',
|
||||
|
||||
@@ -54,6 +54,7 @@ function harness(options: {
|
||||
}
|
||||
return {
|
||||
worktreeId: 'wt-new',
|
||||
workspacePath: '/repo/task',
|
||||
connectionId: null,
|
||||
startupTerminalHandle: args.startupAgent ? 'term_agent_first' : undefined,
|
||||
...carried(args.startupPrompt)
|
||||
@@ -62,7 +63,7 @@ function harness(options: {
|
||||
)
|
||||
const createFolderWorkspace = vi.fn(async (_args: { create: Record<string, unknown> }) => {
|
||||
calls.push('createFolderWorkspace')
|
||||
return { worktreeId: 'folder:fw-new', connectionId: null }
|
||||
return { worktreeId: 'folder:fw-new', workspacePath: '/repo/task', connectionId: null }
|
||||
})
|
||||
const createRecoveryShell = vi.fn(async (_worktreeId: string) => {
|
||||
calls.push('createRecoveryShell')
|
||||
@@ -184,28 +185,24 @@ describe('a structured launch that creates its own worktree', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('asks the host only after the workspace exists, never before', async () => {
|
||||
const h = harness({})
|
||||
await h.run(CREATE_INTENT)
|
||||
expect(h.calls.indexOf('createSupport')).toBeGreaterThan(
|
||||
h.calls.indexOf('createWorktree(startupAgent=undefined)')
|
||||
)
|
||||
})
|
||||
it.each([undefined, 'chat'] as const)(
|
||||
'keeps the created worktree on host refusal (%s)',
|
||||
async (surface) => {
|
||||
const h = harness({ createSupport: { supported: false, reason: 'wsl' } })
|
||||
const result = await h.run({ ...CREATE_INTENT, surface })
|
||||
|
||||
it('falls back to a terminal in the worktree it just created when the host refuses', async () => {
|
||||
const h = harness({ createSupport: { supported: false, reason: 'wsl' } })
|
||||
const result = await h.run(CREATE_INTENT)
|
||||
|
||||
expect(h.calls).toEqual([
|
||||
'createWorktree(startupAgent=undefined)',
|
||||
'createSupport',
|
||||
'createTerminalAgent'
|
||||
])
|
||||
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' })
|
||||
// Not a failed launch, and the workspace is the one just created.
|
||||
expect(result.worktreeId).toBe('wt-new')
|
||||
expect(result.receipt).toMatchObject({ mode: 'terminal', reason: 'wsl_execution_runtime' })
|
||||
})
|
||||
expect(h.calls).toEqual([
|
||||
'createWorktree(startupAgent=undefined)',
|
||||
'createSupport',
|
||||
'createTerminalAgent'
|
||||
])
|
||||
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' })
|
||||
// Not a failed launch, and the workspace is the one just created.
|
||||
expect(result.worktreeId).toBe('wt-new')
|
||||
expect(result.receipt).toMatchObject({ mode: 'terminal', reason: 'wsl_execution_runtime' })
|
||||
expect(result.receipt.detail).toContain(surface ? 'You asked for' : 'Your default is')
|
||||
}
|
||||
)
|
||||
|
||||
it('falls back to a terminal when the host cannot be reached at all', async () => {
|
||||
const h = harness({ createSupportThrows: true })
|
||||
@@ -877,6 +874,7 @@ describe('a new local worktree whose startup terminal did not come up', () => {
|
||||
})
|
||||
h.createWorktree.mockImplementationOnce(async () => ({
|
||||
worktreeId: 'wt-new',
|
||||
workspacePath: '/repo/task',
|
||||
connectionId: null,
|
||||
startupTerminalHandle: undefined
|
||||
}))
|
||||
@@ -887,3 +885,52 @@ describe('a new local worktree whose startup terminal did not come up', () => {
|
||||
expect(h.createTerminalAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('explicit chat settlement uses resolved facts and launch effects', () => {
|
||||
it.each(['create-worktree', 'create-folder-workspace'] as const)(
|
||||
'settles root and custom cwd after %s without losing launch inputs',
|
||||
async (kind) => {
|
||||
for (const cwd of ['.', '/repo/task', 'packages/api']) {
|
||||
const h = harness({ settings: {} })
|
||||
const result = await h.run({
|
||||
agent: 'claude',
|
||||
surface: 'chat',
|
||||
cwd,
|
||||
target: { kind, create: {} },
|
||||
sessionOptions: { model: 'opus' },
|
||||
prompt: { text: 'do the task', delivery: 'submit' }
|
||||
})
|
||||
expect(result.outcome.kind).toBe(cwd === 'packages/api' ? 'terminal' : 'structured')
|
||||
if (cwd === 'packages/api') {
|
||||
expect(result.receipt.detail).toContain('You asked for')
|
||||
expect(h.createTerminalAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd,
|
||||
options: { model: 'opus' },
|
||||
startupPrompt: 'do the task'
|
||||
})
|
||||
)
|
||||
expect(h.createStructuredSession).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('refuses a definitive no-session factory answer only before workspace creation', async () => {
|
||||
const options = {
|
||||
structuredCreateError: new AgentLaunchStructuredSessionRefusedError(
|
||||
'structured_agent_session_unsupported',
|
||||
'unsupported'
|
||||
)
|
||||
}
|
||||
const existing = harness(options)
|
||||
await expect(
|
||||
existing.run({
|
||||
agent: 'claude',
|
||||
surface: 'chat',
|
||||
target: { kind: 'existing', worktree: 'wt-7' }
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'agent_launch_surface_unavailable' })
|
||||
expect(existing.createTerminalAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { assertOpenCodeModelLaunchPreferencesAbsent } from '../opencode/opencode-model-startup-plan'
|
||||
import { parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import type { AgentLaunchIntent, AgentLaunchResult } from '../../shared/agent-launch-intent'
|
||||
import type { AgentLaunchResult } from '../../shared/agent-launch-intent'
|
||||
import { withoutReservedAgentCreateFields } from '../../shared/agent-launch-intent'
|
||||
import {
|
||||
combineLaunchWarnings,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
readAgentLaunchModeSettings,
|
||||
resolveAgentLaunchModeOnHost,
|
||||
type AgentLaunchModeReceipt,
|
||||
type AgentLaunchModeSettlement,
|
||||
DEFAULT_LAUNCH_VOCABULARY,
|
||||
downgradeAgentLaunchModeForStructuredRefusal,
|
||||
warnStructuredLaunchDowngrade
|
||||
@@ -35,7 +36,9 @@ import {
|
||||
} from './agent-launch-legacy-host'
|
||||
import {
|
||||
AgentLaunchStructuredSessionRefusedError,
|
||||
type AgentLaunchStructuredSurface
|
||||
terminalLaunchInputs,
|
||||
type AgentLaunchStructuredSurface,
|
||||
type AgentLaunchWorkspaceFactory
|
||||
} from './agent-launch-surface-factories'
|
||||
import type {
|
||||
AgentLaunchExecution,
|
||||
@@ -74,6 +77,7 @@ export async function executeAgentLaunch(
|
||||
? undefined
|
||||
: readAgentLaunchModeSettings(runtime, intent.nativeChatDefault),
|
||||
...(execution.terminalOnly ? { terminalOnly: true } : {}),
|
||||
...(intent.surface ? { surface: intent.surface } : {}),
|
||||
vocabulary
|
||||
})
|
||||
// The create's startup terminal is this launch's only surface, and the create delivers the text.
|
||||
@@ -140,6 +144,12 @@ export async function executeAgentLaunch(
|
||||
}
|
||||
}
|
||||
|
||||
const settlement: AgentLaunchModeSettlement = {
|
||||
phase: intent.target.kind === 'existing' ? 'before-effects' : 'created-workspace',
|
||||
agent: intent.agent,
|
||||
workspacePath: placed.workspacePath,
|
||||
cwd: intent.cwd
|
||||
}
|
||||
execution.onStage?.('mode_settle')
|
||||
// A caller's own decision about an existing workspace already carries that host's answer.
|
||||
let settled =
|
||||
@@ -150,7 +160,8 @@ export async function executeAgentLaunch(
|
||||
preflight,
|
||||
placed.worktreeId,
|
||||
intent.agent,
|
||||
vocabulary
|
||||
vocabulary,
|
||||
settlement
|
||||
)
|
||||
|
||||
execution.onStage?.('surface_create')
|
||||
@@ -166,7 +177,7 @@ export async function executeAgentLaunch(
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
settled = downgradeAgentLaunchModeForStructuredRefusal(settled, vocabulary)
|
||||
settled = downgradeAgentLaunchModeForStructuredRefusal(settled, vocabulary, settlement)
|
||||
created = await createTerminalSurface(execution, placed)
|
||||
}
|
||||
// Both CAN be set, so neither may be dropped. The create warns precisely when it produced no
|
||||
@@ -217,20 +228,12 @@ async function resolveWorkspace(
|
||||
execution: AgentLaunchExecution,
|
||||
preflight: AgentLaunchModeReceipt,
|
||||
onWorkspaceCreated?: (worktreeId: string) => void
|
||||
): Promise<{
|
||||
worktreeId: string
|
||||
connectionId?: string | null
|
||||
startupTerminalHandle: string | undefined
|
||||
startupTerminalPaneKey?: string
|
||||
warning?: string
|
||||
/** True when this create folded the prompt into the agent's startup command. */
|
||||
promptRodeLaunchCommand?: boolean
|
||||
}> {
|
||||
): Promise<Awaited<ReturnType<AgentLaunchWorkspaceFactory['createWorktree']>>> {
|
||||
const { intent } = execution
|
||||
if (intent.target.kind === 'existing') {
|
||||
// Nothing was created, so there is no create warning to carry.
|
||||
const { worktree: worktreeId, connectionId } = intent.target
|
||||
return { worktreeId, connectionId, startupTerminalHandle: undefined }
|
||||
const { worktree: worktreeId, connectionId, workspacePath } = intent.target
|
||||
return { worktreeId, connectionId, workspacePath, startupTerminalHandle: undefined }
|
||||
}
|
||||
const workspaces = execution.workspaces
|
||||
if (!workspaces) {
|
||||
@@ -304,20 +307,6 @@ async function createSurface(
|
||||
return createTerminalSurface(execution, workspace)
|
||||
}
|
||||
|
||||
/** What every route that builds a terminal agent passes on, so the startup terminal of a new
|
||||
* workspace and the terminal of an existing one start the same agent. */
|
||||
function terminalLaunchInputs(intent: AgentLaunchIntent) {
|
||||
return {
|
||||
...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
|
||||
// `null` is a value the caller meant, so this tests for absence rather than falsiness.
|
||||
...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}),
|
||||
...(intent.cwd ? { cwd: intent.cwd } : {}),
|
||||
...(intent.launchSource ? { launchSource: intent.launchSource } : {}),
|
||||
...(intent.paneKey ? { paneKey: intent.paneKey } : {}),
|
||||
...(intent.accountEnv ? { accountEnv: intent.accountEnv } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a terminal agent is created, so the structured-refusal downgrade builds the same
|
||||
* surface — carrying the same argv prompt — as a launch that chose a terminal outright.
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Which surface a launch gets — a structured chat session or a terminal agent — decided from the
|
||||
* user's own settings and the executing host's answer.
|
||||
* caller's explicit pick, else the user's own settings, and the executing host's answer.
|
||||
*
|
||||
* No caller passes a mode. If the user's default is that a new agent tab opens as a structured
|
||||
* native chat, then every launch is one: an orchestration worker, a mobile create, a CLI create,
|
||||
* a renderer tab. That default is a preference rather than a demand, so a launch it cannot apply
|
||||
* to falls back to a PTY terminal and the receipt says which mode ran and why — a routine launch
|
||||
* must never fail because the user happens to have a chat preference on.
|
||||
* A caller may name the surface (`agent.launch`'s `surface`). Without one, the user's default
|
||||
* applies to every launch: an orchestration worker, a mobile create, a CLI create, a renderer tab.
|
||||
* That default is a preference rather than a demand, so a launch it cannot apply to falls back to a
|
||||
* PTY terminal and the receipt says which mode ran and why — a routine launch must never fail
|
||||
* because the user happens to have a chat preference on. An explicit chat is a demand: one this
|
||||
* host conclusively refuses before launch effects is rejected. A created workspace keeps its
|
||||
* terminal fallback.
|
||||
*
|
||||
* The settings default and the per-launch feasibility both come from
|
||||
* `shared/structured-native-chat-launch-route`. This module supplies placement facts and formats
|
||||
@@ -34,11 +36,16 @@ import {
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { WorkspaceLaunchKind } from '../../shared/workspace-launch-kind'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { AgentLaunchSurface } from '../../shared/agent-launch-surface'
|
||||
import { formatAgentTypeLabel } from '../../shared/agent-type-label'
|
||||
import { AgentLaunchSurfaceUnavailableError } from './agent-launch-surface-refusal'
|
||||
|
||||
// The receipt is part of the launch contract, so it is declared with the rest of it; re-exported
|
||||
// here because this module is where the decision that fills it lives.
|
||||
export type { AgentLaunchMode, AgentLaunchModeReason, AgentLaunchModeReceipt }
|
||||
|
||||
type AgentLaunchDowngradeReason = Exclude<AgentLaunchModeReason, 'user_default' | 'requested'>
|
||||
|
||||
/** What this caller calls the thing it is starting, so one decision serves every surface without
|
||||
* a receipt reading "worker" on a phone. */
|
||||
export type AgentLaunchModeVocabulary = {
|
||||
@@ -48,7 +55,7 @@ export type AgentLaunchModeVocabulary = {
|
||||
terminal: string
|
||||
/** Per-reason wording a surface states differently. Orchestration names the `--terminal` flag
|
||||
* in its reused-terminal detail, which would be meaningless in a phone's receipt. */
|
||||
detailOverrides?: Partial<Record<Exclude<AgentLaunchModeReason, 'user_default'>, string>>
|
||||
detailOverrides?: Partial<Record<AgentLaunchDowngradeReason, string>>
|
||||
}
|
||||
|
||||
export const DEFAULT_LAUNCH_VOCABULARY: AgentLaunchModeVocabulary = {
|
||||
@@ -76,7 +83,7 @@ export type AgentLaunchModePlacement = {
|
||||
* `workspacePath`: a cwd that names the root asks for nothing and decides nothing. */
|
||||
cwd?: string
|
||||
/** The root of the workspace the launch lands in, when the host has resolved it. Without it a
|
||||
* requested `cwd` cannot be proven to name the root and is read as custom. */
|
||||
* requested `cwd` remains unresolved until materialization. */
|
||||
workspacePath?: string
|
||||
/** False when the client asking for the launch cannot show this agent's chat; absent for the
|
||||
* host's own callers, which can. */
|
||||
@@ -87,7 +94,7 @@ const REGISTERED_STRUCTURED_AGENTS: readonly string[] = STRUCTURED_AGENT_RUNTIME
|
||||
({ definition }) => definition.agent
|
||||
)
|
||||
|
||||
const DOWNGRADE_DETAIL: Record<Exclude<AgentLaunchModeReason, 'user_default'>, string> = {
|
||||
const DOWNGRADE_DETAIL: Record<AgentLaunchDowngradeReason, string> = {
|
||||
remote_execution_host: 'this launch runs on a remote execution host',
|
||||
reused_terminal: 'it reuses a running terminal agent',
|
||||
agent_without_structured_session: 'this agent has no structured session',
|
||||
@@ -99,10 +106,7 @@ const DOWNGRADE_DETAIL: Record<Exclude<AgentLaunchModeReason, 'user_default'>, s
|
||||
structured_unsupported_on_host: 'the execution host cannot create one here'
|
||||
}
|
||||
|
||||
const BLOCKER_REASON: Record<
|
||||
StructuredNativeChatBlocker,
|
||||
Exclude<AgentLaunchModeReason, 'user_default'>
|
||||
> = {
|
||||
const BLOCKER_REASON: Record<StructuredNativeChatBlocker, AgentLaunchDowngradeReason> = {
|
||||
'reused-terminal': 'reused_terminal',
|
||||
'agent-without-structured-session': 'agent_without_structured_session',
|
||||
'custom-start-directory': 'tui_launch_command',
|
||||
@@ -114,10 +118,7 @@ const BLOCKER_REASON: Record<
|
||||
}
|
||||
|
||||
/** The host's own create-support verdict (`agentSession.createSupport`) in this vocabulary. */
|
||||
const HOST_SUPPORT_REASON: Record<
|
||||
'agent' | 'remote' | 'wsl',
|
||||
Exclude<AgentLaunchModeReason, 'user_default'>
|
||||
> = {
|
||||
const HOST_SUPPORT_REASON: Record<'agent' | 'remote' | 'wsl', AgentLaunchDowngradeReason> = {
|
||||
agent: 'structured_unsupported_on_host',
|
||||
remote: 'remote_execution_host',
|
||||
wsl: 'wsl_execution_runtime'
|
||||
@@ -132,13 +133,28 @@ export function decideAgentLaunchMode(args: {
|
||||
settings: AgentLaunchModeSettings | null | undefined
|
||||
/** Host-internal: the caller's contract is a terminal handle, so the chat default cannot apply. */
|
||||
terminalOnly?: boolean
|
||||
/** The caller's explicit pick, which replaces the settings default for this launch. */
|
||||
surface?: AgentLaunchSurface
|
||||
vocabulary?: AgentLaunchModeVocabulary
|
||||
}): AgentLaunchModeReceipt {
|
||||
const { placement, settings } = args
|
||||
const { placement, settings, surface } = args
|
||||
const vocabulary = args.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an unrecognized agent name is handled rather than trusted; the registered-agent check rejects it and the launch downgrades to a terminal.
|
||||
const agent = placement.agent as TuiAgent
|
||||
if (args.terminalOnly || !isNativeChatDefaultFor(settings, agent)) {
|
||||
if (surface === 'terminal' && !args.terminalOnly) {
|
||||
return {
|
||||
mode: 'terminal',
|
||||
preferred: 'terminal',
|
||||
reason: 'requested',
|
||||
detail: `Started ${vocabulary.terminal}, as requested.`
|
||||
}
|
||||
}
|
||||
const blocked = (reason: AgentLaunchDowngradeReason): AgentLaunchModeReceipt =>
|
||||
unavailableLaunchMode(reason, vocabulary, surface === 'chat', {
|
||||
agent,
|
||||
phase: 'before-effects'
|
||||
})
|
||||
if (args.terminalOnly || (surface !== 'chat' && !isNativeChatDefaultFor(settings, agent))) {
|
||||
return {
|
||||
mode: 'terminal',
|
||||
preferred: 'terminal',
|
||||
@@ -149,8 +165,9 @@ export function decideAgentLaunchMode(args: {
|
||||
// A worker placed on another runtime starts through federation, which creates terminal agents
|
||||
// only; this host cannot answer for that runtime's structured support.
|
||||
if (placement.on) {
|
||||
return downgraded('remote_execution_host', vocabulary)
|
||||
return blocked('remote_execution_host')
|
||||
}
|
||||
const cwdRelation = launchCwdRelation(placement)
|
||||
const support = resolveStructuredNativeChatSupport({
|
||||
agent,
|
||||
executionHostId: 'local',
|
||||
@@ -163,45 +180,64 @@ export function decideAgentLaunchMode(args: {
|
||||
...(placement.workspaceKind ? { workspaceKind: placement.workspaceKind } : {}),
|
||||
// Mirrors the renderer's own route input (`agent-launch-route-input.ts`): a cwd is terminal-only
|
||||
// when it names somewhere other than the workspace root, by the same shared rule.
|
||||
startsOutsideWorkspaceRoot: requestsCwdOutsideWorkspaceRoot(
|
||||
placement.workspacePath,
|
||||
placement.cwd
|
||||
)
|
||||
startsOutsideWorkspaceRoot:
|
||||
cwdRelation === 'custom' || (surface !== 'chat' && cwdRelation === 'unknown')
|
||||
})
|
||||
if (!support.supported) {
|
||||
return downgraded(BLOCKER_REASON[support.blocker], vocabulary)
|
||||
return blocked(BLOCKER_REASON[support.blocker])
|
||||
}
|
||||
// A chat its caller can neither show nor close is no launch; that caller gets the terminal.
|
||||
if (placement.callerRendersStructured === false) {
|
||||
return downgraded(BLOCKER_REASON['client-capability'], vocabulary)
|
||||
}
|
||||
return {
|
||||
mode: 'structured',
|
||||
preferred: 'structured',
|
||||
reason: 'user_default',
|
||||
detail: `Started ${vocabulary.structured}, the default for new agent tabs in your settings.`
|
||||
return blocked(BLOCKER_REASON['client-capability'])
|
||||
}
|
||||
return surface === 'chat'
|
||||
? {
|
||||
mode: 'structured',
|
||||
preferred: 'structured',
|
||||
reason: 'requested',
|
||||
detail: `Started ${vocabulary.structured}, as requested.`
|
||||
}
|
||||
: {
|
||||
mode: 'structured',
|
||||
preferred: 'structured',
|
||||
reason: 'user_default',
|
||||
detail: `Started ${vocabulary.structured}, the default for new agent tabs in your settings.`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Second half, once the workspace is resolved: the host that will run the agent answers whether it
|
||||
* can create a structured session there at all. Asked before anything is created, so a refusal
|
||||
* becomes a terminal agent rather than a failed launch.
|
||||
* can create a structured session there. Only a conclusive refusal before launch effects can
|
||||
* reject an explicit chat; a materialized workspace retains its terminal fallback.
|
||||
*/
|
||||
export async function resolveAgentLaunchModeOnHost(
|
||||
runtime: Pick<OrcaRuntimeService, 'getStructuredAgentSessionCreateSupport'>,
|
||||
receipt: AgentLaunchModeReceipt,
|
||||
worktreeId: string | undefined,
|
||||
agent: TuiAgent | undefined,
|
||||
vocabulary: AgentLaunchModeVocabulary = DEFAULT_LAUNCH_VOCABULARY
|
||||
vocabulary: AgentLaunchModeVocabulary = DEFAULT_LAUNCH_VOCABULARY,
|
||||
context?: AgentLaunchModeSettlement
|
||||
): Promise<AgentLaunchModeReceipt> {
|
||||
if (receipt.mode !== 'structured' || !worktreeId) {
|
||||
return receipt
|
||||
}
|
||||
if (context && launchCwdRelation(context) !== 'root') {
|
||||
return unavailableLaunchMode(
|
||||
'tui_launch_command',
|
||||
vocabulary,
|
||||
receipt.reason === 'requested',
|
||||
{
|
||||
...context,
|
||||
agent
|
||||
},
|
||||
launchCwdRelation(context) === 'custom'
|
||||
)
|
||||
}
|
||||
return downgradeAgentLaunchModeForHost(
|
||||
receipt,
|
||||
await readStructuredCreateSupport(runtime, worktreeId, agent),
|
||||
vocabulary
|
||||
vocabulary,
|
||||
context ? { ...context, agent } : undefined
|
||||
)
|
||||
}
|
||||
|
||||
@@ -228,42 +264,85 @@ async function readStructuredCreateSupport(
|
||||
export function downgradeAgentLaunchModeForHost(
|
||||
receipt: AgentLaunchModeReceipt,
|
||||
support: { supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } | null,
|
||||
vocabulary: AgentLaunchModeVocabulary = DEFAULT_LAUNCH_VOCABULARY
|
||||
vocabulary: AgentLaunchModeVocabulary = DEFAULT_LAUNCH_VOCABULARY,
|
||||
context?: AgentLaunchModeSettlement
|
||||
): AgentLaunchModeReceipt {
|
||||
if (receipt.mode !== 'structured' || support?.supported) {
|
||||
return receipt
|
||||
}
|
||||
const requested = receipt.reason === 'requested'
|
||||
if (support === null) {
|
||||
return downgraded(BLOCKER_REASON['runtime-capability-unknown'], vocabulary)
|
||||
return downgraded(BLOCKER_REASON['runtime-capability-unknown'], vocabulary, requested)
|
||||
}
|
||||
return downgraded(
|
||||
return unavailableLaunchMode(
|
||||
support.reason ? HOST_SUPPORT_REASON[support.reason] : 'structured_unsupported_on_host',
|
||||
vocabulary
|
||||
vocabulary,
|
||||
requested,
|
||||
context
|
||||
)
|
||||
}
|
||||
|
||||
export function downgradeAgentLaunchModeForStructuredRefusal(
|
||||
receipt: AgentLaunchModeReceipt,
|
||||
vocabulary: AgentLaunchModeVocabulary
|
||||
vocabulary: AgentLaunchModeVocabulary,
|
||||
context?: AgentLaunchModeSettlement
|
||||
): AgentLaunchModeReceipt {
|
||||
const fallback = unavailableLaunchMode(
|
||||
'structured_unsupported_on_host',
|
||||
vocabulary,
|
||||
receipt.reason === 'requested',
|
||||
context
|
||||
)
|
||||
return {
|
||||
mode: 'terminal',
|
||||
preferred: receipt.preferred,
|
||||
reason: 'structured_unsupported_on_host',
|
||||
detail: `Your default is a structured chat session, but the host refused to create one here; started ${vocabulary.terminal} instead.`
|
||||
...fallback,
|
||||
detail: `${receipt.reason === 'requested' ? 'You asked for' : 'Your default is'} a structured chat session, but the host refused to create one here; started ${vocabulary.terminal} instead.`
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase is derived from successful materialization, never from a support lookup's availability. */
|
||||
export type AgentLaunchModeSettlement = {
|
||||
phase: 'before-effects' | 'created-workspace'
|
||||
agent?: TuiAgent
|
||||
workspacePath?: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
function launchCwdRelation(placement: Pick<AgentLaunchModePlacement, 'cwd' | 'workspacePath'>) {
|
||||
if (!placement.cwd?.trim()) {
|
||||
return 'root'
|
||||
}
|
||||
if (!placement.workspacePath) {
|
||||
return 'unknown'
|
||||
}
|
||||
return requestsCwdOutsideWorkspaceRoot(placement.workspacePath, placement.cwd) ? 'custom' : 'root'
|
||||
}
|
||||
|
||||
function unavailableLaunchMode(
|
||||
reason: AgentLaunchDowngradeReason,
|
||||
vocabulary: AgentLaunchModeVocabulary,
|
||||
requested: boolean,
|
||||
context?: AgentLaunchModeSettlement,
|
||||
conclusive = true
|
||||
): AgentLaunchModeReceipt {
|
||||
if (requested && conclusive && context?.phase === 'before-effects') {
|
||||
throw new AgentLaunchSurfaceUnavailableError(
|
||||
`${formatAgentTypeLabel(context.agent)} can't open as a chat here because ${DOWNGRADE_DETAIL[reason]}. Start it in a terminal instead.`
|
||||
)
|
||||
}
|
||||
return downgraded(reason, vocabulary, requested)
|
||||
}
|
||||
|
||||
function downgraded(
|
||||
reason: Exclude<AgentLaunchModeReason, 'user_default'>,
|
||||
vocabulary: AgentLaunchModeVocabulary
|
||||
reason: AgentLaunchDowngradeReason,
|
||||
vocabulary: AgentLaunchModeVocabulary,
|
||||
requested = false
|
||||
): AgentLaunchModeReceipt {
|
||||
const why = vocabulary.detailOverrides?.[reason] ?? DOWNGRADE_DETAIL[reason]
|
||||
return {
|
||||
mode: 'terminal',
|
||||
preferred: 'structured',
|
||||
reason,
|
||||
detail: `Your default is a structured chat session, but ${why}; started ${vocabulary.terminal} instead.`
|
||||
detail: `${requested ? 'You asked for' : 'Your default is'} a structured chat session, but ${why}; started ${vocabulary.terminal} instead.`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* implements, not part of the sequencing it runs.
|
||||
*/
|
||||
|
||||
import type { AgentLaunchPrompt } from '../../shared/agent-launch-intent'
|
||||
import type { AgentLaunchIntent, AgentLaunchPrompt } from '../../shared/agent-launch-intent'
|
||||
import type { StructuredAgentId } from '../../shared/agent-session-provider-handle'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
|
||||
@@ -133,6 +133,8 @@ export type AgentLaunchWorkspaceFactory = {
|
||||
accountEnv?: Readonly<Record<string, string>>
|
||||
}): Promise<{
|
||||
worktreeId: string
|
||||
/** Actual host-resolved root, not a prediction from the create request. */
|
||||
workspacePath?: string
|
||||
/** The new workspace's SSH connection; `null` is local. The executor carries it but nothing
|
||||
* reads it yet; absent when the factory did not resolve it. */
|
||||
connectionId?: string | null
|
||||
@@ -150,5 +152,19 @@ export type AgentLaunchWorkspaceFactory = {
|
||||
* Only `agent.launch` creates folders; callers that only create worktrees omit it. */
|
||||
createFolderWorkspace?(args: {
|
||||
create: Readonly<Record<string, unknown>>
|
||||
}): Promise<{ worktreeId: string; connectionId: string | null }>
|
||||
}): Promise<{ worktreeId: string; workspacePath?: string; connectionId: string | null }>
|
||||
}
|
||||
|
||||
/** What every route that builds a terminal agent passes on, so the startup terminal of a new
|
||||
* workspace and the terminal of an existing one start the same agent. */
|
||||
export function terminalLaunchInputs(intent: AgentLaunchIntent) {
|
||||
return {
|
||||
...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
|
||||
// `null` is a value the caller meant, so this tests for absence rather than falsiness.
|
||||
...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}),
|
||||
...(intent.cwd ? { cwd: intent.cwd } : {}),
|
||||
...(intent.launchSource ? { launchSource: intent.launchSource } : {}),
|
||||
...(intent.paneKey ? { paneKey: intent.paneKey } : {}),
|
||||
...(intent.accountEnv ? { accountEnv: intent.accountEnv } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AGENT_LAUNCH_SURFACE_UNAVAILABLE_CODE } from '../../shared/agent-launch-surface'
|
||||
import { OrchestrationError } from '../runtime/orchestration/orchestration-error'
|
||||
|
||||
/** Raised only before launch effects: preflight or a definitive existing-target refusal,
|
||||
* so a launch that throws it had no effects and its replay answers with the same words. */
|
||||
export class AgentLaunchSurfaceUnavailableError extends OrchestrationError {
|
||||
constructor(message: string) {
|
||||
super(AGENT_LAUNCH_SURFACE_UNAVAILABLE_CODE, message)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// runtime/browser error allowlists define the contract the CLI relies on to
|
||||
// format human-facing messages. Centralizing this mapping keeps the allowlist
|
||||
// auditable in one place instead of spread across per-method branches.
|
||||
import { AGENT_LAUNCH_SURFACE_UNAVAILABLE_CODE } from '../../../shared/agent-launch-surface'
|
||||
import {
|
||||
agentSessionRefusalReference,
|
||||
isAgentSessionRefusalError,
|
||||
@@ -107,6 +108,7 @@ const COMPUTER_PASSTHROUGH_CODES: ReadonlySet<string> = new Set(Object.values(CO
|
||||
const LINEAR_PASSTHROUGH_CODES: ReadonlySet<string> = new Set(LINEAR_ERROR_CODES)
|
||||
const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
|
||||
WORKTREE_CREATE_COLLISION_CODE,
|
||||
AGENT_LAUNCH_SURFACE_UNAVAILABLE_CODE,
|
||||
'worktree_id_requires_full_path',
|
||||
'run_not_found',
|
||||
'run_required',
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import type { TerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started'
|
||||
import { FolderWorkspaceCreateRefusedError } from '../../../project-groups/folder-workspace-create-refusal'
|
||||
import { WorktreeCreateSetupChoiceRefusedError } from '../../worktree-create-setup-choice-refusal'
|
||||
import { AgentLaunchSurfaceUnavailableError } from '../../../agent-launch/agent-launch-surface-refusal'
|
||||
|
||||
/** Long enough for every code this path raises, with room for one a later guard adds. */
|
||||
const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128
|
||||
@@ -74,6 +75,10 @@ export function launchFailureWithoutEffectsCode(
|
||||
if (error instanceof FolderWorkspaceCreateRefusedError) {
|
||||
return folderCreateRefusalCode(error)
|
||||
}
|
||||
// Raised only before launch effects, including definitive existing-workspace refusals.
|
||||
if (error instanceof AgentLaunchSurfaceUnavailableError) {
|
||||
return agentLaunchFailureCode(error)
|
||||
}
|
||||
if (error instanceof WorktreeCreateSetupChoiceRefusedError && targetKind === 'create-worktree') {
|
||||
return agentLaunchFailureCode(error)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,6 @@ export function agentLaunchFolderWorkspaceCreator(
|
||||
const scope = await context.runtime.showTerminalWorkspaceLaunchScope(
|
||||
`id:${folderWorkspaceKey(workspace.id)}`
|
||||
)
|
||||
return { worktreeId: scope.id, connectionId: scope.connectionId }
|
||||
return { worktreeId: scope.id, workspacePath: scope.path, connectionId: scope.connectionId }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ async function agentLaunchIntent(
|
||||
...(params.accountEnv ? { accountEnv: params.accountEnv } : {}),
|
||||
...(params.nativeChatDefault !== undefined
|
||||
? { nativeChatDefault: params.nativeChatDefault }
|
||||
: {})
|
||||
: {}),
|
||||
...(params.surface ? { surface: params.surface } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import '../unused-default-rpc-methods.test-fixture'
|
||||
/**
|
||||
* A terminal launch that fails before its spawn is requested — no launch command, runtime
|
||||
* unavailable — created nothing, so a named operation settles as failed with its real cause.
|
||||
* Once the request has left, a failure proves nothing and the outcome stays unknown.
|
||||
* Once the request has left, a failure proves nothing and the outcome stays unknown. An explicit
|
||||
* chat the host can prove it cannot open is refused the same way, before anything is created.
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
@@ -10,6 +11,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation'
|
||||
import { AGENT_LAUNCH_SURFACE_UNAVAILABLE_CODE } from '../../../../shared/agent-launch-surface'
|
||||
import type { AgentSessionRecordStore } from '../../agent-session-record-store'
|
||||
import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
@@ -42,6 +44,8 @@ type Launch = {
|
||||
target: { kind: string; worktree?: string; create?: Readonly<Record<string, unknown>> }
|
||||
sessionOptions?: Readonly<Record<string, unknown>>
|
||||
reuseTerminal?: { handle: string }
|
||||
cwd?: string
|
||||
surface?: string
|
||||
}
|
||||
|
||||
/** The create throws; `afterDispatch` says whether the spawn request had already left. */
|
||||
@@ -188,6 +192,49 @@ describe('a launch whose terminal fails', () => {
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.createTerminal).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it.each(['cwd', 'wsl', 'remote'] as const)(
|
||||
'refuses conclusive %s before effects and replays it',
|
||||
async (reason) => {
|
||||
const runtime = runtimeStub({
|
||||
settings: {},
|
||||
...(reason === 'cwd' ? {} : { createSupport: { supported: false, reason } })
|
||||
})
|
||||
const subfolderChat = {
|
||||
...EXISTING_LAUNCH,
|
||||
...(reason === 'cwd' ? { cwd: 'packages/app' } : {}),
|
||||
surface: 'chat'
|
||||
}
|
||||
|
||||
const response = await replay(runtime, subfolderChat)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: AGENT_LAUNCH_SURFACE_UNAVAILABLE_CODE,
|
||||
message: expect.stringContaining("Claude can't open as a chat here")
|
||||
}
|
||||
})
|
||||
expect(outcomeOf(OPERATION_ID)).toMatchObject({
|
||||
status: 'failed',
|
||||
code: AGENT_LAUNCH_SURFACE_UNAVAILABLE_CODE
|
||||
})
|
||||
expect(await replay(runtime, subfolderChat)).toMatchObject(response)
|
||||
expect(runtime.createManagedWorktree).not.toHaveBeenCalled()
|
||||
expect(runtime.createTerminal).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('starts the CLI for an explicit terminal even when chats are the default', async () => {
|
||||
const runtime = runtimeStub({ settings: { experimentalNativeChat: true } })
|
||||
|
||||
const response = await replay(runtime, { ...EXISTING_LAUNCH, surface: 'terminal' })
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { outcome: { kind: 'terminal' }, receipt: { reason: 'requested' } }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ ...CREATE_LAUNCH, agent: 'opencode', sessionOptions: { model: 'private-proof/model-b' } },
|
||||
{
|
||||
|
||||
@@ -147,6 +147,8 @@ export async function publishAgentLaunchTabEarly(
|
||||
if (
|
||||
params.target.kind !== 'existing' ||
|
||||
params.reuseTerminal ||
|
||||
// An explicit chat's tab is the session's; a refusal is answered after admission, not here.
|
||||
params.surface === 'chat' ||
|
||||
!readsEarlyLaunchTab(context) ||
|
||||
!runtime.canPublishAgentLaunchTab() ||
|
||||
// A running pane is attached to, never launched into; this launch is refused and must not
|
||||
@@ -170,7 +172,8 @@ export async function publishAgentLaunchTabEarly(
|
||||
workspacePath: workspace.path,
|
||||
...(params.cwd ? { cwd: params.cwd } : {})
|
||||
},
|
||||
settings
|
||||
settings,
|
||||
...(params.surface ? { surface: params.surface } : {})
|
||||
})
|
||||
// A chat's tab is the session's; it appears when the session is created.
|
||||
if (preflight.mode !== 'terminal') {
|
||||
|
||||
@@ -125,6 +125,7 @@ export function agentLaunchWorkspaceFactory(
|
||||
) ?? result.warning
|
||||
return {
|
||||
worktreeId: result.worktree.id,
|
||||
workspacePath: result.worktree.path,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
startupTerminalHandle: result.startupTerminal?.handle,
|
||||
...(promptRodeLaunchCommand ? { promptRodeLaunchCommand } : {}),
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
* resolves it once — settings default plus per-launch feasibility from
|
||||
* `structured-native-chat-launch-route` — so no surface carries its own copy of that decision.
|
||||
*
|
||||
* The intent deliberately does NOT name a mode. A caller states what it wants to happen, not how
|
||||
* to deliver it; picking structured vs terminal is the host's job and is reported back in the
|
||||
* receipt rather than requested here.
|
||||
* A caller may name the surface it wants (`surface`); otherwise picking structured vs terminal is
|
||||
* the host's job. Either way the host decides feasibility and reports the result in the receipt.
|
||||
*/
|
||||
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
import type { AgentLaunchSurface } from './agent-launch-surface'
|
||||
|
||||
/** How a launch's initial text reaches the agent. */
|
||||
export type AgentLaunchPromptDelivery =
|
||||
@@ -102,6 +102,8 @@ export type AgentLaunchIntent = {
|
||||
accountEnv?: Readonly<Record<string, string>>
|
||||
/** `agent.launch`'s `nativeChatDefault`: the caller's chat default in place of the host's own. */
|
||||
nativeChatDefault?: boolean
|
||||
/** `agent.launch`'s `surface`: the user's pick for this launch, ahead of either default. */
|
||||
surface?: AgentLaunchSurface
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,10 +217,11 @@ export type AgentLaunchResult = {
|
||||
|
||||
export type AgentLaunchMode = 'structured' | 'terminal'
|
||||
|
||||
/** Why a launch ran in the mode it did. `user_default` is the preference being honoured; every
|
||||
* other member is a reason the preference could not be applied to this launch. */
|
||||
/** Why a launch ran in the mode it did. `user_default` is the preference being honoured and
|
||||
* `requested` the caller's explicit pick; every other member is a reason a chat could not run. */
|
||||
export type AgentLaunchModeReason =
|
||||
| 'user_default'
|
||||
| 'requested'
|
||||
| 'remote_execution_host'
|
||||
| 'reused_terminal'
|
||||
| 'agent_without_structured_session'
|
||||
@@ -236,7 +239,7 @@ export type AgentLaunchModeReason =
|
||||
export type AgentLaunchModeReceipt = {
|
||||
/** The mode the launch actually ran in. */
|
||||
mode: AgentLaunchMode
|
||||
/** The user's settings default for a new agent tab. */
|
||||
/** The caller's explicit pick, else the user's settings default for a new agent tab. */
|
||||
preferred: AgentLaunchMode
|
||||
reason: AgentLaunchModeReason
|
||||
/** One sentence, always present, so a fallback is never silent. */
|
||||
|
||||
@@ -64,6 +64,12 @@ describe('fields the launch fingerprint covers', () => {
|
||||
computeAgentLaunchFingerprint(BASE)
|
||||
)
|
||||
})
|
||||
|
||||
it('separates a retry that picked another surface, or none, from the first launch', () => {
|
||||
const terminal = computeAgentLaunchFingerprint({ ...BASE, surface: 'terminal' })
|
||||
expect(terminal).not.toBe(computeAgentLaunchFingerprint({ ...BASE, surface: 'chat' }))
|
||||
expect(terminal).not.toBe(computeAgentLaunchFingerprint(BASE))
|
||||
})
|
||||
})
|
||||
|
||||
describe('fields the launch fingerprint deliberately ignores', () => {
|
||||
|
||||
@@ -48,6 +48,8 @@ export type AgentLaunchFingerprintInput = {
|
||||
/** In: a retry that minted another session is a different request, since replaying would answer
|
||||
* with a conversation this caller did not mint. */
|
||||
sessionId?: string
|
||||
/** In: unlike the user's default surface, an explicit pick is what the call asks to run. */
|
||||
surface?: string
|
||||
/**
|
||||
* `launchSource` is deliberately absent, and this is the reasoned exclusion rather than an
|
||||
* oversight: it is telemetry, so two launches differing only in which button produced them do the
|
||||
@@ -75,7 +77,8 @@ export function computeAgentLaunchFingerprint(input: AgentLaunchFingerprintInput
|
||||
cwd: input.cwd,
|
||||
// Absent keys are dropped by the canonical form, so every digest without one is unchanged.
|
||||
paneKey: input.paneKey,
|
||||
sessionId: input.sessionId
|
||||
sessionId: input.sessionId,
|
||||
surface: input.surface
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ export const AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY =
|
||||
export const AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY =
|
||||
'settings.agent-launch.native-chat-permissions.v1' as const
|
||||
|
||||
// Host-advertised: reads `surface`, the user's chat or terminal pick for one launch, and refuses an
|
||||
// explicit chat it cannot open before creating anything. Older hosts strip the field.
|
||||
export const AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY = 'agent.launch.surface.v1' as const
|
||||
|
||||
export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [
|
||||
AGENT_LAUNCH_SETTINGS_CAPABILITY,
|
||||
AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY,
|
||||
@@ -80,5 +84,6 @@ export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [
|
||||
AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_LOCAL_PRESENTATION_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY
|
||||
AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Which surface a caller asked one launch to open: the agent's chat, or its CLI in a terminal. */
|
||||
export const AGENT_LAUNCH_SURFACES = ['chat', 'terminal'] as const
|
||||
export type AgentLaunchSurface = (typeof AGENT_LAUNCH_SURFACES)[number]
|
||||
|
||||
/** An explicit chat the host proved it cannot open, refused before anything was created. */
|
||||
export const AGENT_LAUNCH_SURFACE_UNAVAILABLE_CODE = 'agent_launch_surface_unavailable'
|
||||
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* The wire shape of `agent.launch`, mirroring `AgentLaunchIntent`.
|
||||
*
|
||||
* A caller states WHERE the agent lands and WHAT it should say; it never names a mode. There is
|
||||
* deliberately no `structured` / `terminal` field and no startup-agent field on the create
|
||||
* payload — the host decides, and `withoutReservedAgentCreateFields` strips a stale one out of a
|
||||
* payload a caller migrated over from `worktree.create`.
|
||||
* A caller states WHERE the agent lands and WHAT it should say, and may name the surface the user
|
||||
* picked (`surface`); the host honours that pick or refuses it, and otherwise decides from its
|
||||
* settings. There is no startup-agent field on the create payload —
|
||||
* `withoutReservedAgentCreateFields` strips a stale one out of a payload a caller migrated over
|
||||
* from `worktree.create`.
|
||||
*
|
||||
* Shared rather than main-side because every field resolves to a shared schema: a remote client
|
||||
* that sends this method needs `RpcSendParams<'agent.launch'>` to exist, and a method missing from
|
||||
* the catalog can only be sent through the raw request port.
|
||||
*/
|
||||
|
||||
import { AGENT_LAUNCH_SURFACES } from '../agent-launch-surface'
|
||||
import { z } from 'zod'
|
||||
import { parseAgentSessionOperationTimestamp } from '../agent-session-host-authority'
|
||||
import { parsePaneKey } from '../stable-pane-id'
|
||||
@@ -153,9 +155,16 @@ export const AgentLaunchFields = z.object({
|
||||
* The launching user's "new agent tabs open as chats" default, sent by a client that owns the
|
||||
* user's settings when the host's own setting is not theirs (a desktop relaying its phone to a
|
||||
* server). Replaces only the host setting; feasibility still decides. Older hosts strip it and
|
||||
* read their own setting. Outside the replay fingerprint.
|
||||
* read their own setting. Outside the replay fingerprint. An explicit `surface` replaces it.
|
||||
*/
|
||||
nativeChatDefault: z.boolean().optional()
|
||||
nativeChatDefault: z.boolean().optional(),
|
||||
/**
|
||||
* The surface the user picked for this one launch. `terminal` always runs the CLI; `chat` that
|
||||
* this host can prove it cannot open is refused before anything is created. Strict: an unknown
|
||||
* word changes what runs, so it is refused rather than ignored. Inside the replay fingerprint.
|
||||
* Read by hosts advertising `agent.launch.surface.v1`; older hosts strip it, so clients gate it.
|
||||
*/
|
||||
surface: z.enum(AGENT_LAUNCH_SURFACES).optional()
|
||||
})
|
||||
|
||||
/** A caller-minted session id must be shaped like every id the host mints, so an id still names
|
||||
|
||||
Reference in New Issue
Block a user