From 7655abf2bb3488d74eae478971fc744a215bf1d9 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 11 Sep 2026 02:13:39 -0700 Subject: [PATCH] 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. --- .../runtime/rpc/methods/client-ui.test.ts | 26 +++++- src/main/runtime/rpc/methods/client-ui.ts | 15 +++- ...ient-settings-structured-admission.test.ts | 85 +++++++++++++++++++ src/main/runtime/runtime-client-settings.ts | 1 + .../rpc-contract/client-settings-params.ts | 3 + 5 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 src/main/runtime/runtime-client-settings-structured-admission.test.ts diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 3a26b1c615d..683d9762fc7 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 6ed36a6fe83..5a21b222732 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -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', diff --git a/src/main/runtime/runtime-client-settings-structured-admission.test.ts b/src/main/runtime/runtime-client-settings-structured-admission.test.ts new file mode 100644 index 00000000000..c69ec2aaf42 --- /dev/null +++ b/src/main/runtime/runtime-client-settings-structured-admission.test.ts @@ -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 = { + workspaceDir: '/tmp/orca-remote-admission-update', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + compactWorktreeCards: false, + experimentalStructuredNativeChat: structuredNativeChatEnabled + } + const store = { + getSettings: () => settings, + updateSettings: (updates: Partial) => { + 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 +): Promise { + 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) + }) +}) diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 900900700f3..a8536c9c4df 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -68,6 +68,7 @@ export type RuntimeClientSettingsUpdate = Pick< | 'defaultLinearTeamSelection' | 'githubProjects' | 'experimentalNewWorktreeCardStyle' + | 'experimentalStructuredNativeChat' | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' diff --git a/src/shared/rpc-contract/client-settings-params.ts b/src/shared/rpc-contract/client-settings-params.ts index def24ddc703..3dad005db68 100644 --- a/src/shared/rpc-contract/client-settings-params.ts +++ b/src/shared/rpc-contract/client-settings-params.ts @@ -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(),