Files
orca/src/shared/structured-native-chat-launch-route.ts
T
Brennan Benson 251516de60 feat(native-chat): choose which agents open as a chat (#29071)
* feat(native-chat): choose which agents open as a chat

Settings → Chat gets a Providers list. Each agent the host can open as a chat
has a switch; off opens that agent in a terminal instead. Only choices the user
makes are saved. Unchosen: Claude and Codex on, others on when their CLI is
installed on this computer.

The renderer's route and the host's launch-mode decision both read one rule, so
the tab menu, shortcuts, CLI, orchestration and relayed phone launches agree.

* fix: keep chat provider settings with the other chat settings types

* test: route input carries this computer's detected agents

* fix(chat): resolve provider choices from their owning computer

* test(chat): use checked native inventory fixtures

* Fix launch preparation lifetime and execution host startup

* fix(chat): keep unchosen providers on without discovery

* test(chat): type provider routing fixtures against store contracts

* test(chat): align shared launch fixture with private settings reader

* test(chat): give the structured-session RPC fixture the private settings reader

The host now reads saved chat choices through requireStore().getSettings(); the
shared RPC fixture only had getClientSettings, so the launch-mode reader saw no
settings and create support refused Claude. The fixture now follows each test's
client settings.

* fix: preserve provider choice and cached native inventory
2026-10-17 00:51:43 -07:00

138 lines
6.2 KiB
TypeScript

/**
* The one place that answers "should this launch be a structured native chat session?".
*
* Both launch surfaces call it. The renderer asks when a user opens an agent tab
* (`resolveAgentLaunchRoute`); orchestration asks when it dispatches a worker, because the mode is
* the user's own default rather than a per-call flag. Keeping the two halves — the settings default
* and the per-launch feasibility — here is what stops the second caller from growing a copy that
* drifts.
*/
import { isAgentSessionHandleProvider } from './agent-session-provider-handle'
import { parseExecutionHostId } from './execution-host'
import type { GlobalSettings } from './global-settings-types'
import type { NativeChatProviderSettings } from './native-chat-providers'
import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime'
import {
STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY,
STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from './protocol-version'
import type { TuiAgent } from './tui-agent'
import type { WorkspaceLaunchKind } from './workspace-launch-kind'
export type NativeChatDefaultSettings = Pick<GlobalSettings, 'experimentalNativeChat'> &
Partial<NativeChatProviderSettings>
/** Why a launch that the user's default asked to be structured cannot be. */
export type StructuredNativeChatBlocker =
| 'reused-terminal'
| 'agent-without-structured-session'
/** The launch names a start directory outside its workspace, which only a PTY can apply. The
* configured launch command and arguments never decide the surface. */
| 'custom-start-directory'
| 'remote-execution-host'
| 'project-runtime'
| 'runtime-capability'
/** The owning host has not answered yet. Distinct from `runtime-capability`, which is the
* host saying no: an unestablished answer must not read as a refusal. */
| 'runtime-capability-unknown'
/** This client does not tell a paired host it can read and launch structured sessions. */
| 'client-capability'
export type StructuredNativeChatSupport =
| { supported: true }
| { supported: false; blocker: StructuredNativeChatBlocker }
export type StructuredNativeChatSupportInput = {
agent: TuiAgent
executionHostId: string
/** Capabilities of the host this launch would run on — a paired server's own, not this
* machine's. `null` = not yet established. */
hostCapabilities: readonly string[] | null
/** What this client advertises to a paired host; read only for `runtime:` hosts. */
clientCapabilities?: readonly string[]
/** Host-derived. Absent means the kind was never established, which is not evidence of any kind. */
workspaceKind?: WorkspaceLaunchKind
projectRuntime?: ProjectExecutionRuntimeResolution | null
startsOutsideWorkspaceRoot?: boolean
/** An existing PTY agent keeps its execution transport. */
reusesTerminal?: boolean
/** The agents the host listed through `agentSession.agents`. Absent: none learned, so only
* Claude and Codex, which every structured host runs, can be offered. */
hostStructuredAgents?: readonly string[]
}
/** The single Chat UI switch selects structured chat for supported new launches. */
export function isNativeChatEnabled(
settings: Partial<NativeChatDefaultSettings> | null | undefined
): boolean {
return settings?.experimentalNativeChat === true
}
/** Unchosen agents retain the chat default, independently of installation inventory. */
export function isNativeChatDefaultFor(
settings: Partial<NativeChatDefaultSettings> | null | undefined,
agent: TuiAgent
): boolean {
return isNativeChatEnabled(settings) && (settings?.nativeChatProviders?.[agent] ?? true)
}
function clientChoosesStructuredLaunches(capabilities: readonly string[] | undefined): boolean {
return (
capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) === true &&
capabilities.includes(STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY)
)
}
export function resolveStructuredNativeChatSupport(
input: StructuredNativeChatSupportInput
): StructuredNativeChatSupport {
// A paired Orca server runs structured sessions itself and answers for them below. An SSH host
// has no Orca runtime of its own to run one.
const host = parseExecutionHostId(input.executionHostId)
if (host?.kind !== 'local' && host?.kind !== 'runtime') {
return { supported: false, blocker: 'remote-execution-host' }
}
if (input.reusesTerminal === true) {
return { supported: false, blocker: 'reused-terminal' }
}
const builtInAgent = isAgentSessionHandleProvider(input.agent)
if (!builtInAgent && !input.hostStructuredAgents?.includes(input.agent)) {
return { supported: false, blocker: 'agent-without-structured-session' }
}
if (input.startsOutsideWorkspaceRoot === true) {
return { supported: false, blocker: 'custom-start-directory' }
}
const projectRuntime = input.projectRuntime
if (projectRuntime?.status === 'repair-required' || projectRuntime?.runtime.kind === 'wsl') {
return { supported: false, blocker: 'project-runtime' }
}
if (input.hostCapabilities === null) {
return { supported: false, blocker: 'runtime-capability-unknown' }
}
if (
!input.hostCapabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ||
// A host that does not advertise its registered agents accepts only Claude and Codex.
(!builtInAgent &&
!input.hostCapabilities.includes(
STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY
))
) {
return { supported: false, blocker: 'runtime-capability' }
}
if (host.kind === 'runtime') {
// An older paired host advertises structured sessions but admits them only with its own chat
// setting on, so a chat opened there could never start; it keeps the terminal it always got.
if (!input.hostCapabilities.includes(STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY)) {
return { supported: false, blocker: 'runtime-capability' }
}
// The host refuses a client that did not say it reads structured sessions, as the browser
// client does not; that client keeps the host terminal.
if (!clientChoosesStructuredLaunches(input.clientCapabilities)) {
return { supported: false, blocker: 'client-capability' }
}
}
return { supported: true }
}