diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 5b1777c90e3..965a254dd29 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -68,7 +68,9 @@ const DOWNGRADE_DETAIL: Record = { structured_support_unknown: 'the execution host has not established structured session support', wsl_execution_runtime: 'this workspace runs under WSL', codex_on_windows: 'Codex has no structured session on Windows', - structured_unsupported_on_host: 'the execution host cannot create one here' + structured_unsupported_on_host: 'the execution host cannot create one here', + chat_runtime_not_installed: + "its chat runtime isn't installed on this host (install it from a new chat tab in Orca)" } const BLOCKER_REASON: Record = { @@ -82,10 +84,14 @@ const BLOCKER_REASON: Record = { +type HostSupportReason = 'agent' | 'remote' | 'wsl' | 'chat-program' +type HostLaunchSupport = { supported: boolean; reason?: HostSupportReason } + +const HOST_SUPPORT_REASON: Record = { agent: 'structured_unsupported_on_host', remote: 'remote_execution_host', - wsl: 'wsl_execution_runtime' + wsl: 'wsl_execution_runtime', + 'chat-program': 'chat_runtime_not_installed' } /** Apply shared feasibility policy to facts available before resolving the workspace. */ @@ -195,12 +201,15 @@ async function readStructuredCreateSupport( runtime: Pick, worktreeId: string, agent: TuiAgent | undefined -): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } | null> { +): Promise { if (!agent || !REGISTERED_STRUCTURED_AGENTS.includes(agent)) { return { supported: false, reason: 'agent' } } try { - return await runtime.getStructuredAgentSessionCreateSupport(`id:${worktreeId}`, agent) + // These launches have no chat tab to offer the install, so they keep the agent's own CLI. + return await runtime.getStructuredAgentSessionCreateSupport(`id:${worktreeId}`, agent, { + requireInstalledChatProgram: true + }) } catch { return null } @@ -208,7 +217,7 @@ async function readStructuredCreateSupport( export function downgradeAgentLaunchModeForHost( receipt: AgentLaunchModeReceipt, - support: { supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } | null, + support: HostLaunchSupport | null, vocabulary: AgentLaunchModeVocabulary = DEFAULT_LAUNCH_VOCABULARY, context?: AgentLaunchModeSettlement ): AgentLaunchModeReceipt { diff --git a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts index b64864579d7..8393c729cf6 100644 --- a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts @@ -109,7 +109,9 @@ describe('agent.launch with the real floating workspace resolver', () => { expect(scope).toHaveBeenCalledExactlyOnceWith(selector) if (structuredPreference) { - expect(createSupport).toHaveBeenCalledWith(`id:${FLOATING_TERMINAL_WORKTREE_ID}`, 'codex') + expect(createSupport).toHaveBeenCalledWith(`id:${FLOATING_TERMINAL_WORKTREE_ID}`, 'codex', { + requireInstalledChatProgram: true + }) expect(createStructuredSession).toHaveBeenCalledWith( expect.objectContaining({ worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}` }) ) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts index f585a403541..c36aad9b6f5 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts @@ -53,6 +53,29 @@ describe('a worker for a registered native-chat agent', () => { }) }) + it('starts a terminal worker when the chat program Orca installs is missing here', async () => { + const mode = decideWorkerStartMode({ + params: { agent: 'antigravity' }, + settings: STRUCTURED_PREFERENCE + }) + // A desktop chat tab can offer the install; a worker cannot, so only it asks for an installed one. + const host = { + getStructuredAgentSessionCreateSupport: async ( + _worktree: string, + _agent: string, + options?: { requireInstalledChatProgram?: boolean } + ) => + options?.requireInstalledChatProgram + ? { supported: false, reason: 'chat-program' as const } + : { supported: true } + } + expect(await resolveWorkerStartModeOnHost(host, mode, 'wt-1', 'antigravity')).toMatchObject({ + mode: 'terminal', + preferred: 'structured', + reason: 'chat_runtime_not_installed' + }) + }) + it('starts a terminal worker for an agent whose chat provider the user turned off', () => { expect( decideWorkerStartMode({ diff --git a/src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts index 7bcd483e8c0..2358ad00688 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts @@ -24,7 +24,13 @@ describe('host support evidence', () => { reason: 'structured_support_unknown' }) expect(unknown.detail).toContain('has not established') - expect(getStructuredAgentSessionCreateSupport).toHaveBeenCalledWith('id:workspace-1', 'claude') + expect(getStructuredAgentSessionCreateSupport).toHaveBeenCalledWith( + 'id:workspace-1', + 'claude', + { + requireInstalledChatProgram: true + } + ) getStructuredAgentSessionCreateSupport.mockResolvedValue({ supported: false }) const refusal = await resolveWorkerStartModeOnHost(runtime, mode, 'workspace-1', 'claude') expect(refusal.reason).toBe('structured_unsupported_on_host') diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts index cc1eb62d5bc..e1bd7885b05 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts @@ -168,7 +168,8 @@ describe('a fresh worker through the launch executor', () => { expect(f.runtime.getStructuredAgentSessionCreateSupport).toHaveBeenCalledTimes(1) expect(f.runtime.getStructuredAgentSessionCreateSupport).toHaveBeenCalledWith( 'id:wt_new', - 'claude' + 'claude', + { requireInstalledChatProgram: true } ) expect(f.stages).toEqual(['worktree_creating', 'worktree_created', 'terminal_creating']) expect(f.failedStages).toEqual(['worktree_create', 'terminal_create', 'terminal_create']) diff --git a/src/main/runtime/session-tabs/structured-session-create-support.ts b/src/main/runtime/session-tabs/structured-session-create-support.ts index 74cddb313cc..719df1deee8 100644 --- a/src/main/runtime/session-tabs/structured-session-create-support.ts +++ b/src/main/runtime/session-tabs/structured-session-create-support.ts @@ -21,6 +21,7 @@ import { resolveHostStructuredAgentLaunchEnvironment } from '../structured-agent-launch-support' import { structuredAgentRuntimeMetadata } from '../structured-agent-runtime-metadata' +import { structuredAgentChatProgramNeedsInstall } from '../structured-agent-availability' import { structuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from '../structured-agent-session-runtime' import { ensureStructuredAgentSessionHostUnlessRefused } from '../structured-agent-session-host-refusal' @@ -43,16 +44,26 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte RestoreStructuredAgentSessionTabsOnceMethods['restoreStructuredAgentSessionTabsOnce'] > + /** `requireInstalledChatProgram` is for launches with no chat tab to offer Orca's install. */ async getStructuredAgentSessionCreateSupport( worktreeSelector: string, - agent: StructuredAgentId - ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { - return resolveHostStructuredAgentCreateSupport({ + agent: StructuredAgentId, + options?: { requireInstalledChatProgram?: boolean } + ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' | 'chat-program' }> { + const support = await resolveHostStructuredAgentCreateSupport({ agent, worktreeSelector, location: await this.resolveStructuredAgentSessionLocation(worktreeSelector), runtime: this }) + if ( + support.supported && + options?.requireInstalledChatProgram && + (await structuredAgentChatProgramNeedsInstall(agent, this)) + ) { + return { supported: false, reason: 'chat-program' } + } + return support } /** Use this runtime's registered definition to pin account home; reads/create must not install the host just to inspect it. */ diff --git a/src/main/runtime/structured-agent-availability.ts b/src/main/runtime/structured-agent-availability.ts index 3bd4604b32d..043c7948259 100644 --- a/src/main/runtime/structured-agent-availability.ts +++ b/src/main/runtime/structured-agent-availability.ts @@ -12,7 +12,10 @@ import { observeStructuredAgentLaunch, resolveHostStructuredAgentLaunchEnvironment } from './structured-agent-launch-support' -import { STRUCTURED_AGENT_RUNTIME_METADATA } from './structured-agent-runtime-metadata' +import { + STRUCTURED_AGENT_RUNTIME_METADATA, + structuredAgentRuntimeMetadata +} from './structured-agent-runtime-metadata' import type { StructuredAgentRuntimeMetadata } from './structured-agent-runtime-registration-types' import { recaptureHostShellEnvironment } from './structured-agent-shell-environment' import type { RuntimeStore } from './runtime-store-contract' @@ -89,6 +92,18 @@ async function observeChatProgram( return chatProgram.installable?.() ? 'installable' : 'missing' } +/** True when a chat start would fail until Orca installs the agent's chat program on this host. */ +export async function structuredAgentChatProgramNeedsInstall( + agent: string, + runtime: HealthRuntime +): Promise { + const metadata = structuredAgentRuntimeMetadata(agent) + return Boolean( + metadata?.chatProgram?.installable && + (await observeChatProgram(metadata, runtime)) === 'installable' + ) +} + /** Refresh shell facts on install, settings or agent Refresh; retain health without readers for immediate later subscription. */ export function installStructuredAgentAvailability( store: Pick | null, diff --git a/src/main/runtime/structured-agent-session-support-probe.test.ts b/src/main/runtime/structured-agent-session-support-probe.test.ts index f6a3f4da2dc..af2b1120efa 100644 --- a/src/main/runtime/structured-agent-session-support-probe.test.ts +++ b/src/main/runtime/structured-agent-session-support-probe.test.ts @@ -53,7 +53,7 @@ type TestLocation = { type SupportResult = { supported: boolean - reason?: 'agent' | 'remote' | 'wsl' + reason?: 'agent' | 'remote' | 'wsl' | 'chat-program' } function createRuntime(location: TestLocation): OrcaRuntimeService { diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index ac22745c67c..8f1018202e5 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -137,6 +137,7 @@ export type AgentLaunchModeReason = | 'wsl_execution_runtime' | 'codex_on_windows' | 'structured_unsupported_on_host' + | 'chat_runtime_not_installed' /** Restates `WorkerStartModeReceipt` in surface-neutral terms so orchestration's receipt and a * mobile or renderer launch report the same vocabulary. */