From 2eb93206c8efd42d73b813e773dde936da1eee59 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:19:42 -0700 Subject: [PATCH] refactor(agent-launch): make the launch-mode decision surface-neutral (#19848) * refactor(agent-launch): make the launch-mode decision surface-neutral `decideWorkerStartMode` was the only shared answer to "structured chat session or terminal agent?", but it lived in an orchestration-named module and spoke orchestration's vocabulary, so the other launch surfaces could not call it. Move the decision to `main/agent-launch/agent-launch-mode` unchanged and leave `orchestration-worker-start-mode` as the adapter that supplies the noun. A worker is not a special kind of launch; it is the same launch with a dispatch attached. Naming the receipt's subject is the only thing orchestration actually contributed, so that is the only thing the adapter keeps: "worker" in both sentences, plus the `--terminal` wording, which reads as nonsense anywhere a `--terminal` flag does not exist. Both are pinned, because they are asserted. No behavior change. The receipts are byte-identical for every reachable case, proven by running the new pin against both implementations. Also pins the wording, which nothing was holding. 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; all six files stayed green against a deliberately corrupted vocabulary. A dispatch receipt is the only place a structured-to-terminal downgrade explains itself, so the whole sentence is the contract, not a fragment of it. * fix(agent-launch): drop the deleted draft-prompt blocker from the reason map main removed the draft-prompt blocker in #19681 (a structured session now holds an unsent draft), so the exhaustive Record no longer typechecks. * chore(agent-launch): carry a SAFETY rationale on the agent placement cast The type-assertion gate landed after this branch's base, so the new file's copy of the worker-start cast is now a changed-code finding. * docs(agent-launch): stop the receipt-wording comment claiming a migration The decision was never moved out of orchestration-worker-start-mode; this PR adds a second copy beside it. Say so, and name the unenforced agreement. --------- Co-authored-by: Merge Sim --- src/main/agent-launch/agent-launch-mode.ts | 248 ++++++++++++++++++ ...ation-worker-start-receipt-wording.test.ts | 144 ++++++++++ 2 files changed, 392 insertions(+) create mode 100644 src/main/agent-launch/agent-launch-mode.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts 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..8180c075e17 --- /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', + '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.` + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an unrecognized agent name is handled rather than trusted; isAgentSessionHandleProvider rejects it and the launch downgrades to a terminal. + 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-receipt-wording.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts new file mode 100644 index 00000000000..b52263ba791 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts @@ -0,0 +1,144 @@ +/** + * The exact sentences `orchestration.workerStart` puts in its mode receipt. + * + * These were never pinned: 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. + * + * This pins orchestration's own module, which this PR leaves in place. The neutral + * `agent-launch/agent-launch-mode` it introduces is a second copy of the same policy; nothing yet + * enforces that the two agree. + */ + +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) + }) +})