mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 00:02:37 +00:00
fix(agent-launch): preserve setup and refusal fallbacks
This commit is contained in:
@@ -9,7 +9,11 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { executeAgentLaunch, type AgentLaunchExecution } from './agent-launch-executor'
|
||||
import {
|
||||
AgentLaunchStructuredSessionRefusedError,
|
||||
executeAgentLaunch,
|
||||
type AgentLaunchExecution
|
||||
} from './agent-launch-executor'
|
||||
import type { AgentLaunchIntent } from '../../shared/agent-launch-intent'
|
||||
|
||||
const STRUCTURED_PREFERENCE = {
|
||||
@@ -22,6 +26,7 @@ function harness(options: {
|
||||
settings?: Record<string, unknown> | null
|
||||
createSupport?: { supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }
|
||||
createSupportThrows?: boolean
|
||||
structuredCreateError?: Error
|
||||
}) {
|
||||
const calls: string[] = []
|
||||
const createWorktree = vi.fn(
|
||||
@@ -42,6 +47,9 @@ function harness(options: {
|
||||
})
|
||||
const createStructuredSession = vi.fn(async () => {
|
||||
calls.push('createStructuredSession')
|
||||
if (options.structuredCreateError) {
|
||||
throw options.structuredCreateError
|
||||
}
|
||||
return { sessionId: 'sess-1', handle: 'handle_structured' }
|
||||
})
|
||||
const createTerminalAgent = vi.fn(async () => {
|
||||
@@ -124,6 +132,44 @@ describe('a structured launch that creates its own worktree', () => {
|
||||
expect(result.receipt).toMatchObject({ reason: 'structured_support_unknown' })
|
||||
})
|
||||
|
||||
it('falls back only for a definitive structured refusal after the worktree exists', async () => {
|
||||
const h = harness({
|
||||
structuredCreateError: new AgentLaunchStructuredSessionRefusedError(
|
||||
'structured_agent_session_unsupported',
|
||||
'unsupported'
|
||||
)
|
||||
})
|
||||
const result = await h.run(CREATE_INTENT)
|
||||
|
||||
expect(h.calls).toEqual([
|
||||
'createWorktree(startupAgent=undefined)',
|
||||
'createSupport',
|
||||
'createStructuredSession',
|
||||
'createTerminalAgent'
|
||||
])
|
||||
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' })
|
||||
expect(result.receipt).toMatchObject({
|
||||
mode: 'terminal',
|
||||
reason: 'structured_unsupported_on_host'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not create a duplicate terminal when structured creation is unknown', async () => {
|
||||
const h = harness({
|
||||
structuredCreateError: new AgentLaunchStructuredSessionRefusedError(
|
||||
'agent_session_operation_unknown',
|
||||
'unknown'
|
||||
)
|
||||
})
|
||||
|
||||
await expect(h.run(CREATE_INTENT)).rejects.toThrow('unknown')
|
||||
expect(h.calls).toEqual([
|
||||
'createWorktree(startupAgent=undefined)',
|
||||
'createSupport',
|
||||
'createStructuredSession'
|
||||
])
|
||||
})
|
||||
|
||||
it('strips a stale startupAgent out of a migrated create payload', async () => {
|
||||
const h = harness({})
|
||||
await h.run({
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
import { withoutReservedAgentCreateFields } from '../../shared/agent-launch-intent'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { isDefinitiveAgentSessionCreateRefusal } from '../../shared/agent-session-definitive-refusal'
|
||||
import {
|
||||
decideAgentLaunchMode,
|
||||
readAgentLaunchModeSettings,
|
||||
@@ -59,6 +60,17 @@ export type AgentLaunchSurfaceFactory = {
|
||||
}): Promise<{ handle: string; warning?: string }>
|
||||
}
|
||||
|
||||
/** A structured create refusal that proves no session was committed, so the launch may downgrade. */
|
||||
export class AgentLaunchStructuredSessionRefusedError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'AgentLaunchStructuredSessionRefusedError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
/** Creating the workspace, when the intent asks for one. Injected so orchestration keeps recording
|
||||
* its own worktree stages and residual-resource effects around the same call. */
|
||||
export type AgentLaunchWorkspaceFactory = {
|
||||
@@ -119,7 +131,7 @@ export async function executeAgentLaunch(
|
||||
}
|
||||
|
||||
execution.onStage?.('mode_settle')
|
||||
const settled = await resolveAgentLaunchModeOnHost(
|
||||
let settled = await resolveAgentLaunchModeOnHost(
|
||||
runtime,
|
||||
preflight,
|
||||
placed.worktreeId,
|
||||
@@ -128,7 +140,33 @@ export async function executeAgentLaunch(
|
||||
)
|
||||
|
||||
execution.onStage?.('surface_create')
|
||||
const outcome = await createSurface(execution, placed.worktreeId, settled)
|
||||
let outcome: AgentLaunchResult['outcome']
|
||||
try {
|
||||
outcome = await createSurface(execution, placed.worktreeId, settled)
|
||||
} catch (error) {
|
||||
// The structured create path distinguishes a definitive pre-commit refusal from an unknown
|
||||
// outcome. Only the former is safe to replace with a terminal in the same workspace; retrying
|
||||
// after an unknown attach outcome could create two agents.
|
||||
if (
|
||||
settled.mode !== 'structured' ||
|
||||
!(error instanceof AgentLaunchStructuredSessionRefusedError) ||
|
||||
!isDefinitiveAgentSessionCreateRefusal(error.code)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
settled = downgradeAgentLaunchModeForStructuredRefusal(settled, vocabulary)
|
||||
outcome = await execution.surfaces
|
||||
.createTerminalAgent({
|
||||
worktreeId: placed.worktreeId,
|
||||
agent: intent.agent,
|
||||
...(intent.sessionOptions ? { options: intent.sessionOptions } : {})
|
||||
})
|
||||
.then((terminal) => ({
|
||||
kind: 'terminal' as const,
|
||||
handle: terminal.handle,
|
||||
...(terminal.warning ? { warning: terminal.warning } : {})
|
||||
}))
|
||||
}
|
||||
return {
|
||||
outcome,
|
||||
worktreeId: placed.worktreeId,
|
||||
@@ -137,6 +175,18 @@ export async function executeAgentLaunch(
|
||||
}
|
||||
}
|
||||
|
||||
function downgradeAgentLaunchModeForStructuredRefusal(
|
||||
receipt: AgentLaunchModeReceipt,
|
||||
vocabulary: AgentLaunchModeVocabulary
|
||||
): AgentLaunchModeReceipt {
|
||||
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.`
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveWorkspace(
|
||||
execution: AgentLaunchExecution,
|
||||
preflight: AgentLaunchModeReceipt
|
||||
|
||||
@@ -12,7 +12,10 @@ import { randomUUID } from 'node:crypto'
|
||||
import { narrowStructuredLaunchSeedOptions } from '../../../../shared/native-chat-session-option-defaults'
|
||||
import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation'
|
||||
import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection'
|
||||
import type { AgentLaunchSurfaceFactory } from '../../../agent-launch/agent-launch-executor'
|
||||
import {
|
||||
AgentLaunchStructuredSessionRefusedError,
|
||||
type AgentLaunchSurfaceFactory
|
||||
} from '../../../agent-launch/agent-launch-executor'
|
||||
import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import type { RpcContext } from '../core'
|
||||
@@ -47,7 +50,10 @@ export function agentLaunchSurfaceFactory(context: RpcContext): AgentLaunchSurfa
|
||||
activate: true
|
||||
})
|
||||
if (!created.ok) {
|
||||
throw new Error(created.refusal.message)
|
||||
throw new AgentLaunchStructuredSessionRefusedError(
|
||||
created.refusal.code,
|
||||
created.refusal.message
|
||||
)
|
||||
}
|
||||
return {
|
||||
sessionId: created.value.sessionId,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import {
|
||||
finishAutomationWorkspaceProvenanceRequest,
|
||||
releaseAutomationWorkspaceProvenanceRequest,
|
||||
@@ -27,6 +28,8 @@ type WorktreeCreateParams = Extract<
|
||||
{ kind: 'create-worktree' }
|
||||
>['create']
|
||||
|
||||
const STRUCTURED_SETUP_WAIT_TIMEOUT_MS = 60_000
|
||||
|
||||
export function agentLaunchWorkspaceFactory(
|
||||
context: RpcContext,
|
||||
agent: TuiAgent
|
||||
@@ -61,8 +64,16 @@ export function agentLaunchWorkspaceFactory(
|
||||
),
|
||||
// The launch owns the agent whichever surface it settles on, so the workspace records
|
||||
// it even when no startup terminal was created for it.
|
||||
createdWithAgent: agent
|
||||
createdWithAgent: agent,
|
||||
// Structured sessions have no startup command to sequence behind setup. Provision the
|
||||
// setup terminal synchronously and attach a completion token so the launch can wait
|
||||
// before creating the chat surface.
|
||||
awaitTerminalProvisioning: true,
|
||||
observeSetupCompletion: true
|
||||
})
|
||||
if (!startupAgent) {
|
||||
await waitForStructuredSetup(runtime, result.setupReceipt)
|
||||
}
|
||||
finishAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest)
|
||||
return {
|
||||
worktreeId: result.worktree.id,
|
||||
@@ -76,3 +87,30 @@ export function agentLaunchWorkspaceFactory(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForStructuredSetup(
|
||||
runtime: Pick<OrcaRuntimeService, 'waitForSetupTerminalCompletion'>,
|
||||
receipt: Awaited<ReturnType<OrcaRuntimeService['createManagedWorktree']>>['setupReceipt']
|
||||
): Promise<void> {
|
||||
if (
|
||||
!receipt ||
|
||||
receipt.startupPolicy !== 'wait-for-setup' ||
|
||||
receipt.state !== 'running' ||
|
||||
!receipt.terminalHandle
|
||||
) {
|
||||
return
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
await Promise.race([
|
||||
runtime.waitForSetupTerminalCompletion(receipt.terminalHandle),
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, STRUCTURED_SETUP_WAIT_TIMEOUT_MS)
|
||||
})
|
||||
])
|
||||
} catch {
|
||||
// Setup completion is evidence, not a reason to strand a launch when the PTY disappears.
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,14 @@ function runtimeStub(
|
||||
options: {
|
||||
settings?: Record<string, unknown>
|
||||
createSupport?: { supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }
|
||||
setupReceipt?: {
|
||||
startupPolicy: 'start-immediately' | 'wait-for-setup'
|
||||
state: 'running' | 'skipped' | 'not_configured' | 'spawn_failed'
|
||||
terminalHandle?: string
|
||||
}
|
||||
} = {}
|
||||
) {
|
||||
const waitForSetupTerminalCompletion = vi.fn(async () => ({ exitCode: 0 }))
|
||||
return {
|
||||
getClientSettings: vi.fn(() => options.settings ?? STRUCTURED_PREFERENCE),
|
||||
getStructuredAgentSessionCreateSupport: vi.fn(
|
||||
@@ -48,13 +54,17 @@ function runtimeStub(
|
||||
showRepo: vi.fn(async () => ({ id: 'repo-1' })),
|
||||
createManagedWorktree: vi.fn(async (args: Record<string, unknown>) => ({
|
||||
worktree: { id: 'wt-new' },
|
||||
startupTerminal: args.startupAgent ? { handle: 'term_agent_first' } : undefined
|
||||
startupTerminal: args.startupAgent ? { handle: 'term_agent_first' } : undefined,
|
||||
...(options.setupReceipt ? { setupReceipt: options.setupReceipt } : {})
|
||||
})),
|
||||
createTerminal: vi.fn(async () => ({ handle: 'term_1' })),
|
||||
showTerminal: vi.fn(async (handle: string) => ({ handle, worktreeId: 'wt-7' })),
|
||||
isTerminalRunningAgent: vi.fn(async () => true),
|
||||
showManagedTerminalWorkspace: vi.fn(async (selector: string) => ({
|
||||
id: selector.replace(/^id:/, '')
|
||||
})),
|
||||
ensureStructuredAgentSessionHost: vi.fn(async () => {})
|
||||
ensureStructuredAgentSessionHost: vi.fn(async () => {}),
|
||||
waitForSetupTerminalCompletion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +189,48 @@ describe('what agent.launch accepts', () => {
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('validates a reused terminal against the addressed workspace', async () => {
|
||||
const runtime = runtimeStub()
|
||||
const result = await launch(
|
||||
{
|
||||
agent: 'claude',
|
||||
target: { kind: 'existing', worktree: 'id:wt-7' },
|
||||
reuseTerminal: { handle: 'term_live' }
|
||||
},
|
||||
runtime
|
||||
)
|
||||
|
||||
expect(runtime.showTerminal).toHaveBeenCalledWith('term_live')
|
||||
expect(runtime.isTerminalRunningAgent).toHaveBeenCalledWith('term_live')
|
||||
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_live' })
|
||||
})
|
||||
|
||||
it('rejects a reused terminal from a different workspace before launching', async () => {
|
||||
const runtime = runtimeStub()
|
||||
runtime.showTerminal.mockResolvedValue({ handle: 'term_live', worktreeId: 'wt-other' })
|
||||
|
||||
await expect(
|
||||
launch(
|
||||
{
|
||||
agent: 'claude',
|
||||
target: { kind: 'existing', worktree: 'id:wt-7' },
|
||||
reuseTerminal: { handle: 'term_live' }
|
||||
},
|
||||
runtime
|
||||
)
|
||||
).rejects.toThrow('agent_launch_terminal_worktree_mismatch')
|
||||
expect(runtime.createManagedWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects reusing a terminal while creating a new workspace', async () => {
|
||||
const runtime = runtimeStub()
|
||||
await expect(
|
||||
launch({ ...CREATE_LAUNCH, reuseTerminal: { handle: 'term_live' } }, runtime)
|
||||
).rejects.toThrow('agent_launch_reuse_requires_existing_workspace')
|
||||
expect(runtime.showTerminal).not.toHaveBeenCalled()
|
||||
expect(runtime.createManagedWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the worktree factory', () => {
|
||||
@@ -188,11 +240,37 @@ describe('the worktree factory', () => {
|
||||
|
||||
const args = createArgs(runtime)
|
||||
expect(args.startupAgent).toBeUndefined()
|
||||
expect(args.awaitTerminalProvisioning).toBe(true)
|
||||
expect(args.observeSetupCompletion).toBe(true)
|
||||
// Still recorded on the workspace: the launch owns the agent whichever surface it settles on.
|
||||
expect(args.createdWithAgent).toBe('claude')
|
||||
expect(result.outcome.kind).toBe('structured')
|
||||
})
|
||||
|
||||
it('waits for a setup-gated structured workspace before creating its session', async () => {
|
||||
const runtime = runtimeStub({
|
||||
setupReceipt: {
|
||||
startupPolicy: 'wait-for-setup',
|
||||
state: 'running',
|
||||
terminalHandle: 'setup-1'
|
||||
}
|
||||
})
|
||||
const order: string[] = []
|
||||
runtime.waitForSetupTerminalCompletion.mockImplementation(async () => {
|
||||
order.push('setup-complete')
|
||||
return { exitCode: 0 }
|
||||
})
|
||||
createStructuredSession.mockImplementationOnce(async () => {
|
||||
order.push('structured-create')
|
||||
return { ok: true as const, value: { sessionId: 'sess-1' } }
|
||||
})
|
||||
|
||||
await launch(CREATE_LAUNCH, runtime)
|
||||
|
||||
expect(order).toEqual(['setup-complete', 'structured-create'])
|
||||
expect(runtime.waitForSetupTerminalCompletion).toHaveBeenCalledWith('setup-1')
|
||||
})
|
||||
|
||||
it('keeps agent-first creation for a launch the user wants as a terminal', async () => {
|
||||
const runtime = runtimeStub({ settings: {} })
|
||||
const result = await launch(CREATE_LAUNCH, runtime)
|
||||
|
||||
@@ -67,6 +67,25 @@ async function agentLaunchIntent(
|
||||
}
|
||||
}
|
||||
|
||||
async function validateReusedTerminal(
|
||||
intent: AgentLaunchIntent,
|
||||
runtime: Pick<OrcaRuntimeService, 'showTerminal' | 'isTerminalRunningAgent'>
|
||||
): Promise<void> {
|
||||
if (!intent.reuseTerminal) {
|
||||
return
|
||||
}
|
||||
if (intent.target.kind !== 'existing') {
|
||||
throw new Error('agent_launch_reuse_requires_existing_workspace')
|
||||
}
|
||||
const terminal = await runtime.showTerminal(intent.reuseTerminal.handle)
|
||||
if (terminal.worktreeId !== intent.target.worktree) {
|
||||
throw new Error('agent_launch_terminal_worktree_mismatch')
|
||||
}
|
||||
if (!(await runtime.isTerminalRunningAgent(intent.reuseTerminal.handle))) {
|
||||
throw new Error('agent_launch_terminal_not_running_agent')
|
||||
}
|
||||
}
|
||||
|
||||
export const AGENT_LAUNCH_METHODS = [
|
||||
defineMethod({
|
||||
name: 'agent.launch',
|
||||
@@ -76,6 +95,7 @@ export const AGENT_LAUNCH_METHODS = [
|
||||
throw new Error('agent_launch_unsupported')
|
||||
}
|
||||
const intent = await agentLaunchIntent(params, context.runtime)
|
||||
await validateReusedTerminal(intent, context.runtime)
|
||||
return executeAgentLaunch({
|
||||
runtime: context.runtime,
|
||||
intent,
|
||||
|
||||
Reference in New Issue
Block a user