refactor(native-chat): make shared feasibility authoritative for launch routing

This commit is contained in:
Merge Sim
2026-09-08 11:51:08 -07:00
parent 8479d48450
commit ec3948c602
6 changed files with 193 additions and 39 deletions
@@ -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<Exclude<WorkerStartModeReason, 'user_default'>, 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<WorkerStartModeReason, 'user_default'>
> = {
'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<OrcaRuntimeService, 'getStructuredAgentSessionCreateSupport'>,
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<WorkerStartModeReason, 'user_default'> | 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<WorkerStartModeReason, 'user_default'>
): WorkerStartModeReceipt {
@@ -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')
})
})
+7 -4
View File
@@ -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.
@@ -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'],
@@ -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' }
@@ -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<typeof SharedLaunchRoute>()
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)
}
}
}
}
})
})