mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
refactor(native-chat): make shared feasibility authoritative for launch routing
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user