Sync upstream Gemini-CLI v0.8.2 (#838)

This commit is contained in:
tanzhenxin
2025-10-23 09:27:04 +08:00
committed by GitHub
parent 096fabb5d6
commit eb95c131be
644 changed files with 70389 additions and 23709 deletions

View File

@@ -10,8 +10,20 @@ import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import * as versionUtils from '../../utils/version.js';
import { MessageType } from '../types.js';
import { IdeClient } from '@qwen-code/qwen-code-core';
import type { IdeClient } from '../../../../core/src/ide/ide-client.js';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
IdeClient: {
getInstance: vi.fn().mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
}),
},
};
});
vi.mock('../../utils/version.js', () => ({
getCliVersion: vi.fn(),
@@ -27,7 +39,6 @@ describe('aboutCommand', () => {
services: {
config: {
getModel: vi.fn(),
getIdeClient: vi.fn(),
getIdeMode: vi.fn().mockReturnValue(true),
},
settings: {
@@ -53,9 +64,6 @@ describe('aboutCommand', () => {
Object.defineProperty(process, 'platform', {
value: 'test-os',
});
vi.spyOn(mockContext.services.config!, 'getIdeClient').mockReturnValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
} as Partial<IdeClient> as IdeClient);
});
afterEach(() => {
@@ -129,9 +137,9 @@ describe('aboutCommand', () => {
});
it('should not show ide client when it is not detected', async () => {
vi.spyOn(mockContext.services.config!, 'getIdeClient').mockReturnValue({
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue(undefined),
} as Partial<IdeClient> as IdeClient);
} as unknown as IdeClient);
process.env['SANDBOX'] = '';
if (!aboutCommand.action) {

View File

@@ -5,10 +5,11 @@
*/
import { getCliVersion } from '../../utils/version.js';
import type { SlashCommand } from './types.js';
import type { CommandContext, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import process from 'node:process';
import { MessageType, type HistoryItemAbout } from '../types.js';
import { IdeClient } from '@qwen-code/qwen-code-core';
export const aboutCommand: SlashCommand = {
name: 'about',
@@ -29,10 +30,7 @@ export const aboutCommand: SlashCommand = {
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
const ideClient =
(context.services.config?.getIdeMode() &&
context.services.config?.getIdeClient()?.getDetectedIdeDisplayName()) ||
'';
const ideClient = await getIdeClientName(context);
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
type: MessageType.ABOUT,
@@ -48,3 +46,11 @@ export const aboutCommand: SlashCommand = {
context.ui.addItem(aboutItem, Date.now());
},
};
async function getIdeClientName(context: CommandContext) {
if (!context.services.config?.getIdeMode()) {
return '';
}
const ideClient = await IdeClient.getInstance();
return ideClient?.getDetectedIdeDisplayName() ?? '';
}

View File

@@ -16,7 +16,19 @@ import { formatMemoryUsage } from '../utils/formatters.js';
vi.mock('open');
vi.mock('../../utils/version.js');
vi.mock('../utils/formatters.js');
vi.mock('@qwen-code/qwen-code-core');
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
IdeClient: {
getInstance: () => ({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'),
}),
},
sessionId: 'test-session-id',
};
});
vi.mock('node:process', () => ({
default: {
platform: 'test-platform',
@@ -31,9 +43,6 @@ describe('bugCommand', () => {
beforeEach(() => {
vi.mocked(getCliVersion).mockResolvedValue('0.1.0');
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
vi.mock('@qwen-code/qwen-code-core', () => ({
sessionId: 'test-session-id',
}));
vi.stubEnv('SANDBOX', 'qwen-test');
});
@@ -48,9 +57,6 @@ describe('bugCommand', () => {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => undefined,
getIdeClient: () => ({
getDetectedIdeDisplayName: () => 'VSCode',
}),
getIdeMode: () => true,
},
},
@@ -84,9 +90,6 @@ describe('bugCommand', () => {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => ({ urlTemplate: customTemplate }),
getIdeClient: () => ({
getDetectedIdeDisplayName: () => 'VSCode',
}),
getIdeMode: () => true,
},
},

View File

@@ -15,7 +15,7 @@ import { MessageType } from '../types.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import { getCliVersion } from '../../utils/version.js';
import { sessionId } from '@qwen-code/qwen-code-core';
import { IdeClient, sessionId } from '@qwen-code/qwen-code-core';
export const bugCommand: SlashCommand = {
name: 'bug',
@@ -37,10 +37,7 @@ export const bugCommand: SlashCommand = {
const modelVersion = config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion();
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
const ideClient =
(context.services.config?.getIdeMode() &&
context.services.config?.getIdeClient()?.getDetectedIdeDisplayName()) ||
'';
const ideClient = await getIdeClientName(context);
let info = `
* **CLI Version:** ${cliVersion}
@@ -90,3 +87,11 @@ export const bugCommand: SlashCommand = {
}
},
};
async function getIdeClientName(context: CommandContext) {
if (!context.services.config?.getIdeMode()) {
return '';
}
const ideClient = await IdeClient.getInstance();
return ideClient.getDetectedIdeDisplayName() ?? '';
}

View File

@@ -17,13 +17,15 @@ import type { Content } from '@google/genai';
import type { GeminiClient } from '@qwen-code/qwen-code-core';
import * as fsPromises from 'node:fs/promises';
import { chatCommand } from './chatCommand.js';
import { chatCommand, serializeHistoryToMarkdown } from './chatCommand.js';
import type { Stats } from 'node:fs';
import type { HistoryItemWithoutId } from '../types.js';
import path from 'node:path';
vi.mock('fs/promises', () => ({
stat: vi.fn(),
readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]),
writeFile: vi.fn(),
}));
describe('chatCommand', () => {
@@ -37,7 +39,7 @@ describe('chatCommand', () => {
let mockGetHistory: ReturnType<typeof vi.fn>;
const getSubCommand = (
name: 'list' | 'save' | 'resume' | 'delete',
name: 'list' | 'save' | 'resume' | 'delete' | 'share',
): SlashCommand => {
const subCommand = chatCommand.subCommands?.find(
(cmd) => cmd.name === name,
@@ -86,7 +88,7 @@ describe('chatCommand', () => {
it('should have the correct main command definition', () => {
expect(chatCommand.name).toBe('chat');
expect(chatCommand.description).toBe('Manage conversation history.');
expect(chatCommand.subCommands).toHaveLength(4);
expect(chatCommand.subCommands).toHaveLength(5);
});
describe('list subcommand', () => {
@@ -407,4 +409,293 @@ describe('chatCommand', () => {
});
});
});
describe('share subcommand', () => {
let shareCommand: SlashCommand;
const mockHistory = [
{ role: 'user', parts: [{ text: 'context' }] },
{ role: 'model', parts: [{ text: 'context response' }] },
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
];
beforeEach(() => {
shareCommand = getSubCommand('share');
vi.spyOn(process, 'cwd').mockReturnValue(
path.resolve('/usr/local/google/home/myuser/gemini-cli'),
);
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
mockGetHistory.mockReturnValue(mockHistory);
mockFs.writeFile.mockClear();
});
it('should default to a json file if no path is provided', async () => {
const result = await shareCommand?.action?.(mockContext, '');
const expectedPath = path.join(
process.cwd(),
'gemini-conversation-1234567890.json',
);
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should share the conversation to a JSON file', async () => {
const filePath = 'my-chat.json';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should share the conversation to a Markdown file', async () => {
const filePath = 'my-chat.md';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const expectedContent = `🧑‍💻 ## USER
context
---
✨ ## MODEL
context response
---
🧑‍💻 ## USER
Hello
---
✨ ## MODEL
Hi there!`;
expect(actualContent).toEqual(expectedContent);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should return an error for unsupported file extensions', async () => {
const filePath = 'my-chat.txt';
const result = await shareCommand?.action?.(mockContext, filePath);
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Invalid file format. Only .md and .json are supported.',
});
});
it('should inform if there is no conversation to share', async () => {
mockGetHistory.mockReturnValue([
{ role: 'user', parts: [{ text: 'context' }] },
{ role: 'model', parts: [{ text: 'context response' }] },
]);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found to share.',
});
});
it('should handle errors during file writing', async () => {
const error = new Error('Permission denied');
mockFs.writeFile.mockRejectedValue(error);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: `Error sharing conversation: ${error.message}`,
});
});
it('should output valid JSON schema', async () => {
const filePath = 'my-chat.json';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const parsedContent = JSON.parse(actualContent);
expect(Array.isArray(parsedContent)).toBe(true);
parsedContent.forEach((item: Content) => {
expect(item).toHaveProperty('role');
expect(item).toHaveProperty('parts');
expect(Array.isArray(item.parts)).toBe(true);
});
});
it('should output correct markdown format', async () => {
const filePath = 'my-chat.md';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const entries = actualContent.split('\n\n---\n\n');
expect(entries.length).toBe(mockHistory.length);
entries.forEach((entry, index) => {
const { role, parts } = mockHistory[index];
const text = parts.map((p) => p.text).join('');
const roleIcon = role === 'user' ? '🧑‍💻' : '✨';
expect(entry).toBe(`${roleIcon} ## ${role.toUpperCase()}\n\n${text}`);
});
});
});
describe('serializeHistoryToMarkdown', () => {
it('should correctly serialize chat history to Markdown with icons', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
{ role: 'user', parts: [{ text: 'How are you?' }] },
];
const expectedMarkdown =
'🧑‍💻 ## USER\n\nHello\n\n---\n\n' +
'✨ ## MODEL\n\nHi there!\n\n---\n\n' +
'🧑‍💻 ## USER\n\nHow are you?';
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should handle empty history', () => {
const history: Content[] = [];
const result = serializeHistoryToMarkdown(history);
expect(result).toBe('');
});
it('should handle items with no text parts', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [] },
{ role: 'user', parts: [{ text: 'How are you?' }] },
];
const expectedMarkdown = `🧑‍💻 ## USER
Hello
---
✨ ## MODEL
---
🧑‍💻 ## USER
How are you?`;
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should correctly serialize function calls and responses', () => {
const history: Content[] = [
{
role: 'user',
parts: [{ text: 'Please call a function.' }],
},
{
role: 'model',
parts: [
{
functionCall: {
name: 'my-function',
args: { arg1: 'value1' },
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
name: 'my-function',
response: { result: 'success' },
},
},
],
},
];
const expectedMarkdown = `🧑‍💻 ## USER
Please call a function.
---
✨ ## MODEL
**Tool Command**:
\`\`\`json
{
"name": "my-function",
"args": {
"arg1": "value1"
}
}
\`\`\`
---
🧑‍💻 ## USER
**Tool Response**:
\`\`\`json
{
"name": "my-function",
"response": {
"result": "success"
}
}
\`\`\``;
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should handle items with undefined role', () => {
const history: Array<Partial<Content>> = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ parts: [{ text: 'Hi there!' }] },
];
const expectedMarkdown = `🧑‍💻 ## USER
Hello
---
✨ ## MODEL
Hi there!`;
const result = serializeHistoryToMarkdown(history as Content[]);
expect(result).toBe(expectedMarkdown);
});
});
});

