diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index 07e53e960..a0f7a2629 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -472,6 +472,156 @@ function setupAcpTest( } }); + it('supports session/set_config_option for mode and model', async () => { + const rig = new TestRig(); + rig.setup('acp set config option'); + + const { sendRequest, cleanup, stderr } = setupAcpTest(rig); + + try { + // Initialize + await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + }); + + await sendRequest('authenticate', { methodId: 'openai' }); + + // Create a new session + const newSession = (await sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + })) as { + sessionId: string; + models: { + availableModels: Array<{ modelId: string }>; + }; + }; + expect(newSession.sessionId).toBeTruthy(); + + // Test: Set mode using set_config_option + const setModeResult = (await sendRequest('session/set_config_option', { + sessionId: newSession.sessionId, + configId: 'mode', + value: 'yolo', + })) as { + configOptions: Array<{ + id: string; + currentValue: string; + options: Array<{ value: string; name: string; description: string }>; + }>; + }; + + expect(setModeResult).toBeDefined(); + expect(Array.isArray(setModeResult.configOptions)).toBe(true); + expect(setModeResult.configOptions.length).toBeGreaterThanOrEqual(2); + + // Find mode option + const modeOption = setModeResult.configOptions.find( + (opt) => opt.id === 'mode', + ); + expect(modeOption).toBeDefined(); + expect(modeOption!.currentValue).toBe('yolo'); + expect(Array.isArray(modeOption!.options)).toBe(true); + expect(modeOption!.options.some((o) => o.value === 'yolo')).toBe(true); + + // Find model option + const modelOption = setModeResult.configOptions.find( + (opt) => opt.id === 'model', + ); + expect(modelOption).toBeDefined(); + expect(modelOption!.currentValue).toBeTruthy(); + + // Test: Set model using set_config_option + // Use openai model to avoid auth issues + const openaiModel = newSession.models.availableModels.find((model) => + model.modelId.includes('openai'), + ); + expect(openaiModel).toBeDefined(); + + const setModelResult = (await sendRequest('session/set_config_option', { + sessionId: newSession.sessionId, + configId: 'model', + value: openaiModel!.modelId, + })) as { + configOptions: Array<{ + id: string; + currentValue: string; + options: Array<{ value: string; name: string; description: string }>; + }>; + }; + + expect(setModelResult).toBeDefined(); + expect(Array.isArray(setModelResult.configOptions)).toBe(true); + + // Verify model was updated + const updatedModelOption = setModelResult.configOptions.find( + (opt) => opt.id === 'model', + ); + expect(updatedModelOption).toBeDefined(); + expect(updatedModelOption!.currentValue).toBe(openaiModel!.modelId); + } catch (e) { + if (stderr.length) { + console.error('Agent stderr:', stderr.join('')); + } + throw e; + } finally { + await cleanup(); + } + }); + + it('returns error for invalid configId in set_config_option', async () => { + const rig = new TestRig(); + rig.setup('acp set config option error'); + + const { sendRequest, cleanup, stderr } = setupAcpTest(rig); + + try { + // Initialize + await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + }); + + await sendRequest('authenticate', { methodId: 'openai' }); + + // Create a new session + const newSession = (await sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + })) as { sessionId: string }; + expect(newSession.sessionId).toBeTruthy(); + + // Test: Invalid configId should return error + await expect( + sendRequest('session/set_config_option', { + sessionId: newSession.sessionId, + configId: 'invalid_config', + value: 'some_value', + }), + ).rejects.toMatchObject({ + response: { + code: -32602, + message: 'Invalid params', + data: { + details: 'Unsupported configId: invalid_config', + }, + }, + }); + } catch (e) { + if (stderr.length) { + console.error('Agent stderr:', stderr.join('')); + } + throw e; + } finally { + await cleanup(); + } + }); + it('receives available_commands_update with slash commands after session creation', async () => { const rig = new TestRig(); rig.setup('acp slash commands'); diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts index 904d61473..8c1dc0907 100644 --- a/packages/cli/src/acp-integration/acp.ts +++ b/packages/cli/src/acp-integration/acp.ts @@ -81,6 +81,14 @@ export class AgentSideConnection implements Client { const validatedParams = schema.setModelRequestSchema.parse(params); return agent.setModel(validatedParams); } + case schema.AGENT_METHODS.session_set_config_option: { + if (!agent.setConfigOption) { + throw RequestError.methodNotFound(); + } + const validatedParams = + schema.setConfigOptionRequestSchema.parse(params); + return agent.setConfigOption(validatedParams); + } default: throw RequestError.methodNotFound(method); } @@ -489,4 +497,7 @@ export interface Agent { cancel(params: schema.CancelNotification): Promise; setMode?(params: schema.SetModeRequest): Promise; setModel?(params: schema.SetModelRequest): Promise; + setConfigOption?( + params: schema.SetConfigOptionRequest, + ): Promise; } diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 11878017a..faf89db90 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -21,7 +21,7 @@ import { type ConversationRecord, type DeviceAuthorizationData, } from '@qwen-code/qwen-code-core'; -import type { ApprovalModeValue } from './schema.js'; +import type { ApprovalModeValue, ConfigOption } from './schema.js'; import * as acp from './acp.js'; import { buildAuthMethods } from './authMethods.js'; import { AcpFileSystemService } from './service/filesystem.js'; @@ -295,6 +295,104 @@ class GeminiAgent { return await session.setModel(params); } + async setConfigOption( + params: acp.SetConfigOptionRequest, + ): Promise { + const { sessionId, configId, value } = params; + + // Get the session's config + const session = this.sessions.get(sessionId); + if (!session) { + throw acp.RequestError.invalidParams( + `Session not found for id: ${sessionId}`, + ); + } + + switch (configId) { + case 'mode': { + await this.setMode({ + sessionId, + modeId: value as ApprovalModeValue, + }); + break; + } + case 'model': { + await this.setModel({ + sessionId, + modelId: value as string, + }); + break; + } + default: + throw acp.RequestError.invalidParams( + `Unsupported configId: ${configId}`, + ); + } + + // Return all config options with current values + return { + configOptions: this.buildConfigOptions(session.getConfig()), + }; + } + + private buildConfigOptions(config: Config): ConfigOption[] { + const currentApprovalMode = config.getApprovalMode(); + const allConfiguredModels = config.getAllConfiguredModels(); + const rawCurrentModelId = (config.getModel() || '').trim(); + const currentAuthType = config.getAuthType?.(); + + // Check if current model is a runtime model + const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.(); + const currentModelId = activeRuntimeSnapshot + ? formatAcpModelId( + activeRuntimeSnapshot.id, + activeRuntimeSnapshot.authType, + ) + : this.formatCurrentModelId(rawCurrentModelId, currentAuthType); + + // Build mode config option + const modeOptions = APPROVAL_MODES.map((mode) => ({ + value: mode, + name: APPROVAL_MODE_INFO[mode].name, + description: APPROVAL_MODE_INFO[mode].description, + })); + + const modeConfigOption: ConfigOption = { + id: 'mode', + name: 'Mode', + description: 'Session permission mode', + category: 'mode', + type: 'select', + currentValue: currentApprovalMode, + options: modeOptions, + }; + + // Build model config option + const modelOptions = allConfiguredModels.map((model) => { + const effectiveModelId = + model.isRuntimeModel && model.runtimeSnapshotId + ? model.runtimeSnapshotId + : model.id; + return { + value: formatAcpModelId(effectiveModelId, model.authType), + name: model.label, + description: model.description ?? '', + }; + }); + + const modelConfigOption: ConfigOption = { + id: 'model', + name: 'Model', + description: 'AI model to use', + category: 'model', + type: 'select', + currentValue: currentModelId, + options: modelOptions, + }; + + return [modeConfigOption, modelConfigOption]; + } + private async ensureAuthenticated(config: Config): Promise { const selectedType = config.getModelsConfig().getCurrentAuthType(); if (!selectedType) { @@ -478,55 +576,6 @@ class GeminiAgent { }; } - private buildConfigOptions(config: Config): acp.ConfigOption[] { - const currentApprovalMode = config.getApprovalMode(); - const currentModelId = this.formatCurrentModelId( - config.getModel() || this.config.getModel() || '', - config.getAuthType(), - ); - - const modeOptions = APPROVAL_MODES.map((mode) => ({ - value: mode, - name: APPROVAL_MODE_INFO[mode].name, - description: APPROVAL_MODE_INFO[mode].description, - })); - - const allConfiguredModels = config.getAllConfiguredModels(); - const modelOptions = allConfiguredModels.map((model) => { - const effectiveModelId = - model.isRuntimeModel && model.runtimeSnapshotId - ? model.runtimeSnapshotId - : model.id; - - return { - value: formatAcpModelId(effectiveModelId, model.authType), - name: model.label, - description: model.description ?? '', - }; - }); - - return [ - { - id: 'mode', - name: 'Mode', - description: 'Session permission mode', - category: 'mode', - type: 'select', - currentValue: currentApprovalMode, - options: modeOptions, - }, - { - id: 'model', - name: 'Model', - description: 'AI model to use', - category: 'model', - type: 'select', - currentValue: currentModelId, - options: modelOptions, - }, - ]; - } - private formatCurrentModelId( baseModelId: string, authType?: AuthType, diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts index 1df709c45..021bf7c93 100644 --- a/packages/cli/src/acp-integration/schema.ts +++ b/packages/cli/src/acp-integration/schema.ts @@ -16,6 +16,7 @@ export const AGENT_METHODS = { session_list: 'session/list', session_set_mode: 'session/set_mode', session_set_model: 'session/set_model', + session_set_config_option: 'session/set_config_option', }; export const CLIENT_METHODS = { @@ -475,6 +476,23 @@ export const configOptionSchema = z.object({ export type ConfigOption = z.infer; +export const setConfigOptionRequestSchema = z.object({ + sessionId: z.string(), + configId: z.string(), + value: z.unknown(), +}); + +export const setConfigOptionResponseSchema = z.object({ + configOptions: z.array(configOptionSchema), +}); + +export type SetConfigOptionRequest = z.infer< + typeof setConfigOptionRequestSchema +>; +export type SetConfigOptionResponse = z.infer< + typeof setConfigOptionResponseSchema +>; + // newSessionResponseSchema includes modes and configOptions for ACP/Zed integration export const newSessionResponseSchema = z.object({ sessionId: z.string(), @@ -684,6 +702,7 @@ export const agentRequestSchema = z.union([ listSessionsRequestSchema, setModeRequestSchema, setModelRequestSchema, + setConfigOptionRequestSchema, ]); export const agentNotificationSchema = sessionNotificationSchema;