diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index b89292d87..7b989e4c7 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -311,9 +311,9 @@ function setupAcpTest( } }); - it('returns modes on initialize and allows setting approval mode', async () => { + it('returns modes on initialize and allows setting mode and model', async () => { const rig = new TestRig(); - rig.setup('acp approval mode'); + rig.setup('acp mode and model'); const { sendRequest, cleanup, stderr } = setupAcpTest(rig); @@ -366,8 +366,14 @@ function setupAcpTest( const newSession = (await sendRequest('session/new', { cwd: rig.testDir!, mcpServers: [], - })) as { sessionId: string }; + })) as { + sessionId: string; + models: { + availableModels: Array<{ modelId: string }>; + }; + }; expect(newSession.sessionId).toBeTruthy(); + expect(newSession.models.availableModels.length).toBeGreaterThan(0); // Test 4: Set approval mode to 'yolo' const setModeResult = (await sendRequest('session/set_mode', { @@ -392,6 +398,15 @@ function setupAcpTest( })) as { modeId: string }; expect(setModeResult3).toBeDefined(); expect(setModeResult3.modeId).toBe('default'); + + // Test 7: Set model using first available model + const firstModel = newSession.models.availableModels[0]; + const setModelResult = (await sendRequest('session/set_model', { + sessionId: newSession.sessionId, + modelId: firstModel.modelId, + })) as { modelId: string }; + expect(setModelResult).toBeDefined(); + expect(setModelResult.modelId).toBeTruthy(); } catch (e) { if (stderr.length) { console.error('Agent stderr:', stderr.join('')); diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts index 82be72361..9c705c11e 100644 --- a/packages/cli/src/acp-integration/acp.ts +++ b/packages/cli/src/acp-integration/acp.ts @@ -70,6 +70,13 @@ export class AgentSideConnection implements Client { const validatedParams = schema.setModeRequestSchema.parse(params); return agent.setMode(validatedParams); } + case schema.AGENT_METHODS.session_set_model: { + if (!agent.setModel) { + throw RequestError.methodNotFound(); + } + const validatedParams = schema.setModelRequestSchema.parse(params); + return agent.setModel(validatedParams); + } default: throw RequestError.methodNotFound(method); } @@ -408,4 +415,5 @@ export interface Agent { prompt(params: schema.PromptRequest): Promise; cancel(params: schema.CancelNotification): Promise; setMode?(params: schema.SetModeRequest): Promise; + setModel?(params: schema.SetModelRequest): Promise; } diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index d56d196db..373bf67be 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -165,30 +165,11 @@ class GeminiAgent { this.setupFileSystem(config); const session = await this.createAndStoreSession(config); - const configuredModel = ( - config.getModel() || - this.config.getModel() || - '' - ).trim(); - const modelId = configuredModel || 'default'; - const modelName = configuredModel || modelId; + const availableModels = this.buildAvailableModels(config); return { sessionId: session.getId(), - models: { - currentModelId: modelId, - availableModels: [ - { - modelId, - name: modelName, - description: null, - _meta: { - contextLimit: tokenLimit(modelId), - }, - }, - ], - _meta: null, - }, + models: availableModels, }; } @@ -305,15 +286,29 @@ class GeminiAgent { async setMode(params: acp.SetModeRequest): Promise { const session = this.sessions.get(params.sessionId); if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); + throw acp.RequestError.invalidParams( + `Session not found for id: ${params.sessionId}`, + ); } return session.setMode(params); } + async setModel(params: acp.SetModelRequest): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw acp.RequestError.invalidParams( + `Session not found for id: ${params.sessionId}`, + ); + } + return session.setModel(params); + } + private async ensureAuthenticated(config: Config): Promise { const selectedType = this.settings.merged.security?.auth?.selectedType; if (!selectedType) { - throw acp.RequestError.authRequired('No Selected Type'); + throw acp.RequestError.authRequired( + 'Use Qwen Code CLI to authenticate first.', + ); } try { @@ -382,4 +377,43 @@ class GeminiAgent { return session; } + + private buildAvailableModels( + config: Config, + ): acp.NewSessionResponse['models'] { + const currentModelId = ( + config.getModel() || + this.config.getModel() || + '' + ).trim(); + const availableModels = config.getAvailableModels(); + + const mappedAvailableModels = availableModels.map((model) => ({ + modelId: model.id, + name: model.label, + description: model.description ?? null, + _meta: { + contextLimit: tokenLimit(model.id), + }, + })); + + if ( + currentModelId && + !mappedAvailableModels.some((model) => model.modelId === currentModelId) + ) { + mappedAvailableModels.unshift({ + modelId: currentModelId, + name: currentModelId, + description: null, + _meta: { + contextLimit: tokenLimit(currentModelId), + }, + }); + } + + return { + currentModelId, + availableModels: mappedAvailableModels, + }; + } } diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts index 05b5b1908..4278f0dd4 100644 --- a/packages/cli/src/acp-integration/schema.ts +++ b/packages/cli/src/acp-integration/schema.ts @@ -15,6 +15,7 @@ export const AGENT_METHODS = { session_prompt: 'session/prompt', session_list: 'session/list', session_set_mode: 'session/set_mode', + session_set_model: 'session/set_model', }; export const CLIENT_METHODS = { @@ -266,6 +267,18 @@ export const modelInfoSchema = z.object({ name: z.string(), }); +export const setModelRequestSchema = z.object({ + sessionId: z.string(), + modelId: z.string(), +}); + +export const setModelResponseSchema = z.object({ + modelId: z.string(), +}); + +export type SetModelRequest = z.infer; +export type SetModelResponse = z.infer; + export const sessionModelStateSchema = z.object({ _meta: acpMetaSchema, availableModels: z.array(modelInfoSchema), @@ -592,6 +605,7 @@ export const agentResponseSchema = z.union([ promptResponseSchema, listSessionsResponseSchema, setModeResponseSchema, + setModelResponseSchema, ]); export const requestPermissionRequestSchema = z.object({ @@ -624,6 +638,7 @@ export const agentRequestSchema = z.union([ promptRequestSchema, listSessionsRequestSchema, setModeRequestSchema, + setModelRequestSchema, ]); export const agentNotificationSchema = sessionNotificationSchema; diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts new file mode 100644 index 000000000..af98fe25c --- /dev/null +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Session } from './Session.js'; +import type { Config, GeminiChat } from '@qwen-code/qwen-code-core'; +import { ApprovalMode } from '@qwen-code/qwen-code-core'; +import type * as acp from '../acp.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js'; + +vi.mock('../../nonInteractiveCliCommands.js', () => ({ + getAvailableCommands: vi.fn(), + handleSlashCommand: vi.fn(), +})); + +describe('Session', () => { + let mockChat: GeminiChat; + let mockConfig: Config; + let mockClient: acp.Client; + let mockSettings: LoadedSettings; + let session: Session; + let currentModel: string; + let setModelSpy: ReturnType; + let getAvailableCommandsSpy: ReturnType; + + beforeEach(() => { + currentModel = 'qwen3-code-plus'; + setModelSpy = vi.fn().mockImplementation(async (modelId: string) => { + currentModel = modelId; + }); + + mockChat = { + sendMessageStream: vi.fn(), + addHistory: vi.fn(), + } as unknown as GeminiChat; + + mockConfig = { + setApprovalMode: vi.fn(), + setModel: setModelSpy, + getModel: vi.fn().mockImplementation(() => currentModel), + } as unknown as Config; + + mockClient = { + sessionUpdate: vi.fn().mockResolvedValue(undefined), + requestPermission: vi.fn().mockResolvedValue({ + outcome: { outcome: 'selected', optionId: 'proceed_once' }, + }), + sendCustomNotification: vi.fn().mockResolvedValue(undefined), + } as unknown as acp.Client; + + mockSettings = { + merged: {}, + } as LoadedSettings; + + getAvailableCommandsSpy = vi.mocked(nonInteractiveCliCommands) + .getAvailableCommands as unknown as ReturnType; + getAvailableCommandsSpy.mockResolvedValue([]); + + session = new Session( + 'test-session-id', + mockChat, + mockConfig, + mockClient, + mockSettings, + ); + }); + + describe('setMode', () => { + it.each([ + ['plan', ApprovalMode.PLAN], + ['default', ApprovalMode.DEFAULT], + ['auto-edit', ApprovalMode.AUTO_EDIT], + ['yolo', ApprovalMode.YOLO], + ] as const)('maps %s mode', async (modeId, expected) => { + const result = await session.setMode({ + sessionId: 'test-session-id', + modeId, + }); + + expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(expected); + expect(result).toEqual({ modeId }); + }); + }); + + describe('setModel', () => { + it('sets model via config and returns current model', async () => { + const result = await session.setModel({ + sessionId: 'test-session-id', + modelId: ' qwen3-coder-plus ', + }); + + expect(mockConfig.setModel).toHaveBeenCalledWith('qwen3-coder-plus', { + reason: 'user_request_acp', + context: 'session/set_model', + }); + expect(mockConfig.getModel).toHaveBeenCalled(); + expect(result).toEqual({ modelId: 'qwen3-coder-plus' }); + }); + + it('rejects empty/whitespace model IDs', async () => { + await expect( + session.setModel({ + sessionId: 'test-session-id', + modelId: ' ', + }), + ).rejects.toThrow('Invalid params'); + + expect(mockConfig.setModel).not.toHaveBeenCalled(); + }); + + it('propagates errors from config.setModel', async () => { + const configError = new Error('Invalid model'); + setModelSpy.mockRejectedValueOnce(configError); + + await expect( + session.setModel({ + sessionId: 'test-session-id', + modelId: 'invalid-model', + }), + ).rejects.toThrow('Invalid model'); + }); + }); + + describe('sendAvailableCommandsUpdate', () => { + it('sends available_commands_update from getAvailableCommands()', async () => { + getAvailableCommandsSpy.mockResolvedValueOnce([ + { + name: 'init', + description: 'Initialize project context', + }, + ]); + + await session.sendAvailableCommandsUpdate(); + + expect(getAvailableCommandsSpy).toHaveBeenCalledWith( + mockConfig, + expect.any(AbortSignal), + ); + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { + name: 'init', + description: 'Initialize project context', + input: null, + }, + ], + }, + }); + }); + + it('swallows errors and does not throw', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + getAvailableCommandsSpy.mockRejectedValueOnce( + new Error('Command discovery failed'), + ); + + await expect( + session.sendAvailableCommandsUpdate(), + ).resolves.toBeUndefined(); + expect(mockClient.sessionUpdate).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index d83ba8a8d..5348d78df 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -52,6 +52,8 @@ import type { AvailableCommandsUpdate, SetModeRequest, SetModeResponse, + SetModelRequest, + SetModelResponse, ApprovalModeValue, CurrentModeUpdate, } from '../schema.js'; @@ -348,6 +350,31 @@ export class Session implements SessionContext { return { modeId: params.modeId }; } + /** + * Sets the model for the current session. + * Validates the model ID and switches the model via Config. + */ + async setModel(params: SetModelRequest): Promise { + const modelId = params.modelId.trim(); + + if (!modelId) { + throw acp.RequestError.invalidParams('modelId cannot be empty'); + } + + // Attempt to set the model using config + await this.config.setModel(modelId, { + reason: 'user_request_acp', + context: 'session/set_model', + }); + + // Get updated model info + const currentModel = this.config.getModel(); + + return { + modelId: currentModel, + }; + } + /** * Sends a current_mode_update notification to the client. * Called after the agent switches modes (e.g., from exit_plan_mode tool).