Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading

This commit is contained in:
yiliang114
2025-12-13 16:44:29 +08:00
14 changed files with 2201 additions and 255 deletions

View File

@@ -88,6 +88,16 @@ export class AgentSideConnection implements Client {
);
}
/**
* Streams authentication updates (e.g. Qwen OAuth authUri) to the client.
*/
async authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void> {
return await this.#connection.sendNotification(
schema.CLIENT_METHODS.authenticate_update,
params,
);
}
/**
* Request permission before running a tool
*
@@ -241,9 +251,11 @@ class Connection {
).toResult();
}
let errorName;
let details;
if (error instanceof Error) {
errorName = error.name;
details = error.message;
} else if (
typeof error === 'object' &&
@@ -254,6 +266,10 @@ class Connection {
details = error.message;
}
if (errorName === 'TokenManagerError') {
return RequestError.authRequired(details).toResult();
}
return RequestError.internalError(details).toResult();
}
}
@@ -357,6 +373,7 @@ export interface Client {
params: schema.RequestPermissionRequest,
): Promise<schema.RequestPermissionResponse>;
sessionUpdate(params: schema.SessionNotification): Promise<void>;
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
writeTextFile(
params: schema.WriteTextFileRequest,
): Promise<schema.WriteTextFileResponse>;

View File

@@ -6,15 +6,19 @@
import type { ReadableStream, WritableStream } from 'node:stream/web';
import type { Config, ConversationRecord } from '@qwen-code/qwen-code-core';
import {
APPROVAL_MODE_INFO,
APPROVAL_MODES,
AuthType,
clearCachedCredentialFile,
QwenOAuth2Event,
qwenOAuth2Events,
MCPServerConfig,
SessionService,
buildApiHistoryFromConversation,
type Config,
type ConversationRecord,
type DeviceAuthorizationData,
} from '@qwen-code/qwen-code-core';
import type { ApprovalModeValue } from './schema.js';
import * as acp from './acp.js';
@@ -123,13 +127,33 @@ class GeminiAgent {
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
const method = z.nativeEnum(AuthType).parse(methodId);
let authUri: string | undefined;
const authUriHandler = (deviceAuth: DeviceAuthorizationData) => {
authUri = deviceAuth.verification_uri_complete;
// Send the auth URL to ACP client as soon as it's available (refreshAuth is blocking).
void this.client.authenticateUpdate({ _meta: { authUri } });
};
if (method === AuthType.QWEN_OAUTH) {
qwenOAuth2Events.once(QwenOAuth2Event.AuthUri, authUriHandler);
}
await clearCachedCredentialFile();
await this.config.refreshAuth(method);
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
try {
await this.config.refreshAuth(method);
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
} finally {
// Ensure we don't leak listeners if auth fails early.
if (method === AuthType.QWEN_OAUTH) {
qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, authUriHandler);
}
}
return;
}
async newSession({
@@ -272,7 +296,8 @@ class GeminiAgent {
}
try {
await config.refreshAuth(selectedType);
// Use true for the second argument to ensure only cached credentials are used
await config.refreshAuth(selectedType, true);
} catch (e) {
console.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();

View File

@@ -20,6 +20,7 @@ export const AGENT_METHODS = {
export const CLIENT_METHODS = {
fs_read_text_file: 'fs/read_text_file',
fs_write_text_file: 'fs/write_text_file',
authenticate_update: 'authenticate/update',
session_request_permission: 'session/request_permission',
session_update: 'session/update',
};
@@ -57,8 +58,6 @@ export type CancelNotification = z.infer<typeof cancelNotificationSchema>;
export type AuthenticateRequest = z.infer<typeof authenticateRequestSchema>;
export type AuthenticateResponse = z.infer<typeof authenticateResponseSchema>;
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
export type LoadSessionResponse = z.infer<typeof loadSessionResponseSchema>;
@@ -247,7 +246,13 @@ export const authenticateRequestSchema = z.object({
methodId: z.string(),
});
export const authenticateResponseSchema = z.null();
export const authenticateUpdateSchema = z.object({
_meta: z.object({
authUri: z.string(),
}),
});
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
export const newSessionResponseSchema = z.object({
sessionId: z.string(),
@@ -555,7 +560,6 @@ export const sessionUpdateSchema = z.union([
export const agentResponseSchema = z.union([
initializeResponseSchema,
authenticateResponseSchema,
newSessionResponseSchema,
loadSessionResponseSchema,
promptResponseSchema,

View File

@@ -191,6 +191,7 @@ const SETTINGS_SCHEMA = {
{ value: 'auto', label: 'Auto (detect from system)' },
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' },
{ value: 'ru', label: 'Русский (Russian)' },
],
},
},

View File

@@ -9,7 +9,7 @@ import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { homedir } from 'node:os';
export type SupportedLanguage = 'en' | 'zh' | string; // Allow custom language codes
export type SupportedLanguage = 'en' | 'zh' | 'ru' | string; // Allow custom language codes
// State
let currentLanguage: SupportedLanguage = 'en';
@@ -51,10 +51,12 @@ export function detectSystemLanguage(): SupportedLanguage {
const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG'];
if (envLang?.startsWith('zh')) return 'zh';
if (envLang?.startsWith('en')) return 'en';
if (envLang?.startsWith('ru')) return 'ru';
try {
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
if (locale.startsWith('zh')) return 'zh';
if (locale.startsWith('ru')) return 'ru';
} catch {
// Fallback to default
}

File diff suppressed because it is too large Load Diff

View File

@@ -60,6 +60,7 @@ export const createMockCommandContext = (
toggleVimEnabled: vi.fn(),
extensionsUpdateState: new Map(),
setExtensionsUpdateState: vi.fn(),
reloadCommands: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
session: {

View File

@@ -0,0 +1,587 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import { type CommandContext, CommandKind } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
// Mock i18n module
vi.mock('../../i18n/index.js', () => ({
setLanguageAsync: vi.fn().mockResolvedValue(undefined),
getCurrentLanguage: vi.fn().mockReturnValue('en'),
t: vi.fn((key: string) => key),
}));
// Mock settings module to avoid Storage side effect
vi.mock('../../config/settings.js', () => ({
SettingScope: {
User: 'user',
Workspace: 'workspace',
Default: 'default',
},
}));
// Mock fs module
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
default: {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
},
};
});
// Mock Storage from core
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
Storage: {
getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'),
getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/.qwen/settings.json'),
},
};
});
// Import modules after mocking
import * as i18n from '../../i18n/index.js';
import { languageCommand } from './languageCommand.js';
describe('languageCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
vi.clearAllMocks();
mockContext = createMockCommandContext({
services: {
config: {
getModel: vi.fn().mockReturnValue('test-model'),
},
settings: {
merged: {},
setValue: vi.fn(),
},
},
});
// Reset i18n mocks
vi.mocked(i18n.getCurrentLanguage).mockReturnValue('en');
vi.mocked(i18n.t).mockImplementation((key: string) => key);
// Reset fs mocks
vi.mocked(fs.existsSync).mockReturnValue(false);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('command metadata', () => {
it('should have the correct name', () => {
expect(languageCommand.name).toBe('language');
});
it('should have a description', () => {
expect(languageCommand.description).toBeDefined();
expect(typeof languageCommand.description).toBe('string');
});
it('should be a built-in command', () => {
expect(languageCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should have subcommands', () => {
expect(languageCommand.subCommands).toBeDefined();
expect(languageCommand.subCommands?.length).toBe(2);
});
it('should have ui and output subcommands', () => {
const subCommandNames = languageCommand.subCommands?.map((c) => c.name);
expect(subCommandNames).toContain('ui');
expect(subCommandNames).toContain('output');
});
});
describe('main command action - no arguments', () => {
it('should show current language settings when no arguments provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Current UI language:'),
});
});
it('should show available subcommands in help', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('/language ui'),
});
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('/language output'),
});
});
it('should show LLM output language when set', async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
'# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY',
);
// Make t() function handle interpolation for this test
vi.mocked(i18n.t).mockImplementation(
(key: string, params?: Record<string, string>) => {
if (params && key.includes('{{lang}}')) {
return key.replace('{{lang}}', params['lang'] || '');
}
return key;
},
);
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Current UI language:'),
});
// Verify it correctly parses "Chinese" from the template format
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Chinese'),
});
});
});
describe('main command action - config not available', () => {
it('should return error when config is null', async () => {
mockContext.services.config = null;
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Configuration not available'),
});
});
});
describe('/language ui subcommand', () => {
it('should show help when no language argument provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Usage: /language ui'),
});
});
it('should set English with "en"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(mockContext.services.settings.setValue).toHaveBeenCalled();
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with "en-US"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui en-US');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with "english"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui english');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "zh"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui zh');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "zh-CN"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui zh-CN');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "chinese"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui chinese');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should return error for invalid language', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui invalid');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Invalid language'),
});
});
it('should persist setting to user scope', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
await languageCommand.action(mockContext, 'ui en');
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.anything(), // SettingScope.User
'general.language',
'en',
);
});
});
describe('/language output subcommand', () => {
it('should show help when no language argument provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Usage: /language output'),
});
});
it('should create LLM output language rule file', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output Chinese');
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Chinese'),
'utf-8',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('LLM output language rule file generated'),
});
});
it('should include restart notice in success message', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output Japanese');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('restart'),
});
});
it('should handle file write errors gracefully', async () => {
vi.mocked(fs.writeFileSync).mockImplementation(() => {
throw new Error('Permission denied');
});
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output German');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Failed to generate'),
});
});
});
describe('backward compatibility - direct language arguments', () => {
it('should set Chinese with direct "zh" argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'zh');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with direct "en" argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should return error for unknown direct argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'unknown');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Invalid command'),
});
});
});
describe('ui subcommand object', () => {
const uiSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'ui',
);
it('should have correct metadata', () => {
expect(uiSubcommand).toBeDefined();
expect(uiSubcommand?.name).toBe('ui');
expect(uiSubcommand?.kind).toBe(CommandKind.BUILT_IN);
});
it('should have nested language subcommands', () => {
const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name);
expect(nestedNames).toContain('zh-CN');
expect(nestedNames).toContain('en-US');
});
it('should have action that sets language', async () => {
if (!uiSubcommand?.action) {
throw new Error('UI subcommand must have an action.');
}
const result = await uiSubcommand.action(mockContext, 'en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
});
describe('output subcommand object', () => {
const outputSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'output',
);
it('should have correct metadata', () => {
expect(outputSubcommand).toBeDefined();
expect(outputSubcommand?.name).toBe('output');
expect(outputSubcommand?.kind).toBe(CommandKind.BUILT_IN);
});
it('should have action that generates rule file', async () => {
if (!outputSubcommand?.action) {
throw new Error('Output subcommand must have an action.');
}
// Ensure mocks are properly set for this test
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
const result = await outputSubcommand.action(mockContext, 'French');
expect(fs.writeFileSync).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('LLM output language rule file generated'),
});
});
});
describe('nested ui language subcommands', () => {
const uiSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'ui',
);
const zhCNSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'zh-CN',
);
const enUSSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'en-US',
);
it('zh-CN should have aliases', () => {
expect(zhCNSubcommand?.altNames).toContain('zh');
expect(zhCNSubcommand?.altNames).toContain('chinese');
});
it('en-US should have aliases', () => {
expect(enUSSubcommand?.altNames).toContain('en');
expect(enUSSubcommand?.altNames).toContain('english');
});
it('zh-CN action should set Chinese', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
const result = await zhCNSubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('en-US action should set English', async () => {
if (!enUSSubcommand?.action) {
throw new Error('en-US subcommand must have an action.');
}
const result = await enUSSubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should reject extra arguments', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
const result = await zhCNSubcommand.action(mockContext, 'extra args');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('do not accept additional arguments'),
});
});
});
});

View File

@@ -81,8 +81,9 @@ function getCurrentLlmOutputLanguage(): string | null {
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
// Extract language name from the first line (e.g., "# Chinese Response Rules" -> "Chinese")
const match = content.match(/^#\s+(.+?)\s+Response Rules/i);
// Extract language name from the first line
// Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY"
const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i);
if (match) {
return match[1];
}
@@ -127,16 +128,17 @@ async function setUiLanguage(
context.ui.reloadCommands();
// Map language codes to friendly display names
const langDisplayNames: Record<SupportedLanguage, string> = {
const langDisplayNames: Partial<Record<SupportedLanguage, string>> = {
zh: '中文zh-CN',
en: 'Englishen-US',
ru: 'Русский (ru-RU)',
};
return {
type: 'message',
messageType: 'info',
content: t('UI language changed to {{lang}}', {
lang: langDisplayNames[lang],
lang: langDisplayNames[lang] || lang,
}),
};
}
@@ -216,7 +218,7 @@ export const languageCommand: SlashCommand = {
: t('LLM output language not set'),
'',
t('Available subcommands:'),
` /language ui [zh-CN|en-US] - ${t('Set UI language')}`,
` /language ui [zh-CN|en-US|ru-RU] - ${t('Set UI language')}`,
` /language output <language> - ${t('Set LLM output language')}`,
].join('\n');
@@ -232,7 +234,7 @@ export const languageCommand: SlashCommand = {
const subcommand = parts[0].toLowerCase();
if (subcommand === 'ui') {
// Handle /language ui [zh-CN|en-US]
// Handle /language ui [zh-CN|en-US|ru-RU]
if (parts.length === 1) {
// Show UI language subcommand help
return {
@@ -241,11 +243,12 @@ export const languageCommand: SlashCommand = {
content: [
t('Set UI language'),
'',
t('Usage: /language ui [zh-CN|en-US]'),
t('Usage: /language ui [zh-CN|en-US|ru-RU]'),
'',
t('Available options:'),
t(' - zh-CN: Simplified Chinese'),
t(' - en-US: English'),
t(' - ru-RU: Russian'),
'',
t(
'To request additional UI language packs, please open an issue on GitHub.',
@@ -266,11 +269,18 @@ export const languageCommand: SlashCommand = {
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else if (
langArg === 'ru' ||
langArg === 'ru-RU' ||
langArg === 'russian' ||
langArg === 'русский'
) {
targetLang = 'ru';
} else {
return {
type: 'message',
messageType: 'error',
content: t('Invalid language. Available: en-US, zh-CN'),
content: t('Invalid language. Available: en-US, zh-CN, ru-RU'),
};
}
@@ -307,13 +317,20 @@ export const languageCommand: SlashCommand = {
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else if (
langArg === 'ru' ||
langArg === 'ru-RU' ||
langArg === 'russian' ||
langArg === 'русский'
) {
targetLang = 'ru';
} else {
return {
type: 'message',
messageType: 'error',
content: [
t('Invalid command. Available subcommands:'),
' - /language ui [zh-CN|en-US] - ' + t('Set UI language'),
' - /language ui [zh-CN|en-US|ru-RU] - ' + t('Set UI language'),
' - /language output <language> - ' + t('Set LLM output language'),
].join('\n'),
};
@@ -423,6 +440,29 @@ export const languageCommand: SlashCommand = {
return setUiLanguage(context, 'en');
},
},
{
name: 'ru-RU',
altNames: ['ru', 'russian', 'русский'],
get description() {
return t('Set UI language to Russian (ru-RU)');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'Language subcommands do not accept additional arguments.',
),
};
}
return setUiLanguage(context, 'ru');
},
},
],
},
{

View File

@@ -7,7 +7,13 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { OpenAIContentConverter } from './converter.js';
import type { StreamingToolCallParser } from './streamingToolCallParser.js';
import type { GenerateContentParameters, Content } from '@google/genai';
import {
Type,
type GenerateContentParameters,
type Content,
type Tool,
type CallableTool,
} from '@google/genai';
import type OpenAI from 'openai';
describe('OpenAIContentConverter', () => {
@@ -202,4 +208,338 @@ describe('OpenAIContentConverter', () => {
);
});
});
describe('convertGeminiToolsToOpenAI', () => {
it('should convert Gemini tools with parameters field', async () => {
const geminiTools = [
{
functionDeclarations: [
{
name: 'get_weather',
description: 'Get weather for a location',
parameters: {
type: Type.OBJECT,
properties: {
location: { type: Type.STRING },
},
required: ['location'],
},
},
],
},
] as Tool[];
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'function',
function: {
name: 'get_weather',
description: 'Get weather for a location',
parameters: {
type: 'object',
properties: {
location: { type: 'string' },
},
required: ['location'],
},
},
});
});
it('should convert MCP tools with parametersJsonSchema field', async () => {
// MCP tools use parametersJsonSchema which contains plain JSON schema (not Gemini types)
const mcpTools = [
{
functionDeclarations: [
{
name: 'read_file',
description: 'Read a file from disk',
parametersJsonSchema: {
type: 'object',
properties: {
path: { type: 'string' },
},
required: ['path'],
},
},
],
},
] as Tool[];
const result = await converter.convertGeminiToolsToOpenAI(mcpTools);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'function',
function: {
name: 'read_file',
description: 'Read a file from disk',
parameters: {
type: 'object',
properties: {
path: { type: 'string' },
},
required: ['path'],
},
},
});
});
it('should handle CallableTool by resolving tool function', async () => {
const callableTools = [
{
tool: async () => ({
functionDeclarations: [
{
name: 'dynamic_tool',
description: 'A dynamically resolved tool',
parameters: {
type: Type.OBJECT,
properties: {},
},
},
],
}),
},
] as CallableTool[];
const result = await converter.convertGeminiToolsToOpenAI(callableTools);
expect(result).toHaveLength(1);
expect(result[0].function.name).toBe('dynamic_tool');
});
it('should skip functions without name or description', async () => {
const geminiTools = [
{
functionDeclarations: [
{
name: 'valid_tool',
description: 'A valid tool',
},
{
name: 'missing_description',
// no description
},
{
// no name
description: 'Missing name',
},
],
},
] as Tool[];
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
expect(result).toHaveLength(1);
expect(result[0].function.name).toBe('valid_tool');
});
it('should handle tools without functionDeclarations', async () => {
const emptyTools: Tool[] = [
{} as Tool,
{ functionDeclarations: [] },
];
const result = await converter.convertGeminiToolsToOpenAI(emptyTools);
expect(result).toHaveLength(0);
});
it('should handle functions without parameters', async () => {
const geminiTools: Tool[] = [
{
functionDeclarations: [
{
name: 'no_params_tool',
description: 'A tool without parameters',
},
],
},
];
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
expect(result).toHaveLength(1);
expect(result[0].function.parameters).toBeUndefined();
});
it('should not mutate original parametersJsonSchema', async () => {
const originalSchema = {
type: 'object',
properties: { foo: { type: 'string' } },
};
const mcpTools: Tool[] = [
{
functionDeclarations: [
{
name: 'test_tool',
description: 'Test tool',
parametersJsonSchema: originalSchema,
},
],
} as Tool,
];
const result = await converter.convertGeminiToolsToOpenAI(mcpTools);
// Verify the result is a copy, not the same reference
expect(result[0].function.parameters).not.toBe(originalSchema);
expect(result[0].function.parameters).toEqual(originalSchema);
});
});
describe('convertGeminiToolParametersToOpenAI', () => {
it('should convert type names to lowercase', () => {
const params = {
type: 'OBJECT',
properties: {
count: { type: 'INTEGER' },
amount: { type: 'NUMBER' },
name: { type: 'STRING' },
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
expect(result).toEqual({
type: 'object',
properties: {
count: { type: 'integer' },
amount: { type: 'number' },
name: { type: 'string' },
},
});
});
it('should convert string numeric constraints to numbers', () => {
const params = {
type: 'object',
properties: {
value: {
type: 'number',
minimum: '0',
maximum: '100',
multipleOf: '0.5',
},
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>;
expect(properties?.['value']).toEqual({
type: 'number',
minimum: 0,
maximum: 100,
multipleOf: 0.5,
});
});
it('should convert string length constraints to integers', () => {
const params = {
type: 'object',
properties: {
text: {
type: 'string',
minLength: '1',
maxLength: '100',
},
items: {
type: 'array',
minItems: '0',
maxItems: '10',
},
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>;
expect(properties?.['text']).toEqual({
type: 'string',
minLength: 1,
maxLength: 100,
});
expect(properties?.['items']).toEqual({
type: 'array',
minItems: 0,
maxItems: 10,
});
});
it('should handle nested objects', () => {
const params = {
type: 'object',
properties: {
nested: {
type: 'object',
properties: {
deep: {
type: 'INTEGER',
minimum: '0',
},
},
},
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>;
const nested = properties?.['nested'] as Record<string, unknown>;
const nestedProperties = nested?.['properties'] as Record<string, unknown>;
expect(nestedProperties?.['deep']).toEqual({
type: 'integer',
minimum: 0,
});
});
it('should handle arrays', () => {
const params = {
type: 'array',
items: {
type: 'INTEGER',
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
expect(result).toEqual({
type: 'array',
items: {
type: 'integer',
},
});
});
it('should return undefined for null or non-object input', () => {
expect(
converter.convertGeminiToolParametersToOpenAI(
null as unknown as Record<string, unknown>,
),
).toBeNull();
expect(
converter.convertGeminiToolParametersToOpenAI(
undefined as unknown as Record<string, unknown>,
),
).toBeUndefined();
});
it('should not mutate the original parameters', () => {
const original = {
type: 'OBJECT',
properties: {
count: { type: 'INTEGER' },
},
};
const originalCopy = JSON.parse(JSON.stringify(original));
converter.convertGeminiToolParametersToOpenAI(original);
expect(original).toEqual(originalCopy);
});
});
});

View File

@@ -193,13 +193,11 @@ export class OpenAIContentConverter {
// Handle both Gemini tools (parameters) and MCP tools (parametersJsonSchema)
if (func.parametersJsonSchema) {
// MCP tool format - use parametersJsonSchema directly
if (func.parametersJsonSchema) {
// Create a shallow copy to avoid mutating the original object
const paramsCopy = {
...(func.parametersJsonSchema as Record<string, unknown>),
};
parameters = paramsCopy;
}
// Create a shallow copy to avoid mutating the original object
const paramsCopy = {
...(func.parametersJsonSchema as Record<string, unknown>),
};
parameters = paramsCopy;
} else if (func.parameters) {
// Gemini tool format - convert parameters to OpenAI format
parameters = this.convertGeminiToolParametersToOpenAI(

View File

@@ -761,7 +761,6 @@ describe('getQwenOAuthClient', () => {
});
it('should load cached credentials if available', async () => {
const fs = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'cached-refresh',
@@ -769,10 +768,6 @@ describe('getQwenOAuthClient', () => {
expiry_date: Date.now() + 3600000,
};
vi.mocked(fs.promises.readFile).mockResolvedValue(
JSON.stringify(mockCredentials),
);
// Mock SharedTokenManager to use cached credentials
const mockTokenManager = {
getValidCredentials: vi.fn().mockResolvedValue(mockCredentials),
@@ -792,18 +787,6 @@ describe('getQwenOAuthClient', () => {
});
it('should handle cached credentials refresh failure', async () => {
const fs = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'expired-refresh',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000, // Valid expiry time so loadCachedQwenCredentials returns true
};
vi.mocked(fs.promises.readFile).mockResolvedValue(
JSON.stringify(mockCredentials),
);
// Mock SharedTokenManager to fail with a specific error
const mockTokenManager = {
getValidCredentials: vi
@@ -833,6 +816,35 @@ describe('getQwenOAuthClient', () => {
SharedTokenManager.getInstance = originalGetInstance;
});
it('should not start device flow when requireCachedCredentials is true', async () => {
// Make SharedTokenManager fail so we hit the fallback path
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No credentials')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager);
// If requireCachedCredentials is honored, device-flow network requests should not start
vi.mocked(global.fetch).mockResolvedValue({ ok: true } as Response);
await expect(
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig, {
requireCachedCredentials: true,
}),
),
).rejects.toThrow(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
expect(global.fetch).not.toHaveBeenCalled();
SharedTokenManager.getInstance = originalGetInstance;
});
});
describe('CredentialsClearRequiredError', () => {
@@ -1574,178 +1586,6 @@ describe('Credential Caching Functions', () => {
expect(updatedCredentials.access_token).toBe('new-token');
});
});
describe('loadCachedQwenCredentials', () => {
it('should load and validate cached credentials successfully', async () => {
const { promises: fs } = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'cached-refresh',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000,
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials));
// Test through getQwenOAuthClient which calls loadCachedQwenCredentials
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
// Make SharedTokenManager fail to test the fallback
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock successful auth flow after cache load fails
const mockAuthResponse = {
ok: true,
json: async () => ({
device_code: 'test-device-code',
user_code: 'TEST123',
verification_uri: 'https://chat.qwen.ai/device',
verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123',
expires_in: 1800,
}),
};
const mockTokenResponse = {
ok: true,
json: async () => ({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'openid profile email model.completion',
}),
};
global.fetch = vi
.fn()
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockTokenResponse as Response);
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
expect(fs.readFile).toHaveBeenCalled();
SharedTokenManager.getInstance = originalGetInstance;
});
it('should handle invalid cached credentials gracefully', async () => {
const { promises: fs } = await import('node:fs');
// Mock file read to return invalid JSON
vi.mocked(fs.readFile).mockResolvedValue('invalid-json');
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock auth flow
const mockAuthResponse = {
ok: true,
json: async () => ({
device_code: 'test-device-code',
user_code: 'TEST123',
verification_uri: 'https://chat.qwen.ai/device',
verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123',
expires_in: 1800,
}),
};
const mockTokenResponse = {
ok: true,
json: async () => ({
access_token: 'new-token',
refresh_token: 'new-refresh',
token_type: 'Bearer',
expires_in: 3600,
}),
};
global.fetch = vi
.fn()
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockTokenResponse as Response);
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
SharedTokenManager.getInstance = originalGetInstance;
});
it('should handle file access errors', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found'));
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock device flow to fail quickly
const mockAuthResponse = {
ok: true,
json: async () => ({
error: 'invalid_request',
error_description: 'Invalid request parameters',
}),
};
global.fetch = vi.fn().mockResolvedValue(mockAuthResponse as Response);
// Should proceed to device flow when cache loading fails
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
SharedTokenManager.getInstance = originalGetInstance;
});
});
});
describe('Enhanced Error Handling and Edge Cases', () => {

View File

@@ -514,26 +514,14 @@ export async function getQwenOAuthClient(
}
}
// If shared manager fails, check if we have cached credentials for device flow
if (await loadCachedQwenCredentials(client)) {
// We have cached credentials but they might be expired
// Try device flow instead of forcing refresh
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Use detailed error message if available, otherwise use default
const errorMessage =
result.message || 'Qwen OAuth authentication failed';
throw new Error(errorMessage);
}
return client;
}
if (options?.requireCachedCredentials) {
throw new Error(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
}
// If we couldn't obtain valid credentials via SharedTokenManager, fall back to
// interactive device authorization (unless explicitly forbidden above).
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Only emit timeout event if the failure reason is actually timeout
@@ -847,27 +835,6 @@ async function authWithQwenDeviceFlow(
}
}
async function loadCachedQwenCredentials(
client: QwenOAuth2Client,
): Promise<boolean> {
try {
const keyFile = getQwenCachedCredentialPath();
const creds = await fs.readFile(keyFile, 'utf-8');
const credentials = JSON.parse(creds) as QwenCredentials;
client.setCredentials(credentials);
// Verify that the credentials are still valid
const { token } = await client.getAccessToken();
if (!token) {
return false;
}
return true;
} catch (_) {
return false;
}
}
async function cacheQwenCredentials(credentials: QwenCredentials) {
const filePath = getQwenCachedCredentialPath();
try {