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 693d70529c0..97c64082bf2 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts @@ -9,8 +9,8 @@ * * 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; only the placement options that exist solely on this command are - * decided here. + * `resolveAgentLaunchRoute` uses. This adapter supplies placement facts and formats the receipt; + * it does not own a second feasibility policy. */ import type { GlobalSettings } from '../../../../shared/global-settings-types' @@ -34,6 +34,7 @@ export type WorkerStartModeReason = | 'agent_without_structured_session' | 'tui_launch_customization' | 'structured_sessions_unavailable' + | 'structured_support_unknown' | 'wsl_execution_runtime' | 'codex_on_windows' | 'structured_unsupported_on_host' @@ -66,12 +67,13 @@ type WorkerStartModePlacement = { } const DOWNGRADE_DETAIL: Record, string> = { - remote_execution_host: '--on runs the worker on a remote execution host', + 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' @@ -81,6 +83,7 @@ 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', @@ -88,9 +91,7 @@ const BLOCKER_REASON: Record< 'remote-execution-host': 'remote_execution_host', 'project-runtime': 'wsl_execution_runtime', 'runtime-capability': 'structured_sessions_unavailable', - // Orchestration passes its own host's list, so this is unreachable there; the map is - // exhaustive by type and must still name it. - 'runtime-capability-unknown': 'structured_sessions_unavailable' + 'runtime-capability-unknown': 'structured_support_unknown' } /** The host's own create-support verdict (`agentSession.createSupport`) in this vocabulary. */ @@ -116,15 +117,11 @@ export function decideWorkerStartMode(args: { detail: 'Started a terminal agent worker, the default for new agent tabs in your settings.' } } - const placementReason = resolvePlacementReason(params) - if (placementReason) { - return downgraded(placementReason) - } const agent = params.agent as TuiAgent const support = resolveStructuredNativeChatSupport({ agent, - // Set only by --on, which the placement check above already turned into a fallback. - executionHostId: 'local', + 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 @@ -168,14 +165,14 @@ async function readStructuredCreateSupport( runtime: Pick, worktreeId: string, agent: TuiAgent | undefined -): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { +): 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 { supported: false } + return null } } @@ -185,32 +182,19 @@ async function readStructuredCreateSupport( */ export function downgradeWorkerStartModeForHost( receipt: WorkerStartModeReceipt, - support: { supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } + support: { supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } | null ): WorkerStartModeReceipt { - if (receipt.mode !== 'structured' || support.supported) { + 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' ) } -function resolvePlacementReason( - params: WorkerStartModePlacement -): Exclude | null { - if (params.on) { - return 'remote_execution_host' - } - if (params.terminal) { - return 'reused_terminal' - } - // Creating a worktree and choosing a model are the two most common things a dispatch does, and - // both used to downgrade here — which is why orchestration never produced a structured chat in - // practice. Neither is a placement fact any more: a structured worker's worktree is created - // without a startup agent terminal, and `--model`/`--effort` seed the session's own options. - return null -} - function downgraded( reason: Exclude ): WorkerStartModeReceipt { 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 new file mode 100644 index 00000000000..1adca072f20 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import { + decideWorkerStartMode, + resolveWorkerStartModeOnHost +} from './orchestration-worker-start-mode' + +const mode = decideWorkerStartMode({ + params: { agent: 'claude' }, + settings: { + experimentalNativeChat: true, + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: true + } +}) + +describe('host support evidence', () => { + it('distinguishes an unanswered host from an explicit refusal without creating a session', async () => { + const getStructuredAgentSessionCreateSupport = vi + .fn() + .mockRejectedValue(new Error('disconnected')) + const runtime = { getStructuredAgentSessionCreateSupport } + const unknown = await resolveWorkerStartModeOnHost(runtime, mode, 'workspace-1', 'claude') + expect(unknown).toMatchObject({ + mode: 'terminal', + preferred: 'structured', + reason: 'structured_support_unknown' + }) + expect(unknown.detail).toContain('has not established') + expect(getStructuredAgentSessionCreateSupport).toHaveBeenCalledWith('id:workspace-1', 'claude') + getStructuredAgentSessionCreateSupport.mockResolvedValue({ supported: false }) + const refusal = await resolveWorkerStartModeOnHost(runtime, mode, 'workspace-1', 'claude') + expect(refusal.reason).toBe('structured_unsupported_on_host') + expect(refusal.detail).toContain('cannot create') + }) +}) diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index 5763cccc1f7..955237bb741 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -42,6 +42,12 @@ export type AgentLaunchRoutingInput = { } export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLaunchRoute { + if ( + prefersStructuredNativeChatByDefault(input.settings) && + structuredAgentLaunchSupported(input) + ) { + return 'structured-native-chat' + } const initialViewMode = decideInitialAgentTabViewMode({ experimentalNativeChat: input.settings?.experimentalNativeChat, openAgentTabsInChatByDefault: input.settings?.openAgentTabsInChatByDefault, @@ -53,10 +59,7 @@ export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLa if (initialViewMode !== 'chat') { return 'terminal-tui' } - if (!prefersStructuredNativeChatByDefault(input.settings)) { - return 'legacy-native-chat' - } - return structuredAgentLaunchSupported(input) ? 'structured-native-chat' : 'legacy-native-chat' + return 'legacy-native-chat' } // Explicit chat requests do not depend on the default view mode for new tabs. diff --git a/src/shared/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index e796cc0e2a5..422f7becb52 100644 --- a/src/shared/structured-native-chat-launch-route.test.ts +++ b/src/shared/structured-native-chat-launch-route.test.ts @@ -57,6 +57,7 @@ describe('per-launch structured feasibility', () => { }) it.each([ + ['a reused PTY agent', { reusesTerminal: true }, 'reused-terminal'], ['grok', { agent: 'grok' }, 'agent-without-structured-session'], ['openclaude', { agent: 'openclaude' }, 'agent-without-structured-session'], ['a draft prompt', { isDraftPrompt: true }, 'draft-prompt'], diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index f9006db44a9..2124556804e 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -21,6 +21,7 @@ export type NativeChatDefaultSettings = Pick< /** Why a launch that the user's default asked to be structured cannot be. */ export type StructuredNativeChatBlocker = + | 'reused-terminal' | 'agent-without-structured-session' | 'draft-prompt' | 'floating-workspace' @@ -46,6 +47,8 @@ export type StructuredNativeChatSupportInput = { /** A draft stays terminal-backed: the composer, not a turn, owns unsent text. */ isDraftPrompt?: boolean requiresTuiLaunchCustomization?: boolean + /** An existing PTY agent keeps its execution transport. */ + reusesTerminal?: boolean } /** The user's default for a new agent tab: native chat rather than the raw TUI. */ @@ -69,6 +72,12 @@ export function prefersStructuredNativeChatByDefault( export function resolveStructuredNativeChatSupport( input: StructuredNativeChatSupportInput ): StructuredNativeChatSupport { + if (input.executionHostId !== 'local') { + return { supported: false, blocker: 'remote-execution-host' } + } + if (input.reusesTerminal === true) { + return { supported: false, blocker: 'reused-terminal' } + } if (!isAgentSessionHandleProvider(input.agent)) { return { supported: false, blocker: 'agent-without-structured-session' } } @@ -81,9 +90,6 @@ export function resolveStructuredNativeChatSupport( if (input.requiresTuiLaunchCustomization === true) { return { supported: false, blocker: 'tui-launch-customization' } } - if (input.executionHostId !== 'local') { - return { supported: false, blocker: 'remote-execution-host' } - } const projectRuntime = input.projectRuntime if (projectRuntime?.status === 'repair-required' || projectRuntime?.runtime.kind === 'wsl') { return { supported: false, blocker: 'project-runtime' } diff --git a/tests/e2e/structured-native-chat-routing-authority.unit.test.ts b/tests/e2e/structured-native-chat-routing-authority.unit.test.ts new file mode 100644 index 00000000000..16be06deb4a --- /dev/null +++ b/tests/e2e/structured-native-chat-routing-authority.unit.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as SharedLaunchRoute from '../../src/shared/structured-native-chat-launch-route' +import { decideWorkerStartMode } from '../../src/main/runtime/rpc/methods/orchestration-worker-start-mode' +import { + resolveAgentLaunchRoute, + structuredAgentLaunchSupported, + type AgentLaunchRoutingInput +} from '../../src/renderer/src/lib/agent-launch-routing' +import { RUNTIME_CAPABILITIES } from '../../src/shared/protocol-version' +import { + resolveStructuredNativeChatSupport, + type StructuredNativeChatBlocker +} from '../../src/shared/structured-native-chat-launch-route' + +vi.mock('../../src/shared/structured-native-chat-launch-route', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolveStructuredNativeChatSupport: vi.fn(actual.resolveStructuredNativeChatSupport) + } +}) + +const settings = { + experimentalNativeChat: true, + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: true +} +const predicate = vi.mocked(resolveStructuredNativeChatSupport) +afterEach(() => predicate.mockReset()) + +const placements = [ + {}, + { on: 'server-1' }, + { on: 'local' }, + { terminal: 'term_1' }, + { worktree: 'current' }, + { worktree: 'new-child' }, + { worktree: 'new-top-level' }, + { model: 'opus', effort: 'high' }, + { worktree: 'new-child', model: 'opus', effort: 'high' } +] +const blockers: StructuredNativeChatBlocker[] = [ + 'reused-terminal', + 'agent-without-structured-session', + 'draft-prompt', + 'floating-workspace', + 'tui-launch-customization', + 'remote-execution-host', + 'project-runtime', + 'runtime-capability', + 'runtime-capability-unknown' +] + +describe('shared feasibility owns every caller decision', () => { + it.each(placements)('orchestration cannot override the shared verdict for %j', (placement) => { + for (const agent of ['claude', 'codex', 'grok', 'openclaude'] as const) { + for (const customized of [false, true]) { + const input = { + params: { agent, ...placement }, + settings: { + ...settings, + ...(customized ? { agentDefaultArgs: { [agent]: '--custom' } } : {}) + } + } + predicate.mockReturnValue({ supported: true }) + expect(decideWorkerStartMode(input).mode).toBe('structured') + expect(predicate).toHaveBeenLastCalledWith( + expect.objectContaining({ + agent, + executionHostId: placement.on ? `runtime:${placement.on}` : 'local', + reusesTerminal: Boolean(placement.terminal), + requiresTuiLaunchCustomization: customized + }) + ) + for (const blocker of blockers) { + predicate.mockReturnValue({ supported: false, blocker }) + const receipt = decideWorkerStartMode(input) + expect(receipt).toMatchObject({ mode: 'terminal', preferred: 'structured' }) + expect(receipt.reason).not.toBe('user_default') + expect(receipt.detail).toContain('Your default is a structured chat session') + if (blocker === 'runtime-capability-unknown') { + expect(receipt.reason).toBe('structured_support_unknown') + expect(receipt.detail).toContain('has not established') + } + } + } + } + }) + + it('renderer presentation cannot override shared feasibility', () => { + for (const agent of ['claude', 'codex', 'grok', 'openclaude'] as const) { + for (const executionHostId of ['local', 'ssh:host-1']) { + for (const promptDelivery of ['auto-submit', 'draft'] as const) { + const input: AgentLaunchRoutingInput = { + settings, + agent, + executionHostId, + promptDelivery, + hostCapabilities: RUNTIME_CAPABILITIES, + requiresTuiLaunchCustomization: true, + workspaceKind: 'folder', + initialSessionOptions: { model: 'model-1', effort: 'high' } + } + predicate.mockReturnValue({ supported: true }) + expect(resolveAgentLaunchRoute(input)).toBe('structured-native-chat') + expect(structuredAgentLaunchSupported(input)).toBe(true) + expect(predicate).toHaveBeenLastCalledWith( + expect.objectContaining({ + agent, + executionHostId, + isDraftPrompt: promptDelivery === 'draft', + requiresTuiLaunchCustomization: true, + workspaceKind: 'folder' + }) + ) + for (const blocker of blockers) { + predicate.mockReturnValue({ supported: false, blocker }) + expect(resolveAgentLaunchRoute(input)).not.toBe('structured-native-chat') + expect(structuredAgentLaunchSupported(input)).toBe(false) + } + } + } + } + }) +})