From b0a3397601c6df024ca23a207f346785f59caf62 Mon Sep 17 00:00:00 2001 From: weishu Date: Thu, 21 May 2026 14:40:00 +0800 Subject: [PATCH] refactor: share session config RPC handling --- cli/src/agent/sessionConfigRpc.test.ts | 82 +++++++++++++++++++++++++ cli/src/agent/sessionConfigRpc.ts | 83 ++++++++++++++++++++++++++ cli/src/cursor/runCursor.ts | 35 ++++------- cli/src/gemini/runGemini.ts | 56 +++++------------ cli/src/opencode/runOpencode.ts | 54 +++++------------ 5 files changed, 206 insertions(+), 104 deletions(-) create mode 100644 cli/src/agent/sessionConfigRpc.test.ts create mode 100644 cli/src/agent/sessionConfigRpc.ts diff --git a/cli/src/agent/sessionConfigRpc.test.ts b/cli/src/agent/sessionConfigRpc.test.ts new file mode 100644 index 00000000..e5b8e497 --- /dev/null +++ b/cli/src/agent/sessionConfigRpc.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import { + registerSessionConfigRpc, + resolveNullableSessionModel, + resolveSessionConfigPermissionMode +} from './sessionConfigRpc' + +function createRpcHarness() { + const registerHandler = vi.fn() + return { + rpcHandlerManager: { registerHandler } as never, + getHandler: () => { + const call = registerHandler.mock.calls.find((args) => args[0] === RPC_METHODS.SetSessionConfig) + expect(call).toBeDefined() + return call![1] as (payload: unknown) => Promise + } + } +} + +describe('sessionConfigRpc', () => { + it('rejects permission modes that are not allowed for the agent flavor', () => { + expect(() => resolveSessionConfigPermissionMode('bypassPermissions', 'gemini')).toThrow('Invalid permission mode') + }) + + it('accepts null model for agents that support model config', () => { + expect(resolveNullableSessionModel(null)).toBeNull() + }) + + it('rejects empty and non-string models', () => { + expect(() => resolveNullableSessionModel('')).toThrow('Invalid model') + expect(() => resolveNullableSessionModel(' ')).toThrow('Invalid model') + expect(() => resolveNullableSessionModel(123)).toThrow('Invalid model') + }) + + it('applies model null when model config is supported', async () => { + const harness = createRpcHarness() + const onApply = vi.fn() + + registerSessionConfigRpc({ + rpcHandlerManager: harness.rpcHandlerManager, + flavor: 'opencode', + modelMode: 'nullable', + onApply + }) + + const result = await harness.getHandler()({ model: null }) as { applied: Record } + + expect(result.applied.model).toBeNull() + expect(onApply).toHaveBeenCalledWith({ model: null }) + }) + + it('ignores model config for agents that do not support model changes when configured to ignore', async () => { + const harness = createRpcHarness() + const onApply = vi.fn() + + registerSessionConfigRpc({ + rpcHandlerManager: harness.rpcHandlerManager, + flavor: 'cursor', + modelMode: 'ignore', + appliedFallback: () => ({ permissionMode: 'default' }), + onApply + }) + + const result = await harness.getHandler()({ model: null }) as { applied: Record } + + expect(result.applied).toEqual({ permissionMode: 'default' }) + expect(onApply).toHaveBeenCalledWith({}) + }) + + it('rejects model config for agents configured to reject model changes', async () => { + const harness = createRpcHarness() + + registerSessionConfigRpc({ + rpcHandlerManager: harness.rpcHandlerManager, + flavor: 'cursor', + onApply: vi.fn() + }) + + await expect(harness.getHandler()({ model: null })).rejects.toThrow('Invalid model') + }) +}) diff --git a/cli/src/agent/sessionConfigRpc.ts b/cli/src/agent/sessionConfigRpc.ts new file mode 100644 index 00000000..7f9c2f46 --- /dev/null +++ b/cli/src/agent/sessionConfigRpc.ts @@ -0,0 +1,83 @@ +import { isPermissionModeAllowedForFlavor, type AgentFlavor } from '@hapi/protocol' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import { PermissionModeSchema } from '@hapi/protocol/schemas' +import type { PermissionMode } from '@hapi/protocol/types' +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' + +type SessionConfigState = { + permissionMode?: TPermissionMode + model?: string | null +} + +type RegisterSessionConfigRpcOptions = { + rpcHandlerManager: RpcHandlerManager + flavor: AgentFlavor + modelMode?: 'nullable' | 'ignore' | 'reject' + appliedFallback?: () => Record + onApply: (config: SessionConfigState) => void + onAfterApply?: () => void +} + +export function resolveSessionConfigPermissionMode( + value: unknown, + flavor: AgentFlavor +): TPermissionMode { + const parsed = PermissionModeSchema.safeParse(value) + if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, flavor)) { + throw new Error('Invalid permission mode') + } + return parsed.data as TPermissionMode +} + +export function resolveNullableSessionModel(value: unknown): string | null { + if (value === null) { + return null + } + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error('Invalid model') + } + return value.trim() +} + +export function registerSessionConfigRpc({ + rpcHandlerManager, + flavor, + modelMode = 'reject', + appliedFallback, + onApply, + onAfterApply +}: RegisterSessionConfigRpcOptions): void { + rpcHandlerManager.registerHandler(RPC_METHODS.SetSessionConfig, async (payload: unknown) => { + if (!payload || typeof payload !== 'object') { + throw new Error('Invalid session config payload') + } + + const config = payload as { permissionMode?: unknown; model?: unknown } + const applied: Record = {} + const next: SessionConfigState = {} + + if (config.permissionMode !== undefined) { + next.permissionMode = resolveSessionConfigPermissionMode(config.permissionMode, flavor) + applied.permissionMode = next.permissionMode + } + + if (config.model !== undefined) { + if (modelMode === 'reject') { + throw new Error('Invalid model') + } + if (modelMode === 'nullable') { + next.model = resolveNullableSessionModel(config.model) + applied.model = next.model + } + } + + onApply(next) + onAfterApply?.() + + return { + applied: Object.keys(applied).length > 0 + ? applied + : (appliedFallback?.() ?? applied) + } + }) +} diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts index 988c1ff7..0217a8a6 100644 --- a/cli/src/cursor/runCursor.ts +++ b/cli/src/cursor/runCursor.ts @@ -8,9 +8,7 @@ import type { CursorSession } from './session'; import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory'; import { registerLocalHandoffHandler } from '@/agent/localHandoff'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; -import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; -import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; -import { PermissionModeSchema } from '@hapi/protocol/schemas'; +import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; @@ -105,26 +103,17 @@ export async function runCursor(opts: { return removed; }); - const resolvePermissionMode = (value: unknown): PermissionMode => { - const parsed = PermissionModeSchema.safeParse(value); - if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, 'cursor')) { - throw new Error('Invalid permission mode'); - } - return parsed.data as PermissionMode; - }; - - session.rpcHandlerManager.registerHandler(RPC_METHODS.SetSessionConfig, async (payload: unknown) => { - if (!payload || typeof payload !== 'object') { - throw new Error('Invalid session config payload'); - } - const config = payload as { permissionMode?: unknown }; - - if (config.permissionMode !== undefined) { - currentPermissionMode = resolvePermissionMode(config.permissionMode); - } - - syncSessionMode(); - return { applied: { permissionMode: currentPermissionMode } }; + registerSessionConfigRpc({ + rpcHandlerManager: session.rpcHandlerManager, + flavor: 'cursor', + modelMode: 'ignore', + appliedFallback: () => ({ permissionMode: currentPermissionMode }), + onApply: (config) => { + if (config.permissionMode !== undefined) { + currentPermissionMode = config.permissionMode; + } + }, + onAfterApply: syncSessionMode }); let crashed = false; diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts index 888fab93..34b13026 100644 --- a/cli/src/gemini/runGemini.ts +++ b/cli/src/gemini/runGemini.ts @@ -12,9 +12,7 @@ import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } f import { startHookServer } from '@/claude/utils/startHookServer'; import { cleanupHookSettingsFile, generateHookSettingsFile } from '@/modules/common/hooks/generateHookSettings'; import { resolveGeminiRuntimeConfig } from './utils/config'; -import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; -import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; -import { PermissionModeSchema } from '@hapi/protocol/schemas'; +import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; @@ -147,44 +145,20 @@ export async function runGemini(opts: { return removed; }); - const resolvePermissionMode = (value: unknown): PermissionMode => { - const parsed = PermissionModeSchema.safeParse(value); - if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, 'gemini')) { - throw new Error('Invalid permission mode'); - } - return parsed.data as PermissionMode; - }; - - const resolveModel = (value: unknown): string | null => { - if (value === null) { - return null; - } - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error('Invalid model'); - } - return value.trim(); - }; - - session.rpcHandlerManager.registerHandler(RPC_METHODS.SetSessionConfig, async (payload: unknown) => { - if (!payload || typeof payload !== 'object') { - throw new Error('Invalid session config payload'); - } - const config = payload as { permissionMode?: unknown; model?: unknown }; - const applied: Record = {}; - - if (config.permissionMode !== undefined) { - currentPermissionMode = resolvePermissionMode(config.permissionMode); - applied.permissionMode = currentPermissionMode; - } - - if (config.model !== undefined) { - sessionModel = resolveModel(config.model); - resolvedModel = sessionModel ?? machineDefault; - applied.model = sessionModel; - } - - syncSessionMode(); - return { applied }; + registerSessionConfigRpc({ + rpcHandlerManager: session.rpcHandlerManager, + flavor: 'gemini', + modelMode: 'nullable', + onApply: (config) => { + if (config.permissionMode !== undefined) { + currentPermissionMode = config.permissionMode; + } + if (config.model !== undefined) { + sessionModel = config.model; + resolvedModel = sessionModel ?? machineDefault; + } + }, + onAfterApply: syncSessionMode }); let crashed = false; diff --git a/cli/src/opencode/runOpencode.ts b/cli/src/opencode/runOpencode.ts index 74c63681..fa047b8c 100644 --- a/cli/src/opencode/runOpencode.ts +++ b/cli/src/opencode/runOpencode.ts @@ -9,9 +9,7 @@ import type { OpencodeMode, PermissionMode } from './types'; import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory'; import { registerLocalHandoffHandler } from '@/agent/localHandoff'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; -import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; -import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; -import { PermissionModeSchema } from '@hapi/protocol/schemas'; +import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc'; import { startOpencodeHookServer } from './utils/startOpencodeHookServer'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; @@ -127,43 +125,19 @@ export async function runOpencode(opts: { return removed; }); - const resolvePermissionMode = (value: unknown): PermissionMode => { - const parsed = PermissionModeSchema.safeParse(value); - if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, 'opencode')) { - throw new Error('Invalid permission mode'); - } - return parsed.data as PermissionMode; - }; - - const resolveModel = (value: unknown): string | null => { - if (value === null) { - return null; - } - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error('Invalid model'); - } - return value.trim(); - }; - - session.rpcHandlerManager.registerHandler(RPC_METHODS.SetSessionConfig, async (payload: unknown) => { - if (!payload || typeof payload !== 'object') { - throw new Error('Invalid session config payload'); - } - const config = payload as { permissionMode?: unknown; model?: unknown }; - const applied: Record = {}; - - if (config.permissionMode !== undefined) { - currentPermissionMode = resolvePermissionMode(config.permissionMode); - applied.permissionMode = currentPermissionMode; - } - - if (config.model !== undefined) { - sessionModel = resolveModel(config.model); - applied.model = sessionModel; - } - - syncSessionMode(); - return { applied }; + registerSessionConfigRpc({ + rpcHandlerManager: session.rpcHandlerManager, + flavor: 'opencode', + modelMode: 'nullable', + onApply: (config) => { + if (config.permissionMode !== undefined) { + currentPermissionMode = config.permissionMode; + } + if (config.model !== undefined) { + sessionModel = config.model; + } + }, + onAfterApply: syncSessionMode }); let crashed = false;