diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts new file mode 100644 index 00000000000..07cc9e744a0 --- /dev/null +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -0,0 +1,248 @@ +/** + * 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. + * + * 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. + * + * The settings default and the per-launch feasibility both come from + * `shared/structured-native-chat-launch-route`. This module supplies placement facts and formats + * the receipt; it does not own a second feasibility policy. + * + * Callers differ only in what they call the thing being started, so the receipt's noun is + * parameterized. Orchestration says "worker" because its receipts are read alongside dispatch + * records; every other surface says "chat session" / "terminal agent". + */ + +import type { GlobalSettings } from '../../shared/global-settings-types' +import { RUNTIME_CAPABILITIES } from '../../shared/protocol-version' +import { + prefersStructuredNativeChatByDefault, + resolveStructuredNativeChatSupport, + type NativeChatDefaultSettings, + type StructuredNativeChatBlocker +} from '../../shared/structured-native-chat-launch-route' +import type { TuiAgent } from '../../shared/tui-agent' +import { hasExplicitTuiLaunchCustomization } from '../../shared/tui-agent-launch-customization' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +export type AgentLaunchMode = 'structured' | 'terminal' + +export type AgentLaunchModeReason = + | 'user_default' + | 'remote_execution_host' + | 'reused_terminal' + | 'agent_without_structured_session' + | 'tui_launch_customization' + | 'structured_sessions_unavailable' + | 'structured_support_unknown' + | 'wsl_execution_runtime' + | 'codex_on_windows' + | 'structured_unsupported_on_host' + +export type AgentLaunchModeReceipt = { + /** The mode the launch actually ran in. */ + mode: AgentLaunchMode + /** The user's settings default for a new agent tab. */ + preferred: AgentLaunchMode + reason: AgentLaunchModeReason + /** One sentence, always present, so a fallback is never silent. */ + detail: string +} + +/** 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 = { + /** e.g. 'a structured chat session worker' */ + structured: string + /** e.g. 'a terminal agent worker' */ + 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, string>> +} + +export const DEFAULT_LAUNCH_VOCABULARY: AgentLaunchModeVocabulary = { + structured: 'a structured chat session', + terminal: 'a terminal agent' +} + +export type AgentLaunchModeSettings = Partial< + NativeChatDefaultSettings & + Pick +> + +/** The placement facts the decision reads. `worktree`, `model` and `effort` are deliberately not + * here: a structured launch honours all three, and a placement flag must never imply a mode. */ +export type AgentLaunchModePlacement = { + agent?: string + /** A connected execution server; absent means local. */ + on?: string + /** An existing terminal being reused. */ + terminal?: string +} + +const DOWNGRADE_DETAIL: Record, 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', + tui_launch_customization: + 'this agent has a custom launch command, arguments or environment that only a terminal applies', + structured_sessions_unavailable: 'this runtime does not support structured agent sessions', + 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' +} + +const BLOCKER_REASON: Record< + StructuredNativeChatBlocker, + Exclude +> = { + 'reused-terminal': 'reused_terminal', + 'agent-without-structured-session': 'agent_without_structured_session', + 'draft-prompt': 'structured_unsupported_on_host', + 'floating-workspace': 'structured_unsupported_on_host', + 'tui-launch-customization': 'tui_launch_customization', + 'remote-execution-host': 'remote_execution_host', + 'project-runtime': 'wsl_execution_runtime', + 'runtime-capability': 'structured_sessions_unavailable', + 'runtime-capability-unknown': 'structured_support_unknown' +} + +/** The host's own create-support verdict (`agentSession.createSupport`) in this vocabulary. */ +const HOST_SUPPORT_REASON: Record< + 'agent' | 'remote' | 'wsl', + Exclude +> = { + agent: 'structured_unsupported_on_host', + remote: 'remote_execution_host', + wsl: 'wsl_execution_runtime' +} + +/** + * First half of the decision: the user's default, plus every feasibility fact knowable before a + * workspace is resolved. + */ +export function decideAgentLaunchMode(args: { + placement: AgentLaunchModePlacement + settings: AgentLaunchModeSettings | null | undefined + vocabulary?: AgentLaunchModeVocabulary +}): AgentLaunchModeReceipt { + const { placement, settings } = args + const vocabulary = args.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY + if (!prefersStructuredNativeChatByDefault(settings)) { + return { + mode: 'terminal', + preferred: 'terminal', + reason: 'user_default', + detail: `Started ${vocabulary.terminal}, the default for new agent tabs in your settings.` + } + } + const agent = placement.agent as TuiAgent + const support = resolveStructuredNativeChatSupport({ + agent, + executionHostId: placement.on ? `runtime:${placement.on}` : 'local', + reusesTerminal: Boolean(placement.terminal), + hostCapabilities: RUNTIME_CAPABILITIES, + // A resolved managed worktree or folder workspace is never a floating terminal. WSL is left to + // the executing host's own create-support probe, which reads the resolved workspace rather + // than guessing from a client-side project runtime. + requiresTuiLaunchCustomization: hasExplicitTuiLaunchCustomization(settings, agent) + }) + if (!support.supported) { + return downgraded(BLOCKER_REASON[support.blocker], vocabulary) + } + return { + 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. + */ +export async function resolveAgentLaunchModeOnHost( + runtime: Pick, + receipt: AgentLaunchModeReceipt, + worktreeId: string | undefined, + agent: TuiAgent | undefined, + vocabulary: AgentLaunchModeVocabulary = DEFAULT_LAUNCH_VOCABULARY +): Promise { + if (receipt.mode !== 'structured' || !worktreeId) { + return receipt + } + return downgradeAgentLaunchModeForHost( + receipt, + await readStructuredCreateSupport(runtime, worktreeId, agent), + vocabulary + ) +} + +/** A host that cannot answer has not proved it can create one, so the launch stays a PTY agent. */ +async function readStructuredCreateSupport( + runtime: Pick, + worktreeId: string, + agent: TuiAgent | undefined +): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } | null> { + if (agent !== 'claude' && agent !== 'codex') { + return { supported: false, reason: 'agent' } + } + try { + return await runtime.getStructuredAgentSessionCreateSupport(`id:${worktreeId}`, agent) + } catch { + return null + } +} + +/** + * Applies the executing host's `agentSession.createSupport` answer, which is the authority on WSL, + * remoteness and the Windows process-start-time gate for the resolved workspace. + */ +export function downgradeAgentLaunchModeForHost( + receipt: AgentLaunchModeReceipt, + support: { supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } | null, + vocabulary: AgentLaunchModeVocabulary = DEFAULT_LAUNCH_VOCABULARY +): AgentLaunchModeReceipt { + if (receipt.mode !== 'structured' || support?.supported) { + return receipt + } + if (support === null) { + return downgraded(BLOCKER_REASON['runtime-capability-unknown'], vocabulary) + } + return downgraded( + support.reason ? HOST_SUPPORT_REASON[support.reason] : 'structured_unsupported_on_host', + vocabulary + ) +} + +function downgraded( + reason: Exclude, + vocabulary: AgentLaunchModeVocabulary +): 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.` + } +} + +/** The store can be missing on a runtime that never opened one; that reads as no preference. */ +export function readAgentLaunchModeSettings( + runtime: Pick +): AgentLaunchModeSettings | null { + try { + return runtime.getClientSettings() + } catch { + return null + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts index 97c64082bf2..9cd9f51e0ac 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts @@ -1,59 +1,41 @@ /** - * Which kind of worker `orchestration.workerStart` starts, decided from the user's own settings. + * `orchestration.workerStart`'s view of the shared launch-mode decision. * - * There is no `--structured` flag: if the user's default is that a new agent tab opens as a - * structured native chat, an orchestration worker is one too. That default is a preference, not a - * demand, so a dispatch it cannot apply to falls back to an ordinary PTY terminal worker and the - * receipt says which mode ran and why — a routine `worker-start` must never fail because the user - * happens to have a chat preference on. - * - * The settings default and the per-launch feasibility both come from - * `shared/structured-native-chat-launch-route`, the same module the renderer's - * `resolveAgentLaunchRoute` uses. This adapter supplies placement facts and formats the receipt; - * it does not own a second feasibility policy. + * The decision itself lives in `main/agent-launch/agent-launch-mode`, which every launch surface + * shares — a worker is not a special kind of launch, it is the same launch with a dispatch + * attached. All this module contributes is the noun orchestration puts in its receipts ("worker") + * and the `--terminal` wording, so a dispatch receipt reads the way it always has. */ -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version' import { - prefersStructuredNativeChatByDefault, - resolveStructuredNativeChatSupport, - type NativeChatDefaultSettings, - type StructuredNativeChatBlocker -} from '../../../../shared/structured-native-chat-launch-route' + decideAgentLaunchMode, + downgradeAgentLaunchModeForHost, + readAgentLaunchModeSettings, + resolveAgentLaunchModeOnHost, + type AgentLaunchMode, + type AgentLaunchModeReason, + type AgentLaunchModeReceipt, + type AgentLaunchModeSettings, + type AgentLaunchModeVocabulary +} from '../../../agent-launch/agent-launch-mode' import type { TuiAgent } from '../../../../shared/tui-agent' -import { hasExplicitTuiLaunchCustomization } from '../../../../shared/tui-agent-launch-customization' import type { OrcaRuntimeService } from '../../orca-runtime' -export type WorkerStartMode = 'structured' | 'terminal' +export type WorkerStartMode = AgentLaunchMode +export type WorkerStartModeReason = AgentLaunchModeReason +export type WorkerStartModeReceipt = AgentLaunchModeReceipt -export type WorkerStartModeReason = - | 'user_default' - | 'remote_execution_host' - | 'reused_terminal' - | 'agent_without_structured_session' - | 'tui_launch_customization' - | 'structured_sessions_unavailable' - | 'structured_support_unknown' - | 'wsl_execution_runtime' - | 'codex_on_windows' - | 'structured_unsupported_on_host' - -export type WorkerStartModeReceipt = { - /** The mode the worker actually started in. */ - mode: WorkerStartMode - /** The user's settings default for a new agent tab. */ - preferred: WorkerStartMode - reason: WorkerStartModeReason - /** One sentence, always present, so a fallback is never silent. */ - detail: string +/** Orchestration's receipts are read next to dispatch records, so they name the worker and the + * flag that reused a terminal. Pinned here because the exact strings are asserted. */ +export const WORKER_START_VOCABULARY: AgentLaunchModeVocabulary = { + structured: 'a structured chat session worker', + terminal: 'a terminal agent worker', + detailOverrides: { + remote_execution_host: 'this worker runs on a remote execution host', + reused_terminal: '--terminal reuses a running terminal agent' + } } -type WorkerStartModeSettings = Partial< - NativeChatDefaultSettings & - Pick -> - /** The placement options that exist only on `worker-start`. `worktree`, `model` and `effort` are * listed but no longer read: a structured worker honours all three, and naming them here keeps * the set of options this decision has considered visible. */ @@ -66,153 +48,35 @@ type WorkerStartModePlacement = { effort?: string } -const DOWNGRADE_DETAIL: Record, string> = { - remote_execution_host: 'this worker runs on a remote execution host', - reused_terminal: '--terminal reuses a running terminal agent', - agent_without_structured_session: 'this agent has no structured session', - tui_launch_customization: - 'this agent has a custom launch command, arguments or environment that only a terminal applies', - structured_sessions_unavailable: 'this runtime does not support structured agent sessions', - 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' -} - -const BLOCKER_REASON: Record< - StructuredNativeChatBlocker, - Exclude -> = { - 'reused-terminal': 'reused_terminal', - 'agent-without-structured-session': 'agent_without_structured_session', - 'draft-prompt': 'structured_unsupported_on_host', - 'floating-workspace': 'structured_unsupported_on_host', - 'tui-launch-customization': 'tui_launch_customization', - 'remote-execution-host': 'remote_execution_host', - 'project-runtime': 'wsl_execution_runtime', - 'runtime-capability': 'structured_sessions_unavailable', - 'runtime-capability-unknown': 'structured_support_unknown' -} - -/** The host's own create-support verdict (`agentSession.createSupport`) in this vocabulary. */ -const HOST_SUPPORT_REASON: Record< - 'agent' | 'remote' | 'wsl', - Exclude -> = { - agent: 'structured_unsupported_on_host', - remote: 'remote_execution_host', - wsl: 'wsl_execution_runtime' -} - export function decideWorkerStartMode(args: { params: WorkerStartModePlacement - settings: WorkerStartModeSettings | null | undefined + settings: AgentLaunchModeSettings | null | undefined }): WorkerStartModeReceipt { - const { params, settings } = args - if (!prefersStructuredNativeChatByDefault(settings)) { - return { - mode: 'terminal', - preferred: 'terminal', - reason: 'user_default', - detail: 'Started a terminal agent worker, the default for new agent tabs in your settings.' - } - } - const agent = params.agent as TuiAgent - const support = resolveStructuredNativeChatSupport({ - agent, - executionHostId: params.on ? `runtime:${params.on}` : 'local', - reusesTerminal: Boolean(params.terminal), - hostCapabilities: RUNTIME_CAPABILITIES, - // Orchestration resolves a managed worktree or folder workspace; a floating terminal is never - // a worker placement. WSL is left to the executing host's own create-support probe, which - // reads the resolved workspace rather than guessing from a client-side project runtime. - requiresTuiLaunchCustomization: hasExplicitTuiLaunchCustomization(settings, agent) + return decideAgentLaunchMode({ + placement: args.params, + settings: args.settings, + vocabulary: WORKER_START_VOCABULARY }) - if (!support.supported) { - return downgraded(BLOCKER_REASON[support.blocker]) - } - return { - mode: 'structured', - preferred: 'structured', - reason: 'user_default', - detail: - 'Started a structured chat session worker, the default for new agent tabs in your settings.' - } } -/** - * Second half of the decision, once the worktree is resolved: the host that will run the worker - * answers whether it can create a structured session there at all. Asked before anything is - * created, so a refusal becomes a terminal worker rather than a failed start. - */ export async function resolveWorkerStartModeOnHost( runtime: Pick, mode: WorkerStartModeReceipt, worktreeId: string | undefined, agent: TuiAgent | undefined ): Promise { - if (mode.mode !== 'structured' || !worktreeId) { - return mode - } - return downgradeWorkerStartModeForHost( - mode, - await readStructuredCreateSupport(runtime, worktreeId, agent) - ) + return resolveAgentLaunchModeOnHost(runtime, mode, worktreeId, agent, WORKER_START_VOCABULARY) } -/** A host that cannot answer has not proved it can create one, so the worker stays a PTY agent. */ -async function readStructuredCreateSupport( - runtime: Pick, - worktreeId: string, - agent: TuiAgent | undefined -): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } | null> { - if (agent !== 'claude' && agent !== 'codex') { - return { supported: false, reason: 'agent' } - } - try { - return await runtime.getStructuredAgentSessionCreateSupport(`id:${worktreeId}`, agent) - } catch { - return null - } -} - -/** - * Applies the executing host's `agentSession.createSupport` answer, which is the authority on WSL, - * remoteness and the Windows process-start-time gate for the resolved workspace. - */ export function downgradeWorkerStartModeForHost( receipt: WorkerStartModeReceipt, support: { supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } | null ): WorkerStartModeReceipt { - if (receipt.mode !== 'structured' || support?.supported) { - return receipt - } - if (support === null) { - return downgraded(BLOCKER_REASON['runtime-capability-unknown']) - } - return downgraded( - support.reason ? HOST_SUPPORT_REASON[support.reason] : 'structured_unsupported_on_host' - ) + return downgradeAgentLaunchModeForHost(receipt, support, WORKER_START_VOCABULARY) } -function downgraded( - reason: Exclude -): WorkerStartModeReceipt { - return { - mode: 'terminal', - preferred: 'structured', - reason, - detail: `Your default is a structured chat session, but ${DOWNGRADE_DETAIL[reason]}; started a terminal agent worker instead.` - } -} - -/** The store can be missing on a runtime that never opened one; that reads as no preference. */ export function readWorkerStartModeSettings( runtime: Pick -): WorkerStartModeSettings | null { - try { - return runtime.getClientSettings() - } catch { - return null - } +): AgentLaunchModeSettings | null { + return readAgentLaunchModeSettings(runtime) } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts new file mode 100644 index 00000000000..9ead671775f --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts @@ -0,0 +1,140 @@ +/** + * The exact sentences `orchestration.workerStart` puts in its mode receipt. + * + * These were unpinned when the decision moved to `main/agent-launch/agent-launch-mode`: the + * existing suites assert `toContain` fragments ('terminal agent', 'cannot create'), and the CLI + * suite asserts a receipt handed to it by a mock rather than one this code produced. Every one of + * them stayed green against a deliberately corrupted vocabulary, so nothing was actually holding + * the wording. A dispatch receipt is the only place a structured→terminal downgrade explains + * itself, so the whole sentence is the contract, not a fragment of it. + */ + +import { describe, expect, it } from 'vitest' +import { + decideWorkerStartMode, + downgradeWorkerStartModeForHost, + type WorkerStartModeReceipt +} from './orchestration-worker-start-mode' + +const STRUCTURED_PREFERENCE = { + experimentalNativeChat: true, + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: true +} as const + +function structuredReceipt(): WorkerStartModeReceipt { + const receipt = decideWorkerStartMode({ + params: { agent: 'claude' }, + settings: STRUCTURED_PREFERENCE + }) + expect(receipt.mode).toBe('structured') + return receipt +} + +function downgradeSentence(why: string): string { + return `Your default is a structured chat session, but ${why}; started a terminal agent worker instead.` +} + +describe('worker-start mode receipt wording', () => { + it('states the settings default when the user has no structured preference', () => { + expect(decideWorkerStartMode({ params: { agent: 'claude' }, settings: null })).toEqual({ + mode: 'terminal', + preferred: 'terminal', + reason: 'user_default', + detail: 'Started a terminal agent worker, the default for new agent tabs in your settings.' + }) + }) + + it('states the settings default when the launch is structured', () => { + expect(structuredReceipt()).toEqual({ + mode: 'structured', + preferred: 'structured', + reason: 'user_default', + detail: + 'Started a structured chat session worker, the default for new agent tabs in your settings.' + }) + }) + + it.each([ + [ + 'remote execution host', + { agent: 'claude', on: 'server-1' }, + 'remote_execution_host', + 'this worker runs on a remote execution host' + ], + [ + 'reused terminal', + { agent: 'claude', terminal: 'term_1' }, + 'reused_terminal', + '--terminal reuses a running terminal agent' + ], + [ + 'agent with no structured session', + { agent: 'grok' }, + 'agent_without_structured_session', + 'this agent has no structured session' + ] + ])('names the %s downgrade in full', (_label, params, reason, why) => { + expect(decideWorkerStartMode({ params, settings: STRUCTURED_PREFERENCE })).toEqual({ + mode: 'terminal', + preferred: 'structured', + reason, + detail: downgradeSentence(why) + }) + }) + + it('names a custom TUI launch as the downgrade', () => { + expect( + decideWorkerStartMode({ + params: { agent: 'claude' }, + settings: { ...STRUCTURED_PREFERENCE, agentDefaultArgs: { claude: '--custom' } } + }) + ).toEqual({ + mode: 'terminal', + preferred: 'structured', + reason: 'tui_launch_customization', + detail: downgradeSentence( + 'this agent has a custom launch command, arguments or environment that only a terminal applies' + ) + }) + }) + + it.each([ + [ + 'an unanswered host', + null, + 'structured_support_unknown', + 'the execution host has not established structured session support' + ], + [ + 'a host refusal with no reason', + { supported: false }, + 'structured_unsupported_on_host', + 'the execution host cannot create one here' + ], + [ + 'a WSL workspace', + { supported: false, reason: 'wsl' as const }, + 'wsl_execution_runtime', + 'this workspace runs under WSL' + ], + [ + 'a remote workspace', + { supported: false, reason: 'remote' as const }, + 'remote_execution_host', + 'this worker runs on a remote execution host' + ] + ])('names %s in full', (_label, support, reason, why) => { + expect(downgradeWorkerStartModeForHost(structuredReceipt(), support)).toEqual({ + mode: 'terminal', + preferred: 'structured', + reason, + detail: downgradeSentence(why) + }) + }) + + it('leaves a settled terminal receipt untouched', () => { + const terminal = decideWorkerStartMode({ params: { agent: 'claude' }, settings: null }) + expect(downgradeWorkerStartModeForHost(terminal, null)).toEqual(terminal) + }) +})