Merge pull request #1521 from QwenLM/fix/acp-set-model

fix(acp): implement session/set_model method for JetBrains compatibility
This commit is contained in:
tanzhenxin 2026-01-19 16:07:02 +08:00 committed by GitHub
commit 1145045a5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 299 additions and 26 deletions

View file

@ -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<schema.PromptResponse>;
cancel(params: schema.CancelNotification): Promise<void>;
setMode?(params: schema.SetModeRequest): Promise<schema.SetModeResponse>;
setModel?(params: schema.SetModelRequest): Promise<schema.SetModelResponse>;
}

View file

@ -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<acp.SetModeResponse> {
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<acp.SetModelResponse> {
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<void> {
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,
};
}
}

View file

@ -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<typeof setModelRequestSchema>;
export type SetModelResponse = z.infer<typeof setModelResponseSchema>;
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;

View file

@ -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<typeof vi.fn>;
let getAvailableCommandsSpy: ReturnType<typeof vi.fn>;
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<typeof vi.fn>;
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();
});
});
});

View file

@ -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<SetModelResponse> {
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).