fix(runtime): let a paired client enable structured chat on a headless host

`experimentalStructuredNativeChat` was readable in the paired settings
projection but absent from the update surface, and it defaults off. A headless
`orca serve` or peer host ships no settings UI and had no RPC that could flip
it, so every fresh headless host refused the whole structured surface forever.

Add the key to the `settings.update` params schema and to the typed update
projection. Admission stays host-owned policy: this only writes the setting the
host's own admission check already reads, so the status admission field follows
on the next read.

The update decoder is strict, so this is a params-schema change on an existing
method: an older host rejects the key with `invalid_argument` rather than
silently ignoring it, and a client must therefore only send it to a host whose
status carries the admission field. No client sends it yet -- there is no
paired-host settings panel for the toggle today, so the affordance rides the
later settings work.

Mobile keeps its existing refusal. The phone has no surface for host policy, so
the previously schema-level rejection becomes an explicit `clientKind` check
rather than disappearing with the strict-decoder error.
This commit is contained in:
Merge Sim
2026-09-11 02:13:39 -07:00
parent 0f749bf507
commit 7655abf2bb
5 changed files with 124 additions and 6 deletions
+24 -2
View File
@@ -62,7 +62,7 @@ describe('client UI RPC methods', () => {
expect(response).toMatchObject({ ok: true, result: { settings } })
})
it('rejects paired attempts to mutate the host-owned structured chat setting', async () => {
it('rejects mobile attempts to mutate the host-owned structured chat setting', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateClientSettings: vi.fn()
@@ -70,7 +70,8 @@ describe('client UI RPC methods', () => {
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
const response = await dispatcher.dispatch(
makeRequest('settings.update', { experimentalStructuredNativeChat: true })
makeRequest('settings.update', { experimentalStructuredNativeChat: true }),
{ clientKind: 'mobile' }
)
expect(response).toMatchObject({
@@ -80,6 +81,27 @@ describe('client UI RPC methods', () => {
expect(runtime.updateClientSettings).not.toHaveBeenCalled()
})
it('lets a paired desktop peer enable structured chat on a headless host', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateClientSettings: vi.fn(async () => ({ experimentalStructuredNativeChat: true }))
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
const response = await dispatcher.dispatch(
makeRequest('settings.update', { experimentalStructuredNativeChat: true }),
{ clientKind: 'runtime' }
)
expect(response).toMatchObject({
ok: true,
result: { settings: { experimentalStructuredNativeChat: true } }
})
expect(runtime.updateClientSettings).toHaveBeenCalledWith({
experimentalStructuredNativeChat: true
})
})
it('persists the runtime host task source settings for mobile Tasks', async () => {
const settings = {
defaultTuiAgent: null,
+11 -4
View File
@@ -1,6 +1,6 @@
import { omitPairingLocalUiFields } from '../../../../shared/pairing-local-ui-fields'
import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types'
import { defineMethod } from '../core'
import { InvalidArgumentError, defineMethod } from '../core'
import {
NativeChatSessionOptionsMutation,
PRBotAuthorOverrideUpdate,
@@ -21,9 +21,16 @@ export const CLIENT_UI_METHODS = [
defineMethod({
name: 'settings.update',
params: SettingsUpdate,
handler: async (params, { runtime }) => ({
settings: await runtime.updateClientSettings(params)
})
handler: async (params, { runtime, clientKind }) => {
// A headless host ships no settings UI, so this RPC is its only admission lever -- but
// the phone has no surface for host policy, so it stays refused there as before.
if (clientKind === 'mobile' && params.experimentalStructuredNativeChat !== undefined) {
throw new InvalidArgumentError(
'Structured chat admission is host policy and cannot be set from mobile.'
)
}
return { settings: await runtime.updateClientSettings(params) }
}
}),
defineMethod({
name: 'settings.getTerminalQuickCommands',
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { supportsStructuredAgentSessions } from './rpc/methods/structured-agent-session-policy'
import { SettingsUpdate } from '../../shared/rpc-contract/client-settings-params'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import type { GlobalSettings } from '../../shared/global-settings-types'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
function createHeadlessHost(structuredNativeChatEnabled: boolean): OrcaRuntimeService {
let settings: Partial<GlobalSettings> = {
workspaceDir: '/tmp/orca-remote-admission-update',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
compactWorktreeCards: false,
experimentalStructuredNativeChat: structuredNativeChatEnabled
}
const store = {
getSettings: () => settings,
updateSettings: (updates: Partial<GlobalSettings>) => {
settings = { ...settings, ...updates }
return settings
}
}
return new OrcaRuntimeService(store as never)
}
/** Goes through the real RPC decoder, which is strict: an unmodelled key is rejected outright. */
async function applyPairedSettingsUpdate(
runtime: OrcaRuntimeService,
payload: Record<string, unknown>
): Promise<void> {
await runtime.updateClientSettings(SettingsUpdate.parse(payload))
}
function admitsCapableRemoteCaller(runtime: OrcaRuntimeService): boolean {
return supportsStructuredAgentSessions({
runtime,
clientKind: 'runtime',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
})
}
describe('structured-chat admission over the paired settings update', () => {
it('lets a paired client flip a headless host from refusing to admitting', async () => {
const runtime = createHeadlessHost(false)
expect(admitsCapableRemoteCaller(runtime)).toBe(false)
await applyPairedSettingsUpdate(runtime, { experimentalStructuredNativeChat: true })
expect(admitsCapableRemoteCaller(runtime)).toBe(true)
})
it('publishes the flipped admission on the next status read', async () => {
const runtime = createHeadlessHost(false)
expect(runtime.getStatus().structuredSessionAdmission).toEqual({ enabled: false })
await applyPairedSettingsUpdate(runtime, { experimentalStructuredNativeChat: true })
expect(runtime.getStatus().structuredSessionAdmission).toEqual({ enabled: true })
})
it('turns admission back off when the client sends false', async () => {
const runtime = createHeadlessHost(true)
await applyPairedSettingsUpdate(runtime, { experimentalStructuredNativeChat: false })
expect(admitsCapableRemoteCaller(runtime)).toBe(false)
expect(runtime.getStatus().structuredSessionAdmission).toEqual({ enabled: false })
})
it('leaves the setting untouched when an update omits the key', async () => {
const runtime = createHeadlessHost(true)
await applyPairedSettingsUpdate(runtime, { compactWorktreeCards: true })
expect(runtime.getClientSettings().experimentalStructuredNativeChat).toBe(true)
expect(runtime.getClientSettings().compactWorktreeCards).toBe(true)
})
})
@@ -68,6 +68,7 @@ export type RuntimeClientSettingsUpdate = Pick<
| 'defaultLinearTeamSelection'
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'experimentalStructuredNativeChat'
| 'compactWorktreeCards'
| 'minimaxGroupId'
| 'minimaxUsageModels'
@@ -106,6 +106,9 @@ export const SettingsUpdate = z
.enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all'])
.optional(),
experimentalNewWorktreeCardStyle: z.boolean().optional(),
// The only lever a headless host has for structured-chat admission: it ships no settings UI,
// so without this key a fresh `orca serve` or peer host refuses the surface forever.
experimentalStructuredNativeChat: z.boolean().optional(),
agentStatusHooksEnabled: z.boolean().optional(),
defaultRepoSelection: z.array(z.string()).nullable().optional(),
defaultLinearTeamSelection: z.array(z.string()).nullable().optional(),