mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 00:02:41 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user