refactor: share session config RPC handling

This commit is contained in:
weishu
2026-05-21 14:40:00 +08:00
parent e88a9075df
commit b0a3397601
5 changed files with 206 additions and 104 deletions
+82
View File
@@ -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<unknown>
}
}
}
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<string, unknown> }
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<string, unknown> }
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')
})
})
+83
View File
@@ -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<TPermissionMode extends PermissionMode = PermissionMode> = {
permissionMode?: TPermissionMode
model?: string | null
}
type RegisterSessionConfigRpcOptions<TPermissionMode extends PermissionMode = PermissionMode> = {
rpcHandlerManager: RpcHandlerManager
flavor: AgentFlavor
modelMode?: 'nullable' | 'ignore' | 'reject'
appliedFallback?: () => Record<string, unknown>
onApply: (config: SessionConfigState<TPermissionMode>) => void
onAfterApply?: () => void
}
export function resolveSessionConfigPermissionMode<TPermissionMode extends PermissionMode>(
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<TPermissionMode extends PermissionMode>({
rpcHandlerManager,
flavor,
modelMode = 'reject',
appliedFallback,
onApply,
onAfterApply
}: RegisterSessionConfigRpcOptions<TPermissionMode>): 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<string, unknown> = {}
const next: SessionConfigState<TPermissionMode> = {}
if (config.permissionMode !== undefined) {
next.permissionMode = resolveSessionConfigPermissionMode<TPermissionMode>(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)
}
})
}
+12 -23
View File
@@ -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<PermissionMode>({
rpcHandlerManager: session.rpcHandlerManager,
flavor: 'cursor',
modelMode: 'ignore',
appliedFallback: () => ({ permissionMode: currentPermissionMode }),
onApply: (config) => {
if (config.permissionMode !== undefined) {
currentPermissionMode = config.permissionMode;
}
},
onAfterApply: syncSessionMode
});
let crashed = false;
+15 -41
View File
@@ -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<string, unknown> = {};
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<PermissionMode>({
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;
+14 -40
View File
@@ -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<string, unknown> = {};
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<PermissionMode>({
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;