View File

@@ -7,7 +7,7 @@
import * as fsPromises from 'node:fs/promises';
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import type {
CommandContext,
SlashCommand,
@@ -19,6 +19,7 @@ import { decodeTagName } from '@qwen-code/qwen-code-core';
import path from 'node:path';
import type { HistoryItemWithoutId } from '../types.js';
import { MessageType } from '../types.js';
import type { Content } from '@google/genai';
interface ChatDetail {
name: string;
@@ -126,7 +127,7 @@ const saveCommand: SlashCommand = {
Text,
null,
'A checkpoint with the tag ',
React.createElement(Text, { color: Colors.AccentPurple }, tag),
React.createElement(Text, { color: theme.text.accent }, tag),
' already exists. Do you want to overwrite it?',
),
originalInvocation: {
@@ -274,9 +275,115 @@ const deleteCommand: SlashCommand = {
},
};
export function serializeHistoryToMarkdown(history: Content[]): string {
return history
.map((item) => {
const text =
item.parts
?.map((part) => {
if (part.text) {
return part.text;
}
if (part.functionCall) {
return `**Tool Command**:\n\`\`\`json\n${JSON.stringify(
part.functionCall,
null,
2,
)}\n\`\`\``;
}
if (part.functionResponse) {
return `**Tool Response**:\n\`\`\`json\n${JSON.stringify(
part.functionResponse,
null,
2,
)}\n\`\`\``;
}
return '';
})
.join('') || '';
const roleIcon = item.role === 'user' ? '🧑‍💻' : '✨';
return `${roleIcon} ## ${(item.role || 'model').toUpperCase()}\n\n${text}`;
})
.join('\n\n---\n\n');
}
const shareCommand: SlashCommand = {
name: 'share',
description:
'Share the current conversation to a markdown or json file. Usage: /chat share <file>',
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
let filePathArg = args.trim();
if (!filePathArg) {
filePathArg = `gemini-conversation-${Date.now()}.json`;
}
const filePath = path.resolve(filePathArg);
const extension = path.extname(filePath);
if (extension !== '.md' && extension !== '.json') {
return {
type: 'message',
messageType: 'error',
content: 'Invalid file format. Only .md and .json are supported.',
};
}
const chat = await context.services.config?.getGeminiClient()?.getChat();
if (!chat) {
return {
type: 'message',
messageType: 'error',
content: 'No chat client available to share conversation.',
};
}
const history = chat.getHistory();
// An empty conversation has two hidden messages that setup the context for
// the chat. Thus, to check whether a conversation has been started, we
// can't check for length 0.
if (history.length <= 2) {
return {
type: 'message',
messageType: 'info',
content: 'No conversation found to share.',
};
}
let content = '';
if (extension === '.json') {
content = JSON.stringify(history, null, 2);
} else {
content = serializeHistoryToMarkdown(history);
}
try {
await fsPromises.writeFile(filePath, content);
return {
type: 'message',
messageType: 'info',
content: `Conversation shared to ${filePath}`,
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
return {
type: 'message',
messageType: 'error',
content: `Error sharing conversation: ${errorMessage}`,
};
}
},
};
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Manage conversation history.',
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, saveCommand, resumeCommand, deleteCommand],
subCommands: [
listCommand,
saveCommand,
resumeCommand,
deleteCommand,
shareCommand,
],
};

View File

@@ -16,7 +16,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
return {
...actual,
uiTelemetryService: {
resetLastPromptTokenCount: vi.fn(),
setLastPromptTokenCount: vi.fn(),
},
};
});
@@ -57,9 +57,8 @@ describe('clearCommand', () => {
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
expect(mockResetChat).toHaveBeenCalledTimes(1);
expect(uiTelemetryService.resetLastPromptTokenCount).toHaveBeenCalledTimes(
1,
);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
// Check the order of operations.
@@ -67,7 +66,7 @@ describe('clearCommand', () => {
.invocationCallOrder[0];
const resetChatOrder = mockResetChat.mock.invocationCallOrder[0];
const resetTelemetryOrder = (
uiTelemetryService.resetLastPromptTokenCount as Mock
uiTelemetryService.setLastPromptTokenCount as Mock
).mock.invocationCallOrder[0];
const clearOrder = (mockContext.ui.clear as Mock).mock
.invocationCallOrder[0];
@@ -94,9 +93,8 @@ describe('clearCommand', () => {
'Clearing terminal.',
);
expect(mockResetChat).not.toHaveBeenCalled();
expect(uiTelemetryService.resetLastPromptTokenCount).toHaveBeenCalledTimes(
1,
);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1);
});
});

View File

@@ -24,7 +24,7 @@ export const clearCommand: SlashCommand = {
context.ui.setDebugMessage('Clearing terminal.');
}
uiTelemetryService.resetLastPromptTokenCount();
uiTelemetryService.setLastPromptTokenCount(0);
context.ui.clear();
},
};

View File

@@ -9,6 +9,7 @@ import { CommandKind, type SlashCommand } from './types.js';
export const corgiCommand: SlashCommand = {
name: 'corgi',
description: 'Toggles corgi mode.',
hidden: true,
kind: CommandKind.BUILT_IN,
action: (context, _args) => {
context.ui.toggleCorgiMode();

View File

@@ -104,6 +104,7 @@ export const directoryCommand: SlashCommand = {
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
config.getFolderTrust(),
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),

View File

@@ -4,64 +4,332 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { extensionsCommand } from './extensionsCommand.js';
import { type CommandContext } from './types.js';
import type { GeminiCLIExtension } from '@qwen-code/qwen-code-core';
import {
updateAllUpdatableExtensions,
updateExtension,
} from '../../config/extensions/update.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { extensionsCommand } from './extensionsCommand.js';
import { type CommandContext } from './types.js';
import {
describe,
it,
expect,
vi,
beforeEach,
type MockedFunction,
} from 'vitest';
import { ExtensionUpdateState } from '../state/extensions.js';
vi.mock('../../config/extensions/update.js', () => ({
updateExtension: vi.fn(),
updateAllUpdatableExtensions: vi.fn(),
checkForAllExtensionUpdates: vi.fn(),
}));
const mockUpdateExtension = updateExtension as MockedFunction<
typeof updateExtension
>;
const mockUpdateAllUpdatableExtensions =
updateAllUpdatableExtensions as MockedFunction<
typeof updateAllUpdatableExtensions
>;
const mockGetExtensions = vi.fn();
describe('extensionsCommand', () => {
let mockContext: CommandContext;
it('should display "No active extensions." when none are found', async () => {
beforeEach(() => {
vi.resetAllMocks();
mockContext = createMockCommandContext({
services: {
config: {
getExtensions: () => [],
getExtensions: mockGetExtensions,
getWorkingDir: () => '/test/dir',
},
},
});
if (!extensionsCommand.action) throw new Error('Action not defined');
await extensionsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'No active extensions.',
ui: {
dispatchExtensionStateUpdate: vi.fn(),
},
expect.any(Number),
);
});
});
it('should list active extensions when they are found', async () => {
const mockExtensions = [
{ name: 'ext-one', version: '1.0.0', isActive: true },
{ name: 'ext-two', version: '2.1.0', isActive: true },
{ name: 'ext-three', version: '3.0.0', isActive: false },
];
mockContext = createMockCommandContext({
services: {
config: {
getExtensions: () => mockExtensions,
describe('list', () => {
it('should add an EXTENSIONS_LIST item to the UI', async () => {
if (!extensionsCommand.action) throw new Error('Action not defined');
await extensionsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.EXTENSIONS_LIST,
},
},
expect.any(Number),
);
});
});
describe('update', () => {
const updateAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'update',
)?.action;
if (!updateAction) {
throw new Error('Update action not found');
}
it('should show usage if no args are provided', async () => {
await updateAction(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions update <extension-names>|--all',
},
expect.any(Number),
);
});
if (!extensionsCommand.action) throw new Error('Action not defined');
await extensionsCommand.action(mockContext, '');
it('should inform user if there are no extensions to update with --all', async () => {
mockUpdateAllUpdatableExtensions.mockResolvedValue([]);
await updateAction(mockContext, '--all');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'No extensions to update.',
},
expect.any(Number),
);
});
const expectedMessage =
'Active extensions:\n\n' +
` - \u001b[36mext-one (v1.0.0)\u001b[0m\n` +
` - \u001b[36mext-two (v2.1.0)\u001b[0m\n`;
it('should call setPendingItem and addItem in a finally block on success', async () => {
mockUpdateAllUpdatableExtensions.mockResolvedValue([
{
name: 'ext-one',
originalVersion: '1.0.0',
updatedVersion: '1.0.1',
},
{
name: 'ext-two',
originalVersion: '2.0.0',
updatedVersion: '2.0.1',
},
]);
await updateAction(mockContext, '--all');
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
type: MessageType.EXTENSIONS_LIST,
});
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.EXTENSIONS_LIST,
},
expect.any(Number),
);
});
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: expectedMessage,
},
expect.any(Number),
);
it('should call setPendingItem and addItem in a finally block on failure', async () => {
mockUpdateAllUpdatableExtensions.mockRejectedValue(
new Error('Something went wrong'),
);
await updateAction(mockContext, '--all');
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
type: MessageType.EXTENSIONS_LIST,
});
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.EXTENSIONS_LIST,
},
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Something went wrong',
},
expect.any(Number),
);
});
it('should update a single extension by name', async () => {
const extension: GeminiCLIExtension = {
name: 'ext-one',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
mockUpdateExtension.mockResolvedValue({
name: extension.name,
originalVersion: extension.version,
updatedVersion: '1.0.1',
});
mockGetExtensions.mockReturnValue([extension]);
mockContext.ui.extensionsUpdateState.set(extension.name, {
status: ExtensionUpdateState.UPDATE_AVAILABLE,
processed: false,
});
await updateAction(mockContext, 'ext-one');
expect(mockUpdateExtension).toHaveBeenCalledWith(
extension,
'/test/dir',
expect.any(Function),
ExtensionUpdateState.UPDATE_AVAILABLE,
expect.any(Function),
);
});
it('should handle errors when updating a single extension', async () => {
mockUpdateExtension.mockRejectedValue(new Error('Extension not found'));
mockGetExtensions.mockReturnValue([]);
await updateAction(mockContext, 'ext-one');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Extension ext-one not found.',
},
expect.any(Number),
);
});
it('should update multiple extensions by name', async () => {
const extensionOne: GeminiCLIExtension = {
name: 'ext-one',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
const extensionTwo: GeminiCLIExtension = {
name: 'ext-two',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-two',
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
mockGetExtensions.mockReturnValue([extensionOne, extensionTwo]);
mockContext.ui.extensionsUpdateState.set(
extensionOne.name,
ExtensionUpdateState.UPDATE_AVAILABLE,
);
mockContext.ui.extensionsUpdateState.set(
extensionTwo.name,
ExtensionUpdateState.UPDATE_AVAILABLE,
);
mockUpdateExtension
.mockResolvedValueOnce({
name: 'ext-one',
originalVersion: '1.0.0',
updatedVersion: '1.0.1',
})
.mockResolvedValueOnce({
name: 'ext-two',
originalVersion: '2.0.0',
updatedVersion: '2.0.1',
});
await updateAction(mockContext, 'ext-one ext-two');
expect(mockUpdateExtension).toHaveBeenCalledTimes(2);
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
type: MessageType.EXTENSIONS_LIST,
});
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.EXTENSIONS_LIST,
},
expect.any(Number),
);
});
describe('completion', () => {
const updateCompletion = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'update',
)?.completion;
if (!updateCompletion) {
throw new Error('Update completion not found');
}
const extensionOne: GeminiCLIExtension = {
name: 'ext-one',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
const extensionTwo: GeminiCLIExtension = {
name: 'another-ext',
version: '1.0.0',
isActive: true,
path: '/test/dir/another-ext',
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
const allExt: GeminiCLIExtension = {
name: 'all-ext',
version: '1.0.0',
isActive: true,
path: '/test/dir/all-ext',
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
it.each([
{
description: 'should return matching extension names',
extensions: [extensionOne, extensionTwo],
partialArg: 'ext',
expected: ['ext-one'],
},
{
description: 'should return --all when partialArg matches',
extensions: [],
partialArg: '--al',
expected: ['--all'],
},
{
description:
'should return both extension names and --all when both match',
extensions: [allExt],
partialArg: 'all',
expected: ['--all', 'all-ext'],
},
{
description: 'should return an empty array if no matches',
extensions: [extensionOne],
partialArg: 'nomatch',
expected: [],
},
])('$description', async ({ extensions, partialArg, expected }) => {
mockGetExtensions.mockReturnValue(extensions);
const suggestions = await updateCompletion(mockContext, partialArg);
expect(suggestions).toEqual(expected);
});
});
});
});

