Files
orca/tests/e2e/agent-launch-cli.unit.test.ts
T
Jinwoo Hong 4fabde28b1 feat(cli): one enclosing-workspace rule, --environment local, copyable addresses, and --parent-environment (#39733)
* feat(cli): one enclosing-workspace rule, --environment local, and copyable workspace addresses

current/active and cwd inference now pick the innermost workspace of either kind (worktree or
folder workspace); at equal depth the caller terminal's own workspace wins, otherwise the choice
is refused as ambiguous. --environment local names this machine's own Orca over an ambient
ORCA_ENVIRONMENT or pairing code. worktree ps/show/current print an environment: line and an
address: line (JSON environment, address, addressUnavailable, parent) that always carries its
--environment, and name a parent's environment only when it is verified.

* test: answer the folder catalog read in agent launch cwd tests

* feat(cli): worktree rm refuses current/active and names the selector to pass

Removal needs an explicit workspace, as git worktree remove needs a path. The refusal runs before
rm decides between a worktree and a folder workspace, and reports what current resolves to now
(one selector, the ambiguous choices, or none) in text and as invalid_argument data.

* fix(cli): keep the remote-environment guidance when rm refuses current

* fix(cli): surface folder-catalog failures for omitted targets; no address for a duplicated folder parent

* fix(cli): surface a worktree-list failure for omitted browser and terminal targets too

* test(cli): folder rm refuses current/active before the folder removal path

* fix(cli): refuse a cwd owner whose folder host can't be derived instead of skipping it

* fix(cli): compare cwd owners by filesystem identity, refuse every equal-path tie, surface worktree-list failures

Paths on the caller's own filesystem compare by realpath (reusing resolveRealPathSync), so /tmp
and /private/tmp aliases find the deeper owner in both the folder prefilter and ranking; other
hosts' paths stay lexical. Equal-depth owners with different repo registrations now refuse without
a caller instead of picking by catalog order. An omitted target no longer swallows worktree.list
method_not_found; only the optional folder reads fall back.

* fix(cli): read the realpath helper from shared so the packaged CLI ships it

* refactor(cli): move the catalog row type and unresolved-owner refusal out of the selection/catalog cycle

* fix(cli): resolve every caller-filesystem workspace path, not only basename matches

A workspace registered through a differently named symlink was skipped by the basename gate.
Containment now realpaths cwd and each row on the caller's own filesystem once per command,
shared by the folder prefilter and ranking; folder hosts are derived first so another host's
paths are never resolved locally.

* test: answer the folder catalog read in the ambiguous orchestration-check caller test

* feat(cli): --parent-environment names where a worktree's parent lives

worktree create and worktree set take --parent-environment <local|saved id|name>
next to --parent-worktree. The parent is resolved in that environment only, and
sent to the child as a verified cross-store ref after the child's cross-machine
capability check. Without the flag, the parent stays in the child's environment;
active/current always mean this machine's own Orca.

Preflight failures happen before any change: environment_not_paired,
environment_unavailable, LINEAGE_PARENT_NOT_FOUND / LINEAGE_PARENT_AMBIGUOUS
naming the searched environment, and incompatible_runtime with data.environment
and data.requires.

* fix(cli): parent lookup errors name and list the environment searched

LINEAGE_PARENT_AMBIGUOUS and LINEAGE_PARENT_NOT_FOUND now always carry
data.environment and a listing command for the environment the parent was
looked up in, for parents in another environment and in the child's own. Steps
inherited from the runtime listed the caller's default route instead.

* docs(cli): a local address names the Orca that printed it
2026-11-08 04:08:07 -05:00

356 lines
13 KiB
TypeScript

import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AGENT_LAUNCH_RUNTIME_CAPABILITIES } from '../../src/shared/agent-launch/contracts/agent-launch-runtime-capability'
import type { StructuredAgentSessionFirstMessage } from '../../src/shared/agent-session/structured-agent-session-create'
import { openTestAgentSessionRecordStore } from '../../src/main/runtime/agent-session/records/agent-session-record-store-test-harness'
import { closeTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/store-lifecycle/journal-host-database-test-support'
import { RuntimeClient, RuntimeClientError } from '../../src/cli/runtime-client'
import { callAgentLaunch, readCliAgentLaunch } from '../../src/cli/agent-launch-request'
import { parseArgs } from '../../src/cli/args'
import { AGENT_LAUNCH_HANDLERS } from '../../src/cli/handlers/agent-launch'
import { createWorktreeWithAgent } from '../../src/cli/handlers/worktree-create-agent-launch'
import {
methodNamed,
rpcContext,
runtimeStub,
setAgentLaunchRecordStore
} from '../../src/main/runtime/rpc/methods/agent-launch/agent-launch.test-fixture'
const chatCreate = vi.hoisted(() => vi.fn())
const terminalPrompt = vi.hoisted(() => vi.fn(async () => true))
vi.mock(
'../../src/main/runtime/rpc/methods/structured-sessions/creation/structured-agent-session-create',
() => ({
createStructuredAgentSessionForWorktree: chatCreate
})
)
vi.mock('../../src/main/runtime/rpc/methods/agent-launch/agent-launch-terminal-prompt', () => ({
deliverTerminalAgentLaunchPrompt: terminalPrompt
}))
const { AGENT_LAUNCH_METHODS } =
await import('../../src/main/runtime/rpc/methods/agent-launch/agent-launch')
let directory: string
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'orca-launch-cli-'))
setAgentLaunchRecordStore(await openTestAgentSessionRecordStore(directory))
chatCreate
.mockReset()
.mockImplementation(async (args: { firstMessage?: StructuredAgentSessionFirstMessage }) => ({
ok: true,
value: {
sessionId: 'codex_test',
...(args.firstMessage
? { firstMessage: { clientMessageId: args.firstMessage.clientMessageId } }
: {})
}
}))
terminalPrompt.mockClear()
})
afterEach(async () => {
setAgentLaunchRecordStore(null)
vi.restoreAllMocks()
await closeTestJournalHostDatabase(directory)
await rm(directory, { recursive: true, force: true })
})
function hostClient(runtime: ReturnType<typeof runtimeStub>, loseReply = false) {
const client = new RuntimeClient(directory, 60_000, null, null)
let lost = false
vi.spyOn(client, 'call').mockImplementation(async (name, params) => {
if (name === 'status.get') {
return {
id: 'status',
ok: true,
result: { capabilities: [...AGENT_LAUNCH_RUNTIME_CAPABILITIES] },
_meta: { runtimeId: 'runtime-1' }
}
}
if (name === 'projectGroup.list') {
return { id: 'groups', ok: true, result: { groups: [] }, _meta: { runtimeId: 'runtime-1' } }
}
if (name === 'repo.list') {
return { id: 'repos', ok: true, result: { repos: [] }, _meta: { runtimeId: 'runtime-1' } }
}
const method = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay')
expect(name).toBe('agent.launchReplay')
const result = await method.handler(method.params.parse(params), rpcContext(runtime, {}))
if (loseReply && !lost) {
lost = true
throw new RuntimeClientError(
'runtime_unavailable',
'The Orca runtime closed the connection before responding. Restart Orca and try again.'
)
}
return { id: 'reply', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
})
return client
}
const existing = { kind: 'existing', worktree: 'id:wt-7' } as const
const create = {
kind: 'create-worktree',
create: { repo: 'id:repo-1', name: 'task', activate: false }
} as const
describe('CLI launch through host surface creation and replay admission', () => {
it.each([existing, create])(
'a lost accepted reply replays without a second chat or prompt: %j',
async (target) => {
const runtime = runtimeStub()
const launch = {
agent: 'codex',
prompt: { text: 'Fix the repo\nThen test it', delivery: 'submit' }
} as const
let operationId: string | undefined
try {
await callAgentLaunch(
hostClient(runtime, true),
launch,
{ target, presentation: 'background' },
AGENT_LAUNCH_RUNTIME_CAPABILITIES
)
throw new Error('Expected lost reply')
} catch (error) {
expect(error).toBeInstanceOf(RuntimeClientError)
if (
!(error instanceof RuntimeClientError) ||
!error.data ||
typeof error.data !== 'object' ||
!('operationId' in error.data) ||
typeof error.data.operationId !== 'string'
) {
throw error
}
operationId = error.data.operationId
expect(error.message).not.toContain('try again')
expect(error.data).toMatchObject({ retrySafe: true, operationId })
}
const result = await callAgentLaunch(
hostClient(runtime),
readCliAgentLaunch(
new Map([
['prompt', launch.prompt.text],
['retry-launch', operationId ?? '']
]),
'codex',
{ createsWorktree: target.kind === 'create-worktree' }
),
{ target, presentation: 'background' },
AGENT_LAUNCH_RUNTIME_CAPABILITIES
)
const firstMessage = chatCreate.mock.calls[0]?.[0].firstMessage
expect(firstMessage).toMatchObject({
clientMessageId: expect.any(String),
body: { blocks: [{ type: 'text', text: launch.prompt.text }] }
})
expect(result.result.prompt).toMatchObject({
outcome: 'journaled',
messageId: firstMessage.clientMessageId
})
expect(chatCreate).toHaveBeenCalledTimes(1)
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(
target.kind === 'create-worktree' ? 1 : 0
)
}
)
it.each([existing, create])(
'rejects empty recovery IDs after a lost terminal reply and preserves valid replay: %j',
async (target) => {
const runtime = runtimeStub({ settings: {} })
const argv = ['--agent', 'codex', '--prompt', 'Fix the repo\nThen test']
const launch = readCliAgentLaunch(parseArgs(argv).flags, 'codex', {
createsWorktree: target.kind === 'create-worktree'
})
const initialClient = hostClient(runtime, true)
let operationId: string | undefined
try {
await callAgentLaunch(
initialClient,
launch,
{ target, presentation: 'background' },
AGENT_LAUNCH_RUNTIME_CAPABILITIES
)
throw new Error('Expected lost reply')
} catch (error) {
if (
!(error instanceof RuntimeClientError) ||
!error.data ||
typeof error.data !== 'object' ||
!('operationId' in error.data) ||
typeof error.data.operationId !== 'string'
) {
throw error
}
operationId = error.data.operationId
}
const retryClient = hostClient(runtime)
for (const retryArgs of [['--retry-launch', ''], ['--retry-launch=']]) {
await expect(async () => {
const retry = readCliAgentLaunch(parseArgs([...argv, ...retryArgs]).flags, 'codex', {
createsWorktree: target.kind === 'create-worktree'
})
await callAgentLaunch(
retryClient,
retry,
{ target, presentation: 'background' },
AGENT_LAUNCH_RUNTIME_CAPABILITIES
)
}).rejects.toMatchObject({ code: 'invalid_argument' })
}
expect(retryClient.call).not.toHaveBeenCalled()
await callAgentLaunch(
retryClient,
readCliAgentLaunch(
parseArgs([...argv, '--retry-launch', operationId ?? '']).flags,
'codex',
{
createsWorktree: target.kind === 'create-worktree'
}
),
{ target, presentation: 'background' },
AGENT_LAUNCH_RUNTIME_CAPABILITIES
)
expect(retryClient.call).toHaveBeenCalledExactlyOnceWith(
'agent.launchReplay',
vi.mocked(initialClient.call).mock.calls[0]?.[1]
)
expect(terminalPrompt).toHaveBeenCalledTimes(1)
expect(runtime.createTerminal).toHaveBeenCalledTimes(target.kind === 'existing' ? 1 : 0)
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(
target.kind === 'create-worktree' ? 1 : 0
)
}
)
it('refuses replay on an older replacement host without sending any launch', async () => {
const client = hostClient(runtimeStub())
const operationId = `${Date.now()}-${'a'.repeat(32)}`
await expect(
callAgentLaunch(client, { agent: 'codex', operationId }, { target: existing }, [
'agent.launch.v2',
'agent.launch.local-presentation.v1'
])
).rejects.toThrow('do not retry without --retry-launch')
expect(client.call).not.toHaveBeenCalled()
})
it.each([existing, create])(
'refuses a v2-only prompted launch before creating anything: %j',
async (target) => {
const runtime = runtimeStub()
await expect(
callAgentLaunch(
hostClient(runtime),
{ agent: 'codex', prompt: { text: 'Long\nMultiline', delivery: 'submit' } },
{ target },
['agent.launch.v2']
)
).rejects.toThrow('cannot safely carry launch prompts')
expect(chatCreate).not.toHaveBeenCalled()
expect(runtime.createTerminal).not.toHaveBeenCalled()
}
)
it('refuses hosts without local presentation before creating anything', async () => {
const client = hostClient(runtimeStub())
await expect(
callAgentLaunch(
client,
{ agent: 'codex' },
{ target: existing, presentation: 'background' },
['agent.launch.v2']
)
).rejects.toThrow('cannot honor CLI launch focus or background')
expect(client.call).not.toHaveBeenCalled()
})
it('gives inspection-only recovery after an ambiguous legacy create and never falls back again', async () => {
const client = new RuntimeClient(directory, 60_000, null, null)
const call = vi
.spyOn(client, 'call')
.mockResolvedValueOnce({
id: 'status',
ok: true,
result: { capabilities: [] },
_meta: { runtimeId: 'runtime-1' }
})
.mockRejectedValueOnce(
new RuntimeClientError('runtime_unavailable', 'Restart Orca and try again.')
)
await expect(
createWorktreeWithAgent({
client,
flags: new Map(),
create: create.create,
launch: { agent: 'codex' },
json: true
})
).rejects.toMatchObject({
code: 'agent_session_operation_unknown',
data: {
retrySafe: false,
nextSteps: expect.arrayContaining([expect.stringContaining('Do not repeat')])
}
})
expect(call).toHaveBeenCalledTimes(2)
})
it.each([undefined, 'current'])(
'uses the innermost enclosing folder workspace for %s',
async (selector) => {
const client = hostClient(runtimeStub())
vi.spyOn(console, 'log').mockImplementation(() => {})
const call = vi.mocked(client.call)
call.mockResolvedValueOnce({
id: 'list',
ok: true,
result: { worktrees: [], totalCount: 0, truncated: false },
_meta: { runtimeId: 'runtime-1' }
})
call.mockResolvedValueOnce({
id: 'folders',
ok: true,
result: {
folderWorkspaces: [
{ id: 'outer', folderPath: '/tmp/repo' },
{ id: 'remote', folderPath: '/tmp/repo/nested/deeper', connectionId: 'ssh-1' },
{ id: 'nested', folderPath: '/tmp/repo/nested' }
]
},
_meta: { runtimeId: 'runtime-1' }
})
const flags = new Map<string, string | boolean>([['agent', 'codex']])
if (selector) {
flags.set('worktree', selector)
}
await AGENT_LAUNCH_HANDLERS['agent launch']({
flags,
client,
cwd: '/tmp/repo/nested/deeper',
json: true
})
expect(call).toHaveBeenCalledWith(
'agent.launchReplay',
expect.objectContaining({ target: { kind: 'existing', worktree: 'folder:nested' } })
)
}
)
it('refuses remote cwd inference for current', async () => {
const client = hostClient(runtimeStub())
vi.spyOn(client, 'isRemote', 'get').mockReturnValue(true)
const flags = new Map<string, string | boolean>([
['agent', 'codex'],
['worktree', 'current']
])
await expect(
AGENT_LAUNCH_HANDLERS['agent launch']({ flags, client, cwd: '/tmp/repo', json: true })
).rejects.toMatchObject({ code: 'invalid_argument' })
expect(client.call).not.toHaveBeenCalled()
})
})