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(),