View File

@@ -4,43 +4,164 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { requestConsentInteractive } from '../../config/extension.js';
import {
updateAllUpdatableExtensions,
type ExtensionUpdateInfo,
updateExtension,
checkForAllExtensionUpdates,
} from '../../config/extensions/update.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionUpdateState } from '../state/extensions.js';
import { MessageType } from '../types.js';
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import { MessageType } from '../types.js';
export const extensionsCommand: SlashCommand = {
name: 'extensions',
description: 'list active extensions',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext): Promise<void> => {
const activeExtensions = context.services.config
?.getExtensions()
.filter((ext) => ext.isActive);
if (!activeExtensions || activeExtensions.length === 0) {
async function listAction(context: CommandContext) {
context.ui.addItem(
{
type: MessageType.EXTENSIONS_LIST,
},
Date.now(),
);
}
async function updateAction(context: CommandContext, args: string) {
const updateArgs = args.split(' ').filter((value) => value.length > 0);
const all = updateArgs.length === 1 && updateArgs[0] === '--all';
const names = all ? undefined : updateArgs;
let updateInfos: ExtensionUpdateInfo[] = [];
if (!all && names?.length === 0) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Usage: /extensions update <extension-names>|--all',
},
Date.now(),
);
return;
}
try {
await checkForAllExtensionUpdates(
context.services.config!.getExtensions(),
context.ui.dispatchExtensionStateUpdate,
);
context.ui.setPendingItem({
type: MessageType.EXTENSIONS_LIST,
});
if (all) {
updateInfos = await updateAllUpdatableExtensions(
context.services.config!.getWorkingDir(),
// We don't have the ability to prompt for consent yet in this flow.
(description) =>
requestConsentInteractive(
description,
context.ui.addConfirmUpdateExtensionRequest,
),
context.services.config!.getExtensions(),
context.ui.extensionsUpdateState,
context.ui.dispatchExtensionStateUpdate,
);
} else if (names?.length) {
const workingDir = context.services.config!.getWorkingDir();
const extensions = context.services.config!.getExtensions();
for (const name of names) {
const extension = extensions.find(
(extension) => extension.name === name,
);
if (!extension) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Extension ${name} not found.`,
},
Date.now(),
);
continue;
}
const updateInfo = await updateExtension(
extension,
workingDir,
(description) =>
requestConsentInteractive(
description,
context.ui.addConfirmUpdateExtensionRequest,
),
context.ui.extensionsUpdateState.get(extension.name)?.status ??
ExtensionUpdateState.UNKNOWN,
context.ui.dispatchExtensionStateUpdate,
);
if (updateInfo) updateInfos.push(updateInfo);
}
}
if (updateInfos.length === 0) {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'No active extensions.',
text: 'No extensions to update.',
},
Date.now(),
);
return;
}
const extensionLines = activeExtensions.map(
(ext) => ` - \u001b[36m${ext.name} (v${ext.version})\u001b[0m`,
);
const message = `Active extensions:\n\n${extensionLines.join('\n')}\n`;
} catch (error) {
context.ui.addItem(
{
type: MessageType.INFO,
text: message,
type: MessageType.ERROR,
text: getErrorMessage(error),
},
Date.now(),
);
} finally {
context.ui.addItem(
{
type: MessageType.EXTENSIONS_LIST,
},
Date.now(),
);
context.ui.setPendingItem(null);
}
}
const listExtensionsCommand: SlashCommand = {
name: 'list',
description: 'List active extensions',
kind: CommandKind.BUILT_IN,
action: listAction,
};
const updateExtensionsCommand: SlashCommand = {
name: 'update',
description: 'Update extensions. Usage: update <extension-names>|--all',
kind: CommandKind.BUILT_IN,
action: updateAction,
completion: async (context, partialArg) => {
const extensions = context.services.config?.getExtensions() ?? [];
const extensionNames = extensions.map((ext) => ext.name);
const suggestions = extensionNames.filter((name) =>
name.startsWith(partialArg),
);
if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) {
suggestions.unshift('--all');
}
return suggestions;
},
};
export const extensionsCommand: SlashCommand = {
name: 'extensions',
description: 'Manage extensions',
kind: CommandKind.BUILT_IN,
subCommands: [listExtensionsCommand, updateExtensionsCommand],
action: (context, args) =>
// Default to list if no subcommand is provided
listExtensionsCommand.action!(context, args),
};

View File

@@ -8,26 +8,43 @@ import type { MockInstance } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ideCommand } from './ideCommand.js';
import { type CommandContext } from './types.js';
import { type Config, DetectedIde } from '@qwen-code/qwen-code-core';
import { IDE_DEFINITIONS } from '@qwen-code/qwen-code-core';
import * as core from '@qwen-code/qwen-code-core';
vi.mock('child_process');
vi.mock('glob');
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original = await importOriginal<typeof core>();
return {
...original,
getOauthClient: vi.fn(original.getOauthClient),
getIdeInstaller: vi.fn(original.getIdeInstaller),
IdeClient: {
getInstance: vi.fn(),
},
};
});
describe('ideCommand', () => {
let mockContext: CommandContext;
let mockConfig: Config;
let mockIdeClient: core.IdeClient;
let platformSpy: MockInstance;
beforeEach(() => {
vi.resetAllMocks();
mockIdeClient = {
reconnect: vi.fn(),
disconnect: vi.fn(),
connect: vi.fn(),
getCurrentIde: vi.fn(),
getConnectionStatus: vi.fn(),
getDetectedIdeDisplayName: vi.fn(),
} as unknown as core.IdeClient;
vi.mocked(core.IdeClient.getInstance).mockResolvedValue(mockIdeClient);
vi.mocked(mockIdeClient.getDetectedIdeDisplayName).mockReturnValue(
'VS Code',
);
mockContext = {
ui: {
addItem: vi.fn(),
@@ -36,22 +53,14 @@ describe('ideCommand', () => {
settings: {
setValue: vi.fn(),
},
config: {
getIdeMode: vi.fn(),
setIdeMode: vi.fn(),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
},
},
} as unknown as CommandContext;
mockConfig = {
getIdeMode: vi.fn(),
getIdeClient: vi.fn(() => ({
reconnect: vi.fn(),
disconnect: vi.fn(),
getCurrentIde: vi.fn(),
getDetectedIdeDisplayName: vi.fn(),
getConnectionStatus: vi.fn(),
})),
setIdeModeAndSyncConnection: vi.fn(),
setIdeMode: vi.fn(),
} as unknown as Config;
platformSpy = vi.spyOn(process, 'platform', 'get');
});
@@ -59,64 +68,52 @@ describe('ideCommand', () => {
vi.restoreAllMocks();
});
it('should return null if config is not provided', () => {
const command = ideCommand(null);
expect(command).toBeNull();
it('should return the ide command', async () => {
vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
IDE_DEFINITIONS.vscode,
);
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Disconnected,
});
const command = await ideCommand();
expect(command).not.toBeNull();
expect(command.name).toBe('ide');
expect(command.subCommands).toHaveLength(3);
expect(command.subCommands?.[0].name).toBe('enable');
expect(command.subCommands?.[1].name).toBe('status');
expect(command.subCommands?.[2].name).toBe('install');
});
it('should return the ide command', () => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getCurrentIde: () => DetectedIde.VSCode,
getDetectedIdeDisplayName: () => 'VS Code',
getConnectionStatus: () => ({
status: core.IDEConnectionStatus.Disconnected,
}),
} as ReturnType<Config['getIdeClient']>);
const command = ideCommand(mockConfig);
it('should show disable command when connected', async () => {
vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
IDE_DEFINITIONS.vscode,
);
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Connected,
});
const command = await ideCommand();
expect(command).not.toBeNull();
expect(command?.name).toBe('ide');
expect(command?.subCommands).toHaveLength(3);
expect(command?.subCommands?.[0].name).toBe('enable');
expect(command?.subCommands?.[1].name).toBe('status');
expect(command?.subCommands?.[2].name).toBe('install');
});
it('should show disable command when connected', () => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getCurrentIde: () => DetectedIde.VSCode,
getDetectedIdeDisplayName: () => 'VS Code',
getConnectionStatus: () => ({
status: core.IDEConnectionStatus.Connected,
}),
} as ReturnType<Config['getIdeClient']>);
const command = ideCommand(mockConfig);
expect(command).not.toBeNull();
const subCommandNames = command?.subCommands?.map((cmd) => cmd.name);
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
expect(subCommandNames).toContain('disable');
expect(subCommandNames).not.toContain('enable');
});
describe('status subcommand', () => {
const mockGetConnectionStatus = vi.fn();
beforeEach(() => {
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getConnectionStatus: mockGetConnectionStatus,
getCurrentIde: () => DetectedIde.VSCode,
getDetectedIdeDisplayName: () => 'VS Code',
} as unknown as ReturnType<Config['getIdeClient']>);
vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
IDE_DEFINITIONS.vscode,
);
});
it('should show connected status', async () => {
mockGetConnectionStatus.mockReturnValue({
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Connected,
});
const command = ideCommand(mockConfig);
const command = await ideCommand();
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -125,14 +122,14 @@ describe('ideCommand', () => {
});
it('should show connecting status', async () => {
mockGetConnectionStatus.mockReturnValue({
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Connecting,
});
const command = ideCommand(mockConfig);
const command = await ideCommand();
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -140,14 +137,14 @@ describe('ideCommand', () => {
});
});
it('should show disconnected status', async () => {
mockGetConnectionStatus.mockReturnValue({
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Disconnected,
});
const command = ideCommand(mockConfig);
const command = await ideCommand();
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
@@ -157,15 +154,15 @@ describe('ideCommand', () => {
it('should show disconnected status with details', async () => {
const details = 'Something went wrong';
mockGetConnectionStatus.mockReturnValue({
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Disconnected,
details,
});
const command = ideCommand(mockConfig);
const command = await ideCommand();
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
@@ -177,34 +174,39 @@ describe('ideCommand', () => {
describe('install subcommand', () => {
const mockInstall = vi.fn();
beforeEach(() => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getCurrentIde: () => DetectedIde.VSCode,
getConnectionStatus: () => ({
status: core.IDEConnectionStatus.Disconnected,
}),
getDetectedIdeDisplayName: () => 'VS Code',
} as unknown as ReturnType<Config['getIdeClient']>);
vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
IDE_DEFINITIONS.vscode,
);
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Disconnected,
});
vi.mocked(core.getIdeInstaller).mockReturnValue({
install: mockInstall,
isInstalled: vi.fn(),
});
platformSpy.mockReturnValue('linux');
});
it('should install the extension', async () => {
vi.useFakeTimers();
mockInstall.mockResolvedValue({
success: true,
message: 'Successfully installed.',
});
const command = ideCommand(mockConfig);
await command!.subCommands!.find((c) => c.name === 'install')!.action!(
mockContext,
'',
);
const command = await ideCommand();
expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode');
// For the polling loop inside the action.
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Connected,
});
const actionPromise = command!.subCommands!.find(
(c) => c.name === 'install',
)!.action!(mockContext, '');
await vi.runAllTimersAsync();
await actionPromise;
expect(core.getIdeInstaller).toHaveBeenCalledWith(IDE_DEFINITIONS.vscode);
expect(mockInstall).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
@@ -220,6 +222,14 @@ describe('ideCommand', () => {
}),
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: '🟢 Connected to VS Code',
}),
expect.any(Number),
);
vi.useRealTimers();
}, 10000);
it('should show an error if installation fails', async () => {
@@ -228,13 +238,13 @@ describe('ideCommand', () => {
message: 'Installation failed.',
});
const command = ideCommand(mockConfig);
const command = await ideCommand();
await command!.subCommands!.find((c) => c.name === 'install')!.action!(
mockContext,
'',
);
expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode');
expect(core.getIdeInstaller).toHaveBeenCalledWith(IDE_DEFINITIONS.vscode);
expect(mockInstall).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -4,12 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, IdeClient, File } from '@qwen-code/qwen-code-core';
import {
type Config,
IdeClient,
type File,
logIdeConnection,
IdeConnectionEvent,
IdeConnectionType,
} from '@qwen-code/qwen-code-core';
import {
QWEN_CODE_COMPANION_EXTENSION_NAME,
getIdeInstaller,
IDEConnectionStatus,
ideContext,
ideContextStore,
} from '@qwen-code/qwen-code-core';
import path from 'node:path';
import type {
@@ -83,7 +90,7 @@ async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
switch (connection.status) {
case IDEConnectionStatus.Connected: {
let content = `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`;
const context = ideContext.getIdeContext();
const context = ideContextStore.get();
const openFiles = context?.workspaceState?.openFiles;
if (openFiles && openFiles.length > 0) {
content += formatFileList(openFiles);
@@ -111,13 +118,24 @@ async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
}
}
export const ideCommand = (config: Config | null): SlashCommand | null => {
if (!config) {
return null;
async function setIdeModeAndSyncConnection(
config: Config,
value: boolean,
): Promise<void> {
config.setIdeMode(value);
const ideClient = await IdeClient.getInstance();
if (value) {
await ideClient.connect();
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.SESSION));
} else {
await ideClient.disconnect();
}
const ideClient = config.getIdeClient();
}
export const ideCommand = async (): Promise<SlashCommand> => {
const ideClient = await IdeClient.getInstance();
const currentIDE = ideClient.getCurrentIde();
if (!currentIDE || !ideClient.getDetectedIdeDisplayName()) {
if (!currentIDE) {
return {
name: 'ide',
description: 'manage IDE integration',
@@ -194,7 +212,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
);
// Poll for up to 5 seconds for the extension to activate.
for (let i = 0; i < 10; i++) {
await config.setIdeModeAndSyncConnection(true);
await setIdeModeAndSyncConnection(context.services.config!, true);
if (
ideClient.getConnectionStatus().status ===
IDEConnectionStatus.Connected
@@ -236,7 +254,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
'ide.enabled',
true,
);
await config.setIdeModeAndSyncConnection(true);
await setIdeModeAndSyncConnection(context.services.config!, true);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(
{
@@ -258,7 +276,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
'ide.enabled',
false,
);
await config.setIdeModeAndSyncConnection(false);
await setIdeModeAndSyncConnection(context.services.config!, false);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(
{

View File

@@ -15,34 +15,28 @@ import {
DiscoveredMCPTool,
} from '@qwen-code/qwen-code-core';
import type { MessageActionReturn } from './types.js';
import type { CallableTool } from '@google/genai';
import { Type } from '@google/genai';
import { MessageType } from '../types.js';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
const mockAuthenticate = vi.fn();
return {
...actual,
getMCPServerStatus: vi.fn(),
getMCPDiscoveryState: vi.fn(),
MCPOAuthProvider: {
authenticate: vi.fn(),
},
MCPOAuthTokenStorage: {
MCPOAuthProvider: vi.fn(() => ({
authenticate: mockAuthenticate,
})),
MCPOAuthTokenStorage: vi.fn(() => ({
getToken: vi.fn(),
isTokenExpired: vi.fn(),
},
})),
};
});
// Helper function to check if result is a message action
const isMessageAction = (result: unknown): result is MessageActionReturn =>
result !== null &&
typeof result === 'object' &&
'type' in result &&
result.type === 'message';
// Helper function to create a mock DiscoveredMCPTool
const createMockMCPTool = (
name: string,
@@ -58,7 +52,6 @@ const createMockMCPTool = (
name,
description || `Description for ${name}`,
{ type: Type.OBJECT, properties: {} },
name, // serverToolName same as name for simplicity
);
describe('mcpCommand', () => {
@@ -68,6 +61,7 @@ describe('mcpCommand', () => {
getMcpServers: ReturnType<typeof vi.fn>;
getBlockedMcpServers: ReturnType<typeof vi.fn>;
getPromptRegistry: ReturnType<typeof vi.fn>;
getGeminiClient: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
@@ -93,6 +87,7 @@ describe('mcpCommand', () => {
getAllPrompts: vi.fn().mockReturnValue([]),
getPromptsByServer: vi.fn().mockReturnValue([]),
}),
getGeminiClient: vi.fn(),
};
mockContext = createMockCommandContext({
@@ -132,26 +127,6 @@ describe('mcpCommand', () => {
});
});
describe('no MCP servers configured', () => {
beforeEach(() => {
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue([]),
});
mockConfig.getMcpServers = vi.fn().mockReturnValue({});
});
it('should display a message with a URL when no MCP servers are configured', async () => {
const result = await mcpCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'No MCP servers configured. Please view MCP documentation in your browser: https://qwenlm.github.io/qwen-code-docs/en/tools/mcp-server/#how-to-set-up-your-mcp-server or use the cli /docs command',
});
});
});
describe('with configured MCP servers', () => {
beforeEach(() => {
const mockMcpServers = {
@@ -189,870 +164,47 @@ describe('mcpCommand', () => {
getAllTools: vi.fn().mockReturnValue(allTools),
});
const result = await mcpCommand.action!(mockContext, '');
await mcpCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Configured MCP servers:'),
});
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
// Server 1 - Connected
expect(message).toContain(
'🟢 \u001b[1mserver1\u001b[0m - Ready (2 tools)',
);
expect(message).toContain('server1_tool1');
expect(message).toContain('server1_tool2');
// Server 2 - Connected
expect(message).toContain(
'🟢 \u001b[1mserver2\u001b[0m - Ready (1 tool)',
);
expect(message).toContain('server2_tool1');
// Server 3 - Disconnected but with cached tools, so shows as Ready
expect(message).toContain(
'🟢 \u001b[1mserver3\u001b[0m - Ready (1 tool)',
);
expect(message).toContain('server3_tool1');
// Check that helpful tips are displayed when no arguments are provided
expect(message).toContain('💡 Tips:');
expect(message).toContain('/mcp desc');
expect(message).toContain('/mcp schema');
expect(message).toContain('/mcp nodesc');
expect(message).toContain('Ctrl+T');
}
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
tools: allTools.map((tool) => ({
serverName: tool.serverName,
name: tool.name,
description: tool.description,
schema: tool.schema,
})),
showTips: true,
}),
expect.any(Number),
);
});
it('should display tool descriptions when desc argument is used', async () => {
const mockMcpServers = {
server1: {
command: 'cmd1',
description: 'This is a server description',
},
};
await mcpCommand.action!(mockContext, 'desc');
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
// Mock tools with descriptions using actual DiscoveredMCPTool instances
const mockServerTools = [
createMockMCPTool('tool1', 'server1', 'This is tool 1 description'),
createMockMCPTool('tool2', 'server1', 'This is tool 2 description'),
];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(mockServerTools),
});
const result = await mcpCommand.action!(mockContext, 'desc');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Configured MCP servers:'),
});
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
// Check that server description is included
expect(message).toContain(
'\u001b[1mserver1\u001b[0m - Ready (2 tools)',
);
expect(message).toContain(
'\u001b[32mThis is a server description\u001b[0m',
);
// Check that tool descriptions are included
expect(message).toContain('\u001b[36mtool1\u001b[0m');
expect(message).toContain(
'\u001b[32mThis is tool 1 description\u001b[0m',
);
expect(message).toContain('\u001b[36mtool2\u001b[0m');
expect(message).toContain(
'\u001b[32mThis is tool 2 description\u001b[0m',
);
// Check that tips are NOT displayed when arguments are provided
expect(message).not.toContain('💡 Tips:');
}
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
showDescriptions: true,
showTips: false,
}),
expect.any(Number),
);
});
it('should not display descriptions when nodesc argument is used', async () => {
const mockMcpServers = {
server1: {
command: 'cmd1',
description: 'This is a server description',
},
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
const mockServerTools = [
createMockMCPTool('tool1', 'server1', 'This is tool 1 description'),
];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(mockServerTools),
});
const result = await mcpCommand.action!(mockContext, 'nodesc');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Configured MCP servers:'),
});
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
// Check that descriptions are not included
expect(message).not.toContain('This is a server description');
expect(message).not.toContain('This is tool 1 description');
expect(message).toContain('\u001b[36mtool1\u001b[0m');
// Check that tips are NOT displayed when arguments are provided
expect(message).not.toContain('💡 Tips:');
}
});
it('should indicate when a server has no tools', async () => {
const mockMcpServers = {
server1: { command: 'cmd1' },
server2: { command: 'cmd2' },
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
// Setup server statuses
vi.mocked(getMCPServerStatus).mockImplementation((serverName) => {
if (serverName === 'server1') return MCPServerStatus.CONNECTED;
if (serverName === 'server2') return MCPServerStatus.DISCONNECTED;
return MCPServerStatus.DISCONNECTED;
});
// Mock tools - only server1 has tools
const mockServerTools = [createMockMCPTool('server1_tool1', 'server1')];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(mockServerTools),
});
const result = await mcpCommand.action!(mockContext, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain(
'🟢 \u001b[1mserver1\u001b[0m - Ready (1 tool)',
);
expect(message).toContain('\u001b[36mserver1_tool1\u001b[0m');
expect(message).toContain(
'🔴 \u001b[1mserver2\u001b[0m - Disconnected (0 tools cached)',
);
expect(message).toContain('No tools or prompts available');
}
});
it('should show startup indicator when servers are connecting', async () => {
const mockMcpServers = {
server1: { command: 'cmd1' },
server2: { command: 'cmd2' },
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
// Setup server statuses with one connecting
vi.mocked(getMCPServerStatus).mockImplementation((serverName) => {
if (serverName === 'server1') return MCPServerStatus.CONNECTED;
if (serverName === 'server2') return MCPServerStatus.CONNECTING;
return MCPServerStatus.DISCONNECTED;
});
// Setup discovery state as in progress
vi.mocked(getMCPDiscoveryState).mockReturnValue(
MCPDiscoveryState.IN_PROGRESS,
);
// Mock tools
const mockServerTools = [
createMockMCPTool('server1_tool1', 'server1'),
createMockMCPTool('server2_tool1', 'server2'),
];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(mockServerTools),
});
const result = await mcpCommand.action!(mockContext, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
// Check that startup indicator is shown
expect(message).toContain(
'⏳ MCP servers are starting up (1 initializing)...',
);
expect(message).toContain(
'Note: First startup may take longer. Tool availability will update automatically.',
);
// Check server statuses
expect(message).toContain(
'🟢 \u001b[1mserver1\u001b[0m - Ready (1 tool)',
);
expect(message).toContain(
'🔄 \u001b[1mserver2\u001b[0m - Starting... (first startup may take longer) (tools and prompts will appear when ready)',
);
}
});
it('should display the extension name for servers from extensions', async () => {
const mockMcpServers = {
server1: { command: 'cmd1', extensionName: 'my-extension' },
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
const result = await mcpCommand.action!(mockContext, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('server1 (from my-extension)');
}
});
it('should display blocked MCP servers', async () => {
mockConfig.getMcpServers = vi.fn().mockReturnValue({});
const blockedServers = [
{ name: 'blocked-server', extensionName: 'my-extension' },
];
mockConfig.getBlockedMcpServers = vi.fn().mockReturnValue(blockedServers);
const result = await mcpCommand.action!(mockContext, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain(
'🔴 \u001b[1mblocked-server (from my-extension)\u001b[0m - Blocked',
);
}
});
it('should display both active and blocked servers correctly', async () => {
const mockMcpServers = {
server1: { command: 'cmd1', extensionName: 'my-extension' },
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
const blockedServers = [
{ name: 'blocked-server', extensionName: 'another-extension' },
];
mockConfig.getBlockedMcpServers = vi.fn().mockReturnValue(blockedServers);
const result = await mcpCommand.action!(mockContext, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('server1 (from my-extension)');
expect(message).toContain(
'🔴 \u001b[1mblocked-server (from another-extension)\u001b[0m - Blocked',
);
}
});
});
describe('schema functionality', () => {
it('should display tool schemas when schema argument is used', async () => {
const mockMcpServers = {
server1: {
command: 'cmd1',
description: 'This is a server description',
},
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
// Create tools with parameter schemas
const mockCallableTool1: CallableTool = {
callTool: vi.fn(),
tool: vi.fn(),
} as unknown as CallableTool;
const mockCallableTool2: CallableTool = {
callTool: vi.fn(),
tool: vi.fn(),
} as unknown as CallableTool;
const tool1 = new DiscoveredMCPTool(
mockCallableTool1,
'server1',
'tool1',
'This is tool 1 description',
{
type: Type.OBJECT,
properties: {
param1: { type: Type.STRING, description: 'First parameter' },
},
required: ['param1'],
},
'tool1',
);
const tool2 = new DiscoveredMCPTool(
mockCallableTool2,
'server1',
'tool2',
'This is tool 2 description',
{
type: Type.OBJECT,
properties: {
param2: { type: Type.NUMBER, description: 'Second parameter' },
},
required: ['param2'],
},
'tool2',
);
const mockServerTools = [tool1, tool2];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(mockServerTools),
});
const result = await mcpCommand.action!(mockContext, 'schema');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Configured MCP servers:'),
});
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
// Check that server description is included
expect(message).toContain('Ready (2 tools)');
expect(message).toContain('This is a server description');
// Check that tool descriptions and schemas are included
expect(message).toContain('This is tool 1 description');
expect(message).toContain('Parameters:');
expect(message).toContain('param1');
expect(message).toContain('STRING');
expect(message).toContain('This is tool 2 description');
expect(message).toContain('param2');
expect(message).toContain('NUMBER');
}
});
it('should handle tools without parameter schemas gracefully', async () => {
const mockMcpServers = {
server1: { command: 'cmd1' },
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
// Mock tools without parameter schemas
const mockServerTools = [
createMockMCPTool('tool1', 'server1', 'Tool without schema'),
];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(mockServerTools),
});
const result = await mcpCommand.action!(mockContext, 'schema');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Configured MCP servers:'),
});
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('tool1');
expect(message).toContain('Tool without schema');
// Should not crash when parameterSchema is undefined
}
});
});
describe('argument parsing', () => {
beforeEach(() => {
const mockMcpServers = {
server1: {
command: 'cmd1',
description: 'Server description',
},
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
const mockServerTools = [
createMockMCPTool('tool1', 'server1', 'Test tool'),
];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(mockServerTools),
});
});
it('should handle "descriptions" as alias for "desc"', async () => {
const result = await mcpCommand.action!(mockContext, 'descriptions');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('Test tool');
expect(message).toContain('Server description');
}
});
it('should handle "nodescriptions" as alias for "nodesc"', async () => {
const result = await mcpCommand.action!(mockContext, 'nodescriptions');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).not.toContain('Test tool');
expect(message).not.toContain('Server description');
expect(message).toContain('\u001b[36mtool1\u001b[0m');
}
});
it('should handle mixed case arguments', async () => {
const result = await mcpCommand.action!(mockContext, 'DESC');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('Test tool');
expect(message).toContain('Server description');
}
});
it('should handle multiple arguments - "schema desc"', async () => {
const result = await mcpCommand.action!(mockContext, 'schema desc');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('Test tool');
expect(message).toContain('Server description');
expect(message).toContain('Parameters:');
}
});
it('should handle multiple arguments - "desc schema"', async () => {
const result = await mcpCommand.action!(mockContext, 'desc schema');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('Test tool');
expect(message).toContain('Server description');
expect(message).toContain('Parameters:');
}
});
it('should handle "schema" alone showing descriptions', async () => {
const result = await mcpCommand.action!(mockContext, 'schema');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('Test tool');
expect(message).toContain('Server description');
expect(message).toContain('Parameters:');
}
});
it('should handle "nodesc" overriding "schema" - "schema nodesc"', async () => {
const result = await mcpCommand.action!(mockContext, 'schema nodesc');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).not.toContain('Test tool');
expect(message).not.toContain('Server description');
expect(message).toContain('Parameters:'); // Schema should still show
expect(message).toContain('\u001b[36mtool1\u001b[0m');
}
});
it('should handle "nodesc" overriding "desc" - "desc nodesc"', async () => {
const result = await mcpCommand.action!(mockContext, 'desc nodesc');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).not.toContain('Test tool');
expect(message).not.toContain('Server description');
expect(message).not.toContain('Parameters:');
expect(message).toContain('\u001b[36mtool1\u001b[0m');
}
});
it('should handle "nodesc" overriding both "desc" and "schema" - "desc schema nodesc"', async () => {
const result = await mcpCommand.action!(
mockContext,
'desc schema nodesc',
);
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).not.toContain('Test tool');
expect(message).not.toContain('Server description');
expect(message).toContain('Parameters:'); // Schema should still show
expect(message).toContain('\u001b[36mtool1\u001b[0m');
}
});
it('should handle extra whitespace in arguments', async () => {
const result = await mcpCommand.action!(mockContext, ' desc schema ');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('Test tool');
expect(message).toContain('Server description');
expect(message).toContain('Parameters:');
}
});
it('should handle empty arguments gracefully', async () => {
const result = await mcpCommand.action!(mockContext, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).not.toContain('Test tool');
expect(message).not.toContain('Server description');
expect(message).not.toContain('Parameters:');
expect(message).toContain('\u001b[36mtool1\u001b[0m');
}
});
it('should handle unknown arguments gracefully', async () => {
const result = await mcpCommand.action!(mockContext, 'unknown arg');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).not.toContain('Test tool');
expect(message).not.toContain('Server description');
expect(message).not.toContain('Parameters:');
expect(message).toContain('\u001b[36mtool1\u001b[0m');
}
});
});
describe('edge cases', () => {
it('should handle empty server names gracefully', async () => {
const mockMcpServers = {
'': { command: 'cmd1' }, // Empty server name
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue([]),
});
const result = await mcpCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Configured MCP servers:'),
});
});
it('should handle servers with special characters in names', async () => {
const mockMcpServers = {
'server-with-dashes': { command: 'cmd1' },
server_with_underscores: { command: 'cmd2' },
'server.with.dots': { command: 'cmd3' },
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue([]),
});
const result = await mcpCommand.action!(mockContext, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('server-with-dashes');
expect(message).toContain('server_with_underscores');
expect(message).toContain('server.with.dots');
}
});
});
describe('auth subcommand', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should list OAuth-enabled servers when no server name is provided', async () => {
const context = createMockCommandContext({
services: {
config: {
getMcpServers: vi.fn().mockReturnValue({
'oauth-server': { oauth: { enabled: true } },
'regular-server': {},
'another-oauth': { oauth: { enabled: true } },
}),
},
},
});
const authCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'auth',
);
expect(authCommand).toBeDefined();
const result = await authCommand!.action!(context, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
expect(result.messageType).toBe('info');
expect(result.content).toContain('oauth-server');
expect(result.content).toContain('another-oauth');
expect(result.content).not.toContain('regular-server');
expect(result.content).toContain('/mcp auth <server-name>');
}
});
it('should show message when no OAuth servers are configured', async () => {
const context = createMockCommandContext({
services: {
config: {
getMcpServers: vi.fn().mockReturnValue({
'regular-server': {},
}),
},
},
});
const authCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'auth',
);
const result = await authCommand!.action!(context, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
expect(result.messageType).toBe('info');
expect(result.content).toBe(
'No MCP servers configured with OAuth authentication.',
);
}
});
it('should authenticate with a specific server', async () => {
const mockToolRegistry = {
discoverToolsForServer: vi.fn(),
};
const mockGeminiClient = {
setTools: vi.fn(),
};
const context = createMockCommandContext({
services: {
config: {
getMcpServers: vi.fn().mockReturnValue({
'test-server': {
url: 'http://localhost:3000',
oauth: { enabled: true },
},
}),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
getPromptRegistry: vi.fn().mockResolvedValue({
removePromptsByServer: vi.fn(),
}),
},
},
});
// Mock the reloadCommands function
context.ui.reloadCommands = vi.fn();
const { MCPOAuthProvider } = await import('@qwen-code/qwen-code-core');
const authCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'auth',
);
const result = await authCommand!.action!(context, 'test-server');
expect(MCPOAuthProvider.authenticate).toHaveBeenCalledWith(
'test-server',
{ enabled: true },
'http://localhost:3000',
);
expect(mockToolRegistry.discoverToolsForServer).toHaveBeenCalledWith(
'test-server',
);
expect(mockGeminiClient.setTools).toHaveBeenCalled();
expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1);
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
expect(result.messageType).toBe('info');
expect(result.content).toContain('Successfully authenticated');
}
});
it('should handle authentication errors', async () => {
const context = createMockCommandContext({
services: {
config: {
getMcpServers: vi.fn().mockReturnValue({
'test-server': { oauth: { enabled: true } },
}),
},
},
});
const { MCPOAuthProvider } = await import('@qwen-code/qwen-code-core');
(
MCPOAuthProvider.authenticate as ReturnType<typeof vi.fn>
).mockRejectedValue(new Error('Auth failed'));
const authCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'auth',
);
const result = await authCommand!.action!(context, 'test-server');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
expect(result.messageType).toBe('error');
expect(result.content).toContain('Failed to authenticate');
expect(result.content).toContain('Auth failed');
}
});
it('should handle non-existent server', async () => {
const context = createMockCommandContext({
services: {
config: {
getMcpServers: vi.fn().mockReturnValue({
'existing-server': {},
}),
},
},
});
const authCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'auth',
);
const result = await authCommand!.action!(context, 'non-existent');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
expect(result.messageType).toBe('error');
expect(result.content).toContain("MCP server 'non-existent' not found");
}
});
});
describe('refresh subcommand', () => {
it('should refresh the list of tools and display the status', async () => {
const mockToolRegistry = {
discoverMcpTools: vi.fn(),
restartMcpServers: vi.fn(),
getAllTools: vi.fn().mockReturnValue([]),
};
const mockGeminiClient = {
setTools: vi.fn(),
};
const context = createMockCommandContext({
services: {
config: {
getMcpServers: vi.fn().mockReturnValue({ server1: {} }),
getBlockedMcpServers: vi.fn().mockReturnValue([]),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
getPromptRegistry: vi.fn().mockResolvedValue({
getPromptsByServer: vi.fn().mockReturnValue([]),
}),
},
},
});
// Mock the reloadCommands function, which is new logic.
context.ui.reloadCommands = vi.fn();
const refreshCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'refresh',
);
expect(refreshCommand).toBeDefined();
const result = await refreshCommand!.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
{
type: 'info',
text: 'Restarting MCP servers...',
},
await mcpCommand.action!(mockContext, 'nodesc');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
showDescriptions: false,
showTips: false,
}),
expect.any(Number),
);
expect(mockToolRegistry.restartMcpServers).toHaveBeenCalled();
expect(mockGeminiClient.setTools).toHaveBeenCalled();
expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1);
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
expect(result.messageType).toBe('info');
expect(result.content).toContain('Configured MCP servers:');
}
});
it('should show an error if config is not available', async () => {
const contextWithoutConfig = createMockCommandContext({
services: {
config: null,
},
});
const refreshCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'refresh',
);
const result = await refreshCommand!.action!(contextWithoutConfig, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
});
});
it('should show an error if tool registry is not available', async () => {
mockConfig.getToolRegistry = vi.fn().mockReturnValue(undefined);
const refreshCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'refresh',
);
const result = await refreshCommand!.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Could not retrieve tool registry.',
});
});
});
});

View File

@@ -18,300 +18,11 @@ import {
getMCPServerStatus,
MCPDiscoveryState,
MCPServerStatus,
mcpServerRequiresOAuth,
getErrorMessage,
MCPOAuthTokenStorage,
} from '@qwen-code/qwen-code-core';
const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m';
const COLOR_RED = '\u001b[31m';
const COLOR_CYAN = '\u001b[36m';
const COLOR_GREY = '\u001b[90m';
const RESET_COLOR = '\u001b[0m';
const getMcpStatus = async (
context: CommandContext,
showDescriptions: boolean,
showSchema: boolean,
showTips: boolean = false,
): Promise<SlashCommandActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
const toolRegistry = config.getToolRegistry();
if (!toolRegistry) {
return {
type: 'message',
messageType: 'error',
content: 'Could not retrieve tool registry.',
};
}
const mcpServers = config.getMcpServers() || {};
const serverNames = Object.keys(mcpServers);
const blockedMcpServers = config.getBlockedMcpServers() || [];
if (serverNames.length === 0 && blockedMcpServers.length === 0) {
const docsUrl =
'https://qwenlm.github.io/qwen-code-docs/en/tools/mcp-server/#how-to-set-up-your-mcp-server';
return {
type: 'message',
messageType: 'info',
content: `No MCP servers configured. Please view MCP documentation in your browser: ${docsUrl} or use the cli /docs command`,
};
}
// Check if any servers are still connecting
const connectingServers = serverNames.filter(
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
);
const discoveryState = getMCPDiscoveryState();
let message = '';
// Add overall discovery status message if needed
if (
discoveryState === MCPDiscoveryState.IN_PROGRESS ||
connectingServers.length > 0
) {
message += `${COLOR_YELLOW}⏳ MCP servers are starting up (${connectingServers.length} initializing)...${RESET_COLOR}\n`;
message += `${COLOR_CYAN}Note: First startup may take longer. Tool availability will update automatically.${RESET_COLOR}\n\n`;
}
message += 'Configured MCP servers:\n\n';
const allTools = toolRegistry.getAllTools();
for (const serverName of serverNames) {
const serverTools = allTools.filter(
(tool) =>
tool instanceof DiscoveredMCPTool && tool.serverName === serverName,
) as DiscoveredMCPTool[];
const promptRegistry = await config.getPromptRegistry();
const serverPrompts = promptRegistry.getPromptsByServer(serverName) || [];
const originalStatus = getMCPServerStatus(serverName);
const hasCachedItems = serverTools.length > 0 || serverPrompts.length > 0;
// If the server is "disconnected" but has prompts or cached tools, display it as Ready
// by using CONNECTED as the display status.
const status =
originalStatus === MCPServerStatus.DISCONNECTED && hasCachedItems
? MCPServerStatus.CONNECTED
: originalStatus;
// Add status indicator with descriptive text
let statusIndicator = '';
let statusText = '';
switch (status) {
case MCPServerStatus.CONNECTED:
statusIndicator = '🟢';
statusText = 'Ready';
break;
case MCPServerStatus.CONNECTING:
statusIndicator = '🔄';
statusText = 'Starting... (first startup may take longer)';
break;
case MCPServerStatus.DISCONNECTED:
default:
statusIndicator = '🔴';
statusText = 'Disconnected';
break;
}
// Get server description if available
const server = mcpServers[serverName];
let serverDisplayName = serverName;
if (server.extensionName) {
serverDisplayName += ` (from ${server.extensionName})`;
}
// Format server header with bold formatting and status
message += `${statusIndicator} \u001b[1m${serverDisplayName}\u001b[0m - ${statusText}`;
let needsAuthHint = mcpServerRequiresOAuth.get(serverName) || false;
// Add OAuth status if applicable
if (server?.oauth?.enabled) {
needsAuthHint = true;
try {
const { MCPOAuthTokenStorage } = await import(
'@qwen-code/qwen-code-core'
);
const hasToken = await MCPOAuthTokenStorage.getToken(serverName);
if (hasToken) {
const isExpired = MCPOAuthTokenStorage.isTokenExpired(hasToken.token);
if (isExpired) {
message += ` ${COLOR_YELLOW}(OAuth token expired)${RESET_COLOR}`;
} else {
message += ` ${COLOR_GREEN}(OAuth authenticated)${RESET_COLOR}`;
needsAuthHint = false;
}
} else {
message += ` ${COLOR_RED}(OAuth not authenticated)${RESET_COLOR}`;
}
} catch (_err) {
// If we can't check OAuth status, just continue
}
}
// Add tool count with conditional messaging
if (status === MCPServerStatus.CONNECTED) {
const parts = [];
if (serverTools.length > 0) {
parts.push(
`${serverTools.length} ${serverTools.length === 1 ? 'tool' : 'tools'}`,
);
}
if (serverPrompts.length > 0) {
parts.push(
`${serverPrompts.length} ${
serverPrompts.length === 1 ? 'prompt' : 'prompts'
}`,
);
}
if (parts.length > 0) {
message += ` (${parts.join(', ')})`;
} else {
message += ` (0 tools)`;
}
} else if (status === MCPServerStatus.CONNECTING) {
message += ` (tools and prompts will appear when ready)`;
} else {
message += ` (${serverTools.length} tools cached)`;
}
// Add server description with proper handling of multi-line descriptions
if (showDescriptions && server?.description) {
const descLines = server.description.trim().split('\n');
if (descLines) {
message += ':\n';
for (const descLine of descLines) {
message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`;
}
} else {
message += '\n';
}
} else {
message += '\n';
}
// Reset formatting after server entry
message += RESET_COLOR;
if (serverTools.length > 0) {
message += ` ${COLOR_CYAN}Tools:${RESET_COLOR}\n`;
serverTools.forEach((tool) => {
if (showDescriptions && tool.description) {
// Format tool name in cyan using simple ANSI cyan color
message += ` - ${COLOR_CYAN}${tool.name}${RESET_COLOR}`;
// Handle multi-line descriptions by properly indenting and preserving formatting
const descLines = tool.description.trim().split('\n');
if (descLines) {
message += ':\n';
for (const descLine of descLines) {
message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`;
}
} else {
message += '\n';
}
// Reset is handled inline with each line now
} else {
// Use cyan color for the tool name even when not showing descriptions
message += ` - ${COLOR_CYAN}${tool.name}${RESET_COLOR}\n`;
}
const parameters =
tool.schema.parametersJsonSchema ?? tool.schema.parameters;
if (showSchema && parameters) {
// Prefix the parameters in cyan
message += ` ${COLOR_CYAN}Parameters:${RESET_COLOR}\n`;
const paramsLines = JSON.stringify(parameters, null, 2)
.trim()
.split('\n');
if (paramsLines) {
for (const paramsLine of paramsLines) {
message += ` ${COLOR_GREEN}${paramsLine}${RESET_COLOR}\n`;
}
}
}
});
}
if (serverPrompts.length > 0) {
if (serverTools.length > 0) {
message += '\n';
}
message += ` ${COLOR_CYAN}Prompts:${RESET_COLOR}\n`;
serverPrompts.forEach((prompt: DiscoveredMCPPrompt) => {
if (showDescriptions && prompt.description) {
message += ` - ${COLOR_CYAN}${prompt.name}${RESET_COLOR}`;
const descLines = prompt.description.trim().split('\n');
if (descLines) {
message += ':\n';
for (const descLine of descLines) {
message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`;
}
} else {
message += '\n';
}
} else {
message += ` - ${COLOR_CYAN}${prompt.name}${RESET_COLOR}\n`;
}
});
}
if (serverTools.length === 0 && serverPrompts.length === 0) {
message += ' No tools or prompts available\n';
} else if (serverTools.length === 0) {
message += ' No tools available';
if (originalStatus === MCPServerStatus.DISCONNECTED && needsAuthHint) {
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}`;
}
message += '\n';
} else if (
originalStatus === MCPServerStatus.DISCONNECTED &&
needsAuthHint
) {
// This case is for when serverTools.length > 0
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}\n`;
}
message += '\n';
}
for (const server of blockedMcpServers) {
let serverDisplayName = server.name;
if (server.extensionName) {
serverDisplayName += ` (from ${server.extensionName})`;
}
message += `🔴 \u001b[1m${serverDisplayName}\u001b[0m - Blocked\n\n`;
}
// Add helpful tips when no arguments are provided
if (showTips) {
message += '\n';
message += `${COLOR_CYAN}💡 Tips:${RESET_COLOR}\n`;
message += ` • Use ${COLOR_CYAN}/mcp desc${RESET_COLOR} to show server and tool descriptions\n`;
message += ` • Use ${COLOR_CYAN}/mcp schema${RESET_COLOR} to show tool parameter schemas\n`;
message += ` • Use ${COLOR_CYAN}/mcp nodesc${RESET_COLOR} to hide descriptions\n`;
message += ` • Use ${COLOR_CYAN}/mcp auth <server-name>${RESET_COLOR} to authenticate with OAuth-enabled servers\n`;
message += ` • Press ${COLOR_CYAN}Ctrl+T${RESET_COLOR} to toggle tool descriptions on/off\n`;
message += '\n';
}
// Make sure to reset any ANSI formatting at the end to prevent it from affecting the terminal
message += RESET_COLOR;
return {
type: 'message',
messageType: 'info',
content: message,
};
};
import { appEvents, AppEvent } from '../../utils/events.js';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
const authCommand: SlashCommand = {
name: 'auth',
@@ -367,6 +78,12 @@ const authCommand: SlashCommand = {
// Always attempt OAuth authentication, even if not explicitly configured
// The authentication process will discover OAuth requirements automatically
const displayListener = (message: string) => {
context.ui.addItem({ type: 'info', text: message }, Date.now());
};
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
try {
context.ui.addItem(
{
@@ -384,12 +101,13 @@ const authCommand: SlashCommand = {
oauthConfig = { enabled: false };
}
// Pass the MCP server URL for OAuth discovery
const mcpServerUrl = server.httpUrl || server.url;
await MCPOAuthProvider.authenticate(
const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage());
await authProvider.authenticate(
serverName,
oauthConfig,
mcpServerUrl,
appEvents,
);
context.ui.addItem(
@@ -432,6 +150,8 @@ const authCommand: SlashCommand = {
messageType: 'error',
content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`,
};
} finally {
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
}
},
completion: async (context: CommandContext, partialArg: string) => {
@@ -449,7 +169,28 @@ const listCommand: SlashCommand = {
name: 'list',
description: 'List configured MCP servers and tools',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args: string) => {
action: async (
context: CommandContext,
args: string,
): Promise<void | MessageActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
const toolRegistry = config.getToolRegistry();
if (!toolRegistry) {
return {
type: 'message',
messageType: 'error',
content: 'Could not retrieve tool registry.',
};
}
const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean);
const hasDesc =
@@ -459,14 +200,79 @@ const listCommand: SlashCommand = {
lowerCaseArgs.includes('nodescriptions');
const showSchema = lowerCaseArgs.includes('schema');
// Show descriptions if `desc` or `schema` is present,
// but `nodesc` takes precedence and disables them.
const showDescriptions = !hasNodesc && (hasDesc || showSchema);
// Show tips only when no arguments are provided
const showTips = lowerCaseArgs.length === 0;
return getMcpStatus(context, showDescriptions, showSchema, showTips);
const mcpServers = config.getMcpServers() || {};
const serverNames = Object.keys(mcpServers);
const blockedMcpServers = config.getBlockedMcpServers() || [];
const connectingServers = serverNames.filter(
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
);
const discoveryState = getMCPDiscoveryState();
const discoveryInProgress =
discoveryState === MCPDiscoveryState.IN_PROGRESS ||
connectingServers.length > 0;
const allTools = toolRegistry.getAllTools();
const mcpTools = allTools.filter(
(tool) => tool instanceof DiscoveredMCPTool,
) as DiscoveredMCPTool[];
const promptRegistry = await config.getPromptRegistry();
const mcpPrompts = promptRegistry
.getAllPrompts()
.filter(
(prompt) =>
'serverName' in prompt &&
serverNames.includes(prompt.serverName as string),
) as DiscoveredMCPPrompt[];
const authStatus: HistoryItemMcpStatus['authStatus'] = {};
const tokenStorage = new MCPOAuthTokenStorage();
for (const serverName of serverNames) {
const server = mcpServers[serverName];
if (server.oauth?.enabled) {
const creds = await tokenStorage.getCredentials(serverName);
if (creds) {
if (creds.token.expiresAt && creds.token.expiresAt < Date.now()) {
authStatus[serverName] = 'expired';
} else {
authStatus[serverName] = 'authenticated';
}
} else {
authStatus[serverName] = 'unauthenticated';
}
} else {
authStatus[serverName] = 'not-configured';
}
}
const mcpStatusItem: HistoryItemMcpStatus = {
type: MessageType.MCP_STATUS,
servers: mcpServers,
tools: mcpTools.map((tool) => ({
serverName: tool.serverName,
name: tool.name,
description: tool.description,
schema: tool.schema,
})),
prompts: mcpPrompts.map((prompt) => ({
serverName: prompt.serverName as string,
name: prompt.name,
description: prompt.description,
})),
authStatus,
blockedServers: blockedMcpServers,
discoveryInProgress,
connectingServers,
showDescriptions,
showSchema,
showTips,
};
context.ui.addItem(mcpStatusItem, Date.now());
},
};
@@ -476,7 +282,7 @@ const refreshCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
): Promise<SlashCommandActionReturn> => {
): Promise<void | SlashCommandActionReturn> => {
const { config } = context.services;
if (!config) {
return {
@@ -514,7 +320,7 @@ const refreshCommand: SlashCommand = {
// Reload the slash commands to reflect the changes.
context.ui.reloadCommands();
return getMcpStatus(context, false, false, false);
return listCommand.action!(context, '');
},
};
@@ -525,7 +331,10 @@ export const mcpCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, authCommand, refreshCommand],
// Default action when no subcommand is provided
action: async (context: CommandContext, args: string) =>
action: async (
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> =>
// If no subcommand, run the list command
listCommand.action!(context, args),
};

View File

@@ -15,6 +15,7 @@ import {
getErrorMessage,
loadServerHierarchicalMemory,
type FileDiscoveryService,
type LoadServerHierarchicalMemoryResponse,
} from '@qwen-code/qwen-code-core';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
@@ -225,6 +226,7 @@ describe('memoryCommand', () => {
ignore: [],
include: [],
}),
getFolderTrust: () => false,
};
mockContext = createMockCommandContext({
@@ -243,7 +245,7 @@ describe('memoryCommand', () => {
it('should display success message when memory is refreshed with content', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
const refreshResult = {
const refreshResult: LoadServerHierarchicalMemoryResponse = {
memoryContent: 'new memory content',
fileCount: 2,
};

View File

@@ -266,6 +266,7 @@ export const memoryCommand: SlashCommand = {
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
config.getFolderTrust(),
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),

View File

@@ -17,11 +17,7 @@ import * as availableModelsModule from '../models/availableModels.js';
// Mock the availableModels module
vi.mock('../models/availableModels.js', () => ({
AVAILABLE_MODELS_QWEN: [
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
{ id: 'qwen-vl-max-latest', label: 'qwen-vl-max', isVision: true },
],
getOpenAIAvailableModelFromEnv: vi.fn(),
getAvailableModelsForAuthType: vi.fn(),
}));
// Helper function to create a mock config
@@ -35,8 +31,8 @@ function createMockConfig(
describe('modelCommand', () => {
let mockContext: CommandContext;
const mockGetOpenAIAvailableModelFromEnv = vi.mocked(
availableModelsModule.getOpenAIAvailableModelFromEnv,
const mockGetAvailableModelsForAuthType = vi.mocked(
availableModelsModule.getAvailableModelsForAuthType,
);
beforeEach(() => {
@@ -91,6 +87,10 @@ describe('modelCommand', () => {
});
it('should return dialog action for QWEN_OAUTH auth type', async () => {
mockGetAvailableModelsForAuthType.mockReturnValue([
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
]);
const mockConfig = createMockConfig({
model: 'test-model',
authType: AuthType.QWEN_OAUTH,
@@ -106,10 +106,9 @@ describe('modelCommand', () => {
});
it('should return dialog action for USE_OPENAI auth type when model is available', async () => {
mockGetOpenAIAvailableModelFromEnv.mockReturnValue({
id: 'gpt-4',
label: 'gpt-4',
});
mockGetAvailableModelsForAuthType.mockReturnValue([
{ id: 'gpt-4', label: 'gpt-4' },
]);
const mockConfig = createMockConfig({
model: 'test-model',
@@ -126,7 +125,7 @@ describe('modelCommand', () => {
});
it('should return error for USE_OPENAI auth type when no model is available', async () => {
mockGetOpenAIAvailableModelFromEnv.mockReturnValue(null);
mockGetAvailableModelsForAuthType.mockReturnValue([]);
const mockConfig = createMockConfig({
model: 'test-model',
@@ -145,6 +144,8 @@ describe('modelCommand', () => {
});
it('should return error for unsupported auth types', async () => {
mockGetAvailableModelsForAuthType.mockReturnValue([]);
const mockConfig = createMockConfig({
model: 'test-model',
authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType,

View File

@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '@qwen-code/qwen-code-core';
import type {
SlashCommand,
CommandContext,
@@ -12,26 +11,7 @@ import type {
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import {
AVAILABLE_MODELS_QWEN,
getOpenAIAvailableModelFromEnv,
type AvailableModel,
} from '../models/availableModels.js';
function getAvailableModelsForAuthType(authType: AuthType): AvailableModel[] {
switch (authType) {
case AuthType.QWEN_OAUTH:
return AVAILABLE_MODELS_QWEN;
case AuthType.USE_OPENAI: {
const openAIModel = getOpenAIAvailableModelFromEnv();
return openAIModel ? [openAIModel] : [];
}
default:
// For other auth types, return empty array for now
// This can be expanded later according to the design doc
return [];
}
}
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
export const modelCommand: SlashCommand = {
name: 'model',

View File

@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { permissionsCommand } from './permissionsCommand.js';
import { type CommandContext, CommandKind } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('permissionsCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should have the correct name and description', () => {
expect(permissionsCommand.name).toBe('permissions');
expect(permissionsCommand.description).toBe('Manage folder trust settings');
});
it('should be a built-in command', () => {
expect(permissionsCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should return an action to open the permissions dialog', () => {
const actionResult = permissionsCommand.action?.(mockContext, '');
expect(actionResult).toEqual({
type: 'dialog',
dialog: 'permissions',
});
});
});

View File

@@ -7,12 +7,12 @@
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const privacyCommand: SlashCommand = {
name: 'privacy',
description: 'display the privacy notice',
export const permissionsCommand: SlashCommand = {
name: 'permissions',
description: 'Manage folder trust settings',
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'privacy',
dialog: 'permissions',
}),
};

View File

@@ -1,38 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { privacyCommand } from './privacyCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('privacyCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should return a dialog action to open the privacy dialog', () => {
// Ensure the command has an action to test.
if (!privacyCommand.action) {
throw new Error('The privacy command must have an action.');
}
const result = privacyCommand.action(mockContext, '');
// Assert that the action returns the correct object to trigger the privacy dialog.
expect(result).toEqual({
type: 'dialog',
dialog: 'privacy',
});
});
it('should have the correct name and description', () => {
expect(privacyCommand.name).toBe('privacy');
expect(privacyCommand.description).toBe('display the privacy notice');
});
});

View File

@@ -100,6 +100,7 @@ export const summaryCommand: SlashCommand = {
],
{},
new AbortController().signal,
config.getModel(),
);
// Extract text from response

View File

@@ -61,9 +61,11 @@ describe('toolsCommand', () => {
await toolsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('No tools available'),
}),
{
type: MessageType.TOOLS_LIST,
tools: [],
showDescriptions: false,
},
expect.any(Number),
);
});
@@ -80,10 +82,12 @@ describe('toolsCommand', () => {
if (!toolsCommand.action) throw new Error('Action not defined');
await toolsCommand.action(mockContext, '');
const message = (mockContext.ui.addItem as vi.Mock).mock.calls[0][0].text;
expect(message).not.toContain('Reads files from the local system.');
expect(message).toContain('File Reader');
expect(message).toContain('Code Editor');
const [message] = (mockContext.ui.addItem as vi.Mock).mock.calls[0];
expect(message.type).toBe(MessageType.TOOLS_LIST);
expect(message.showDescriptions).toBe(false);
expect(message.tools).toHaveLength(2);
expect(message.tools[0].displayName).toBe('File Reader');
expect(message.tools[1].displayName).toBe('Code Editor');
});
it('should list tools with descriptions when "desc" arg is passed', async () => {
@@ -98,8 +102,13 @@ describe('toolsCommand', () => {
if (!toolsCommand.action) throw new Error('Action not defined');
await toolsCommand.action(mockContext, 'desc');
const message = (mockContext.ui.addItem as vi.Mock).mock.calls[0][0].text;
expect(message).toContain('Reads files from the local system.');
expect(message).toContain('Edits code files.');
const [message] = (mockContext.ui.addItem as vi.Mock).mock.calls[0];
expect(message.type).toBe(MessageType.TOOLS_LIST);
expect(message.showDescriptions).toBe(true);
expect(message.tools).toHaveLength(2);
expect(message.tools[0].description).toBe(
'Reads files from the local system.',
);
expect(message.tools[1].description).toBe('Edits code files.');
});
});

View File

@@ -9,7 +9,7 @@ import {
type SlashCommand,
CommandKind,
} from './types.js';
import { MessageType } from '../types.js';
import { MessageType, type HistoryItemToolsList } from '../types.js';
export const toolsCommand: SlashCommand = {
name: 'tools',
@@ -40,32 +40,16 @@ export const toolsCommand: SlashCommand = {
// Filter out MCP tools by checking for the absence of a serverName property
const geminiTools = tools.filter((tool) => !('serverName' in tool));
let message = 'Available Qwen Code tools:\n\n';
const toolsListItem: HistoryItemToolsList = {
type: MessageType.TOOLS_LIST,
tools: geminiTools.map((tool) => ({
name: tool.name,
displayName: tool.displayName,
description: tool.description,
})),
showDescriptions: useShowDescriptions,
};
if (geminiTools.length > 0) {
geminiTools.forEach((tool) => {
if (useShowDescriptions && tool.description) {
message += ` - \u001b[36m${tool.displayName} (${tool.name})\u001b[0m:\n`;
const greenColor = '\u001b[32m';
const resetColor = '\u001b[0m';
// Handle multi-line descriptions
const descLines = tool.description.trim().split('\n');
for (const descLine of descLines) {
message += ` ${greenColor}${descLine}${resetColor}\n`;
}
} else {
message += ` - \u001b[36m${tool.displayName}\u001b[0m\n`;
}
});
} else {
message += ' No tools available\n';
}
message += '\n';
message += '\u001b[0m';
context.ui.addItem({ type: MessageType.INFO, text: message }, Date.now());
context.ui.addItem(toolsListItem, Date.now());
},
};

View File

@@ -4,13 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type ReactNode } from 'react';
import type { ReactNode } from 'react';
import type { Content, PartListUnion } from '@google/genai';
import type { HistoryItemWithoutId, HistoryItem } from '../types.js';
import type { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
import type {
HistoryItemWithoutId,
HistoryItem,
ConfirmationRequest,
} from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import type {
ExtensionUpdateAction,
ExtensionUpdateStatus,
} from '../state/extensions.js';
// Grouped dependencies for clarity and easier mocking
export interface CommandContext {
@@ -61,6 +69,9 @@ export interface CommandContext {
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
reloadCommands: () => void;
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
};
// Session-specific data
session: {
@@ -114,11 +125,11 @@ export interface OpenDialogActionReturn {
| 'auth'
| 'theme'
| 'editor'
| 'privacy'
| 'settings'
| 'model'
| 'subagent_create'
| 'subagent_list';
| 'subagent_list'
| 'permissions';
}
/**
@@ -186,6 +197,7 @@ export interface SlashCommand {
name: string;
altNames?: string[];
description: string;
hidden?: boolean;
kind: CommandKind;