Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
22946b85c2 chore(release): v0.7.2-preview.0 2026-01-19 11:12:37 +00:00
47 changed files with 198 additions and 1771 deletions

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.2-preview.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.2-preview.0",
"workspaces": [
"packages/*"
],
@@ -17310,7 +17310,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.2-preview.0",
"dependencies": {
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
@@ -17947,7 +17947,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.7.1",
"version": "0.7.2-preview.0",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
@@ -21408,7 +21408,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.7.1",
"version": "0.7.2-preview.0",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -21420,7 +21420,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.7.1",
"version": "0.7.2-preview.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.2-preview.0",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.2-preview.0"
},
"scripts": {
"start": "cross-env node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.2-preview.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.2-preview.0"
},
"dependencies": {
"@google/genai": "1.30.0",

View File

@@ -8,7 +8,6 @@
import { z } from 'zod';
import * as schema from './schema.js';
import { ACP_ERROR_CODES } from './errorCodes.js';
export * from './schema.js';
import type { WritableStream, ReadableStream } from 'node:stream/web';
@@ -350,51 +349,27 @@ export class RequestError extends Error {
}
static parseError(details?: string): RequestError {
return new RequestError(
ACP_ERROR_CODES.PARSE_ERROR,
'Parse error',
details,
);
return new RequestError(-32700, 'Parse error', details);
}
static invalidRequest(details?: string): RequestError {
return new RequestError(
ACP_ERROR_CODES.INVALID_REQUEST,
'Invalid request',
details,
);
return new RequestError(-32600, 'Invalid request', details);
}
static methodNotFound(details?: string): RequestError {
return new RequestError(
ACP_ERROR_CODES.METHOD_NOT_FOUND,
'Method not found',
details,
);
return new RequestError(-32601, 'Method not found', details);
}
static invalidParams(details?: string): RequestError {
return new RequestError(
ACP_ERROR_CODES.INVALID_PARAMS,
'Invalid params',
details,
);
return new RequestError(-32602, 'Invalid params', details);
}
static internalError(details?: string): RequestError {
return new RequestError(
ACP_ERROR_CODES.INTERNAL_ERROR,
'Internal error',
details,
);
return new RequestError(-32603, 'Internal error', details);
}
static authRequired(details?: string): RequestError {
return new RequestError(
ACP_ERROR_CODES.AUTH_REQUIRED,
'Authentication required',
details,
);
return new RequestError(-32000, 'Authentication required', details);
}
toResult<T>(): Result<T> {

View File

@@ -1,25 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const ACP_ERROR_CODES = {
// Parse error: invalid JSON received by server.
PARSE_ERROR: -32700,
// Invalid request: JSON is not a valid Request object.
INVALID_REQUEST: -32600,
// Method not found: method does not exist or is unavailable.
METHOD_NOT_FOUND: -32601,
// Invalid params: invalid method parameter(s).
INVALID_PARAMS: -32602,
// Internal error: implementation-defined server error.
INTERNAL_ERROR: -32603,
// Authentication required: must authenticate before operation.
AUTH_REQUIRED: -32000,
// Resource not found: e.g. missing file.
RESOURCE_NOT_FOUND: -32002,
} as const;
export type AcpErrorCode =
(typeof ACP_ERROR_CODES)[keyof typeof ACP_ERROR_CODES];

View File

@@ -7,7 +7,6 @@
import { describe, expect, it, vi } from 'vitest';
import type { FileSystemService } from '@qwen-code/qwen-code-core';
import { AcpFileSystemService } from './filesystem.js';
import { ACP_ERROR_CODES } from '../errorCodes.js';
const createFallback = (): FileSystemService => ({
readTextFile: vi.fn(),
@@ -17,13 +16,11 @@ const createFallback = (): FileSystemService => ({
describe('AcpFileSystemService', () => {
describe('readTextFile ENOENT handling', () => {
it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => {
const resourceNotFoundError = {
code: ACP_ERROR_CODES.RESOURCE_NOT_FOUND,
message: 'File not found',
};
it('parses path from ACP ENOENT message (quoted)', async () => {
const client = {
readTextFile: vi.fn().mockRejectedValue(resourceNotFoundError),
readTextFile: vi
.fn()
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
@@ -33,20 +30,15 @@ describe('AcpFileSystemService', () => {
createFallback(),
);
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
code: 'ENOENT',
errno: -2,
path: '/some/file.txt',
path: '/remote/file.txt',
});
});
it('re-throws other errors unchanged', async () => {
const otherError = {
code: ACP_ERROR_CODES.INTERNAL_ERROR,
message: 'Internal error',
};
it('falls back to requested path when none provided', async () => {
const client = {
readTextFile: vi.fn().mockRejectedValue(otherError),
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
@@ -56,34 +48,12 @@ describe('AcpFileSystemService', () => {
createFallback(),
);
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
code: ACP_ERROR_CODES.INTERNAL_ERROR,
message: 'Internal error',
await expect(
svc.readTextFile('/fallback/path.txt'),
).rejects.toMatchObject({
code: 'ENOENT',
path: '/fallback/path.txt',
});
});
it('uses fallback when readTextFile capability is disabled', async () => {
const client = {
readTextFile: vi.fn(),
} as unknown as import('../acp.js').Client;
const fallback = createFallback();
(fallback.readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(
'fallback content',
);
const svc = new AcpFileSystemService(
client,
'session-3',
{ readTextFile: false, writeTextFile: true },
fallback,
);
const result = await svc.readTextFile('/some/file.txt');
expect(result).toBe('fallback content');
expect(fallback.readTextFile).toHaveBeenCalledWith('/some/file.txt');
expect(client.readTextFile).not.toHaveBeenCalled();
});
});
});

View File

@@ -6,7 +6,6 @@
import type { FileSystemService } from '@qwen-code/qwen-code-core';
import type * as acp from '../acp.js';
import { ACP_ERROR_CODES } from '../errorCodes.js';
/**
* ACP client-based implementation of FileSystemService
@@ -24,31 +23,25 @@ export class AcpFileSystemService implements FileSystemService {
return this.fallback.readTextFile(filePath);
}
let response: { content: string };
try {
response = await this.client.readTextFile({
path: filePath,
sessionId: this.sessionId,
line: null,
limit: null,
});
} catch (error) {
const errorCode =
typeof error === 'object' && error !== null && 'code' in error
? (error as { code?: unknown }).code
: undefined;
const response = await this.client.readTextFile({
path: filePath,
sessionId: this.sessionId,
line: null,
limit: null,
});
if (errorCode === ACP_ERROR_CODES.RESOURCE_NOT_FOUND) {
const err = new Error(
`File not found: ${filePath}`,
) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
err.path = filePath;
throw err;
}
throw error;
if (response.content.startsWith('ERROR: ENOENT:')) {
// Treat ACP error strings as structured ENOENT errors without
// assuming a specific platform format.
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
const err = new Error(response.content) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
const rawPath = match?.groups?.['path']?.trim();
err['path'] = rawPath
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
: filePath;
throw err;
}
return response.content;

View File

@@ -122,10 +122,9 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Auto-completion
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
// Completion navigation uses only arrow keys
// Ctrl+P/N are reserved for history navigation (HISTORY_UP/DOWN)
[Command.COMPLETION_UP]: [{ key: 'up' }],
[Command.COMPLETION_DOWN]: [{ key: 'down' }],
// Completion navigation (arrow or Ctrl+P/N)
[Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
[Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
// Text input
// Must also exclude shift to allow shift+enter for newline

View File

@@ -434,16 +434,6 @@ const SETTINGS_SCHEMA = {
'Show welcome back dialog when returning to a project with conversation history.',
showInDialog: true,
},
enableUserFeedback: {
type: 'boolean',
label: 'Enable User Feedback',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Show optional feedback dialog after conversations to help improve Qwen performance.',
showInDialog: true,
},
accessibility: {
type: 'object',
label: 'Accessibility',
@@ -474,15 +464,6 @@ const SETTINGS_SCHEMA = {
},
},
},
feedbackLastShownTimestamp: {
type: 'number',
label: 'Feedback Last Shown Timestamp',
category: 'UI',
requiresRestart: false,
default: 0,
description: 'The last time the feedback dialog was shown.',
showInDialog: false,
},
},
},

View File

@@ -289,13 +289,6 @@ export default {
'Show Citations': 'Quellenangaben anzeigen',
'Custom Witty Phrases': 'Benutzerdefinierte Witzige Sprüche',
'Enable Welcome Back': 'Willkommen-zurück aktivieren',
'Enable User Feedback': 'Benutzerfeedback aktivieren',
'How is Qwen doing this session? (optional)':
'Wie macht sich Qwen in dieser Sitzung? (optional)',
Bad: 'Schlecht',
Good: 'Gut',
'Not Sure Yet': 'Noch nicht sicher',
'Any other key': 'Beliebige andere Taste',
'Disable Loading Phrases': 'Ladesprüche deaktivieren',
'Screen Reader Mode': 'Bildschirmleser-Modus',
'IDE Mode': 'IDE-Modus',

View File

@@ -286,13 +286,6 @@ export default {
'Show Citations': 'Show Citations',
'Custom Witty Phrases': 'Custom Witty Phrases',
'Enable Welcome Back': 'Enable Welcome Back',
'Enable User Feedback': 'Enable User Feedback',
'How is Qwen doing this session? (optional)':
'How is Qwen doing this session? (optional)',
Bad: 'Bad',
Good: 'Good',
'Not Sure Yet': 'Not Sure Yet',
'Any other key': 'Any other key',
'Disable Loading Phrases': 'Disable Loading Phrases',
'Screen Reader Mode': 'Screen Reader Mode',
'IDE Mode': 'IDE Mode',

View File

@@ -289,13 +289,6 @@ export default {
'Show Citations': 'Показывать цитаты',
'Custom Witty Phrases': 'Пользовательские остроумные фразы',
'Enable Welcome Back': 'Включить приветствие при возврате',
'Enable User Feedback': 'Включить отзывы пользователей',
'How is Qwen doing this session? (optional)':
'Как дела у Qwen в этой сессии? (необязательно)',
Bad: 'Плохо',
Good: 'Хорошо',
'Not Sure Yet': 'Пока не уверен',
'Any other key': 'Любая другая клавиша',
'Disable Loading Phrases': 'Отключить фразы при загрузке',
'Screen Reader Mode': 'Режим программы чтения с экрана',
'IDE Mode': 'Режим IDE',

View File

@@ -277,12 +277,6 @@ export default {
'Show Citations': '显示引用',
'Custom Witty Phrases': '自定义诙谐短语',
'Enable Welcome Back': '启用欢迎回来',
'Enable User Feedback': '启用用户反馈',
'How is Qwen doing this session? (optional)': 'Qwen 这次表现如何?(可选)',
Bad: '不满意',
Good: '满意',
'Not Sure Yet': '暂不评价',
'Any other key': '任意其他键',
'Disable Loading Phrases': '禁用加载短语',
'Screen Reader Mode': '屏幕阅读器模式',
'IDE Mode': 'IDE 模式',

View File

@@ -45,7 +45,6 @@ import process from 'node:process';
import { useHistory } from './hooks/useHistoryManager.js';
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useFeedbackDialog } from './hooks/useFeedbackDialog.js';
import { useAuthCommand } from './auth/useAuth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
@@ -1196,19 +1195,6 @@ export const AppContainer = (props: AppContainerProps) => {
isApprovalModeDialogOpen ||
isResumeDialogOpen;
const {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
} = useFeedbackDialog({
config,
settings,
streamingState,
history: historyManager.history,
sessionStats,
});
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
@@ -1305,8 +1291,6 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
}),
[
isThemeDialogOpen,
@@ -1397,8 +1381,6 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
],
);
@@ -1439,10 +1421,6 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
}),
[
handleThemeSelect,
@@ -1478,10 +1456,6 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
],
);

View File

@@ -1,61 +0,0 @@
import { Box, Text } from 'ink';
import type React from 'react';
import { t } from '../i18n/index.js';
import { useUIActions } from './contexts/UIActionsContext.js';
import { useUIState } from './contexts/UIStateContext.js';
import { useKeypress } from './hooks/useKeypress.js';
const FEEDBACK_OPTIONS = {
GOOD: 1,
BAD: 2,
NOT_SURE: 3,
} as const;
const FEEDBACK_OPTION_KEYS = {
[FEEDBACK_OPTIONS.GOOD]: '1',
[FEEDBACK_OPTIONS.BAD]: '2',
[FEEDBACK_OPTIONS.NOT_SURE]: 'any',
} as const;
export const FEEDBACK_DIALOG_KEYS = ['1', '2'] as const;
export const FeedbackDialog: React.FC = () => {
const uiState = useUIState();
const uiActions = useUIActions();
useKeypress(
(key) => {
if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.GOOD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.BAD);
} else {
uiActions.submitFeedback(FEEDBACK_OPTIONS.NOT_SURE);
}
uiActions.closeFeedbackDialog();
},
{ isActive: uiState.isFeedbackDialogOpen },
);
return (
<Box flexDirection="column" marginY={1}>
<Box>
<Text color="cyan"> </Text>
<Text bold>{t('How is Qwen doing this session? (optional)')}</Text>
</Box>
<Box marginTop={1}>
<Text color="cyan">
{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]}:{' '}
</Text>
<Text>{t('Good')}</Text>
<Text> </Text>
<Text color="cyan">{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]}: </Text>
<Text>{t('Bad')}</Text>
<Text> </Text>
<Text color="cyan">{t('Any other key')}: </Text>
<Text>{t('Not Sure Yet')}</Text>
</Box>
</Box>
);
};

View File

@@ -4,11 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config,
ContentGeneratorConfig,
ModelProvidersConfig,
} from '@qwen-code/qwen-code-core';
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
import {
AuthEvent,
AuthType,
@@ -218,19 +214,11 @@ export const useAuthCommand = (
if (authType === AuthType.USE_OPENAI) {
if (credentials) {
// Pass settings.model.generationConfig to updateCredentials so it can be merged
// after clearing provider-sourced config. This ensures settings.json generationConfig
// fields (e.g., samplingParams, timeout) are preserved.
const settingsGenerationConfig = settings.merged.model
?.generationConfig as Partial<ContentGeneratorConfig> | undefined;
config.updateCredentials(
{
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
},
settingsGenerationConfig,
);
config.updateCredentials({
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
});
await performAuth(authType, credentials);
}
return;
@@ -238,13 +226,7 @@ export const useAuthCommand = (
await performAuth(authType);
},
[
config,
performAuth,
isProviderManagedModel,
onAuthError,
settings.merged.model?.generationConfig,
],
[config, performAuth, isProviderManagedModel, onAuthError],
);
const openAuthDialog = useCallback(() => {

View File

@@ -26,7 +26,6 @@ import { useSettings } from '../contexts/SettingsContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { StreamingState } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { FeedbackDialog } from '../FeedbackDialog.js';
import { t } from '../../i18n/index.js';
export const Composer = () => {
@@ -135,8 +134,6 @@ export const Composer = () => {
</OverflowProvider>
)}
{uiState.isFeedbackDialogOpen && <FeedbackDialog />}
{uiState.isInputActive && (
<InputPrompt
buffer={uiState.buffer}

View File

@@ -33,9 +33,6 @@ vi.mock('../hooks/useCommandCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../hooks/useReverseSearchCompletion.js');
vi.mock('../utils/clipboardUtils.js');
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false })),
}));
const mockSlashCommands: SlashCommand[] = [
{
@@ -281,7 +278,7 @@ describe('InputPrompt', () => {
unmount();
});
it('should call completion.navigateUp for up arrow when suggestions are showing', async () => {
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
@@ -296,22 +293,19 @@ describe('InputPrompt', () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Test up arrow for completion navigation
// Test up arrow
stdin.write('\u001B[A'); // Up arrow
await wait();
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1);
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
// Ctrl+P should navigate history, not completion
stdin.write('\u0010'); // Ctrl+P
await wait();
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1);
expect(mockInputHistory.navigateUp).toHaveBeenCalled();
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(2);
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
unmount();
});
it('should call completion.navigateDown for down arrow when suggestions are showing', async () => {
it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
@@ -325,17 +319,14 @@ describe('InputPrompt', () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Test down arrow for completion navigation
// Test down arrow
stdin.write('\u001B[B'); // Down arrow
await wait();
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1);
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
// Ctrl+N should navigate history, not completion
stdin.write('\u000E'); // Ctrl+N
await wait();
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1);
expect(mockInputHistory.navigateDown).toHaveBeenCalled();
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(2);
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
unmount();
});
@@ -773,8 +764,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -802,8 +791,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -831,8 +818,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -860,8 +845,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -889,8 +872,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -919,8 +900,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -948,8 +927,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -978,8 +955,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1008,8 +983,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1038,8 +1011,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1068,8 +1039,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1100,8 +1069,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1130,8 +1097,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1162,8 +1127,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();

View File

@@ -36,8 +36,6 @@ import {
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
export interface InputPromptProps {
buffer: TextBuffer;
onSubmit: (value: string) => void;
@@ -102,7 +100,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
isEmbeddedShellFocused,
}) => {
const isShellFocused = useShellFocusState();
const uiState = useUIState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -138,8 +135,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
commandContext,
reverseSearchActive,
config,
// Suppress completion when history navigation just occurred
!justNavigatedHistory,
);
const reverseSearchCompletion = useReverseSearchCompletion(
@@ -224,9 +219,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const inputHistory = useInputHistory({
userMessages,
onSubmit: handleSubmitAndClear,
// History navigation (Ctrl+P/N) now always works since completion navigation
// only uses arrow keys. Only disable in shell mode.
isActive: !shellModeActive,
isActive:
(!completion.showSuggestions || completion.suggestions.length === 1) &&
!shellModeActive,
currentQuery: buffer.text,
onChange: customSetTextAndResetCompletionSignal,
});
@@ -331,14 +326,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Intercept feedback dialog option keys (1, 2) when dialog is open
if (
uiState.isFeedbackDialogOpen &&
(FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)
) {
return;
}
// Reset ESC count and hide prompt on any non-ESC key
if (key.name !== 'escape') {
if (escPressCount > 0 || showEscapePrompt) {
@@ -683,7 +670,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
recentPasteTime,
commandSearchActive,
commandSearchCompletion,
uiState,
],
);

View File

@@ -275,7 +275,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
persistModelSelection(settings, effectiveModelId);
persistAuthTypeSelection(settings, effectiveAuthType);
const baseUrl = after?.baseUrl ?? t('(default)');
const baseUrl = after?.baseUrl ?? '(default)';
const maskedKey = maskApiKey(after?.apiKey);
uiState?.historyManager.addItem(
{
@@ -322,7 +322,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
<>
<ConfigRow
label="Base URL"
value={effectiveConfig?.baseUrl ?? t('(default)')}
value={effectiveConfig?.baseUrl ?? ''}
badge={formatSourceBadge(sources['baseUrl'])}
/>
<ConfigRow

View File

@@ -66,10 +66,6 @@ export interface UIActions {
openResumeDialog: () => void;
closeResumeDialog: () => void;
handleResume: (sessionId: string) => void;
// Feedback dialog
openFeedbackDialog: () => void;
closeFeedbackDialog: () => void;
submitFeedback: (rating: number) => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);

View File

@@ -126,8 +126,6 @@ export interface UIState {
// Subagent dialogs
isSubagentCreateDialogOpen: boolean;
isAgentsManagerDialogOpen: boolean;
// Feedback dialog
isFeedbackDialogOpen: boolean;
}
export const UIStateContext = createContext<UIState | null>(null);

View File

@@ -45,8 +45,6 @@ export function useCommandCompletion(
commandContext: CommandContext,
reverseSearchActive: boolean = false,
config?: Config,
// When false, suppresses showing suggestions (e.g., after history navigation)
active: boolean = true,
): UseCommandCompletionReturn {
const {
suggestions,
@@ -154,11 +152,7 @@ export function useCommandCompletion(
}, [suggestions, setActiveSuggestionIndex, setVisibleStartIndex]);
useEffect(() => {
if (
completionMode === CompletionMode.IDLE ||
reverseSearchActive ||
!active
) {
if (completionMode === CompletionMode.IDLE || reverseSearchActive) {
resetCompletionState();
return;
}
@@ -169,7 +163,6 @@ export function useCommandCompletion(
suggestions.length,
isLoadingSuggestions,
reverseSearchActive,
active,
resetCompletionState,
setShowSuggestions,
]);

View File

@@ -1,178 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import * as fs from 'node:fs';
import {
type Config,
logUserFeedback,
UserFeedbackEvent,
type UserFeedbackRating,
isNodeError,
AuthType,
} from '@qwen-code/qwen-code-core';
import { StreamingState, MessageType, type HistoryItem } from '../types.js';
import {
SettingScope,
type LoadedSettings,
USER_SETTINGS_PATH,
} from '../../config/settings.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import stripJsonComments from 'strip-json-comments';
const FEEDBACK_SHOW_PROBABILITY = 0.25; // 25% probability of showing feedback dialog
const MIN_TOOL_CALLS = 10; // Minimum tool calls to show feedback dialog
const MIN_USER_MESSAGES = 5; // Minimum user messages to show feedback dialog
// Fatigue mechanism constants
const FEEDBACK_COOLDOWN_HOURS = 24; // Hours to wait before showing feedback dialog again
/**
* Check if the last message in the conversation history is an AI response
*/
const lastMessageIsAIResponse = (history: HistoryItem[]): boolean =>
history.length > 0 && history[history.length - 1].type === MessageType.GEMINI;
/**
* Read feedbackLastShownTimestamp directly from the user settings file
*/
const getFeedbackLastShownTimestampFromFile = (): number => {
try {
if (fs.existsSync(USER_SETTINGS_PATH)) {
const content = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
const settings = JSON.parse(stripJsonComments(content));
return settings?.ui?.feedbackLastShownTimestamp ?? 0;
}
} catch (error) {
if (isNodeError(error) && error.code !== 'ENOENT') {
console.warn(
'Failed to read feedbackLastShownTimestamp from settings file:',
error,
);
}
}
return 0;
};
/**
* Check if we should show the feedback dialog based on fatigue mechanism
*/
const shouldShowFeedbackBasedOnFatigue = (): boolean => {
const feedbackLastShownTimestamp = getFeedbackLastShownTimestampFromFile();
const now = Date.now();
const timeSinceLastShown = now - feedbackLastShownTimestamp;
const cooldownMs = FEEDBACK_COOLDOWN_HOURS * 60 * 60 * 1000;
return timeSinceLastShown >= cooldownMs;
};
/**
* Check if the session meets the minimum requirements for showing feedback
* Either tool calls > 10 OR user messages > 5
*/
const meetsMinimumSessionRequirements = (
sessionStats: SessionStatsState,
): boolean => {
const toolCallsCount = sessionStats.metrics.tools.totalCalls;
const userMessagesCount = sessionStats.promptCount;
return (
toolCallsCount > MIN_TOOL_CALLS || userMessagesCount > MIN_USER_MESSAGES
);
};
export interface UseFeedbackDialogProps {
config: Config;
settings: LoadedSettings;
streamingState: StreamingState;
history: HistoryItem[];
sessionStats: SessionStatsState;
}
export const useFeedbackDialog = ({
config,
settings,
streamingState,
history,
sessionStats,
}: UseFeedbackDialogProps) => {
// Feedback dialog state
const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false);
const openFeedbackDialog = useCallback(() => {
setIsFeedbackDialogOpen(true);
// Record the timestamp when feedback dialog is shown (fire and forget)
settings.setValue(
SettingScope.User,
'ui.feedbackLastShownTimestamp',
Date.now(),
);
}, [settings]);
const closeFeedbackDialog = useCallback(
() => setIsFeedbackDialogOpen(false),
[],
);
const submitFeedback = useCallback(
(rating: number) => {
// Create and log the feedback event
const feedbackEvent = new UserFeedbackEvent(
sessionStats.sessionId,
rating as UserFeedbackRating,
config.getModel(),
config.getApprovalMode(),
);
logUserFeedback(config, feedbackEvent);
closeFeedbackDialog();
},
[config, sessionStats, closeFeedbackDialog],
);
useEffect(() => {
const checkAndShowFeedback = () => {
if (streamingState === StreamingState.Idle && history.length > 0) {
// Show feedback dialog if:
// 1. User is authenticated via QWEN_OAUTH
// 2. Qwen logger is enabled (required for feedback submission)
// 3. User feedback is enabled in settings
// 4. The last message is an AI response
// 5. Random chance (25% probability)
// 6. Meets minimum requirements (tool calls > 10 OR user messages > 5)
// 7. Fatigue mechanism allows showing (not shown recently across sessions)
if (
config.getAuthType() !== AuthType.QWEN_OAUTH ||
!config.getUsageStatisticsEnabled() ||
settings.merged.ui?.enableUserFeedback === false ||
!lastMessageIsAIResponse(history) ||
Math.random() > FEEDBACK_SHOW_PROBABILITY ||
!meetsMinimumSessionRequirements(sessionStats)
) {
return;
}
// Check fatigue mechanism (synchronous)
if (shouldShowFeedbackBasedOnFatigue()) {
openFeedbackDialog();
}
}
};
checkAndShowFeedback();
}, [
streamingState,
history,
sessionStats,
isFeedbackDialogOpen,
openFeedbackDialog,
settings.merged.ui?.enableUserFeedback,
config,
]);
return {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
};
};

View File

@@ -38,10 +38,10 @@ describe('keyMatchers', () => {
[Command.NAVIGATION_DOWN]: (key: Key) => key.name === 'down',
[Command.ACCEPT_SUGGESTION]: (key: Key) =>
key.name === 'tab' || (key.name === 'return' && !key.ctrl),
// Completion navigation only uses arrow keys (not Ctrl+P/N)
// to allow Ctrl+P/N to always navigate history
[Command.COMPLETION_UP]: (key: Key) => key.name === 'up',
[Command.COMPLETION_DOWN]: (key: Key) => key.name === 'down',
[Command.COMPLETION_UP]: (key: Key) =>
key.name === 'up' || (key.ctrl && key.name === 'p'),
[Command.COMPLETION_DOWN]: (key: Key) =>
key.name === 'down' || (key.ctrl && key.name === 'n'),
[Command.ESCAPE]: (key: Key) => key.name === 'escape',
[Command.SUBMIT]: (key: Key) =>
key.name === 'return' && !key.ctrl && !key.meta && !key.paste,
@@ -164,26 +164,14 @@ describe('keyMatchers', () => {
negative: [createKey('return', { ctrl: true }), createKey('space')],
},
{
// Completion navigation only uses arrow keys (not Ctrl+P/N)
// to allow Ctrl+P/N to always navigate history
command: Command.COMPLETION_UP,
positive: [createKey('up')],
negative: [
createKey('p'),
createKey('down'),
createKey('p', { ctrl: true }),
],
positive: [createKey('up'), createKey('p', { ctrl: true })],
negative: [createKey('p'), createKey('down')],
},
{
// Completion navigation only uses arrow keys (not Ctrl+P/N)
// to allow Ctrl+P/N to always navigate history
command: Command.COMPLETION_DOWN,
positive: [createKey('down')],
negative: [
createKey('n'),
createKey('up'),
createKey('n', { ctrl: true }),
],
positive: [createKey('down'), createKey('n', { ctrl: true })],
negative: [createKey('n'), createKey('up')],
},
// Text input

View File

@@ -1,722 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
AuthType,
resolveModelConfig,
type ProviderModelConfig,
} from '@qwen-code/qwen-code-core';
import {
getAuthTypeFromEnv,
resolveCliGenerationConfig,
} from './modelConfigUtils.js';
import type { Settings } from '../config/settings.js';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...original,
resolveModelConfig: vi.fn(),
};
});
describe('modelConfigUtils', () => {
describe('getAuthTypeFromEnv', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
// Start with a clean env - getAuthTypeFromEnv only checks auth-related vars
process.env = {};
});
afterEach(() => {
process.env = originalEnv;
});
it('should return USE_OPENAI when all OpenAI env vars are set', () => {
process.env['OPENAI_API_KEY'] = 'test-key';
process.env['OPENAI_MODEL'] = 'gpt-4';
process.env['OPENAI_BASE_URL'] = 'https://api.openai.com';
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_OPENAI);
});
it('should return undefined when OpenAI env vars are incomplete', () => {
process.env['OPENAI_API_KEY'] = 'test-key';
process.env['OPENAI_MODEL'] = 'gpt-4';
// Missing OPENAI_BASE_URL
expect(getAuthTypeFromEnv()).toBeUndefined();
});
it('should return QWEN_OAUTH when QWEN_OAUTH is set', () => {
process.env['QWEN_OAUTH'] = 'true';
expect(getAuthTypeFromEnv()).toBe(AuthType.QWEN_OAUTH);
});
it('should return USE_GEMINI when Gemini env vars are set', () => {
process.env['GEMINI_API_KEY'] = 'test-key';
process.env['GEMINI_MODEL'] = 'gemini-pro';
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_GEMINI);
});
it('should return undefined when Gemini env vars are incomplete', () => {
process.env['GEMINI_API_KEY'] = 'test-key';
// Missing GEMINI_MODEL
expect(getAuthTypeFromEnv()).toBeUndefined();
});
it('should return USE_VERTEX_AI when Google env vars are set', () => {
process.env['GOOGLE_API_KEY'] = 'test-key';
process.env['GOOGLE_MODEL'] = 'vertex-model';
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_VERTEX_AI);
});
it('should return undefined when Google env vars are incomplete', () => {
process.env['GOOGLE_API_KEY'] = 'test-key';
// Missing GOOGLE_MODEL
expect(getAuthTypeFromEnv()).toBeUndefined();
});
it('should return USE_ANTHROPIC when Anthropic env vars are set', () => {
process.env['ANTHROPIC_API_KEY'] = 'test-key';
process.env['ANTHROPIC_MODEL'] = 'claude-3';
process.env['ANTHROPIC_BASE_URL'] = 'https://api.anthropic.com';
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_ANTHROPIC);
});
it('should return undefined when Anthropic env vars are incomplete', () => {
process.env['ANTHROPIC_API_KEY'] = 'test-key';
process.env['ANTHROPIC_MODEL'] = 'claude-3';
// Missing ANTHROPIC_BASE_URL
expect(getAuthTypeFromEnv()).toBeUndefined();
});
it('should prioritize QWEN_OAUTH over other auth types when explicitly set', () => {
process.env['QWEN_OAUTH'] = 'true';
process.env['OPENAI_API_KEY'] = 'test-key';
process.env['OPENAI_MODEL'] = 'gpt-4';
process.env['OPENAI_BASE_URL'] = 'https://api.openai.com';
// QWEN_OAUTH is checked first, so it should be returned even when other auth vars are set
expect(getAuthTypeFromEnv()).toBe(AuthType.QWEN_OAUTH);
});
it('should return undefined when no auth env vars are set', () => {
expect(getAuthTypeFromEnv()).toBeUndefined();
});
});
describe('resolveCliGenerationConfig', () => {
const originalEnv = process.env;
const originalConsoleWarn = console.warn;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
console.warn = vi.fn();
});
afterEach(() => {
process.env = originalEnv;
console.warn = originalConsoleWarn;
vi.clearAllMocks();
});
function makeMockSettings(overrides?: Partial<Settings>): Settings {
return {
model: { name: 'default-model' },
security: {
auth: {
apiKey: 'settings-api-key',
baseUrl: 'https://settings.example.com',
},
},
...overrides,
} as Settings;
}
it('should resolve config from argv with highest precedence', () => {
const argv = {
model: 'argv-model',
openaiApiKey: 'argv-key',
openaiBaseUrl: 'https://argv.example.com',
};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'argv-model',
apiKey: 'argv-key',
baseUrl: 'https://argv.example.com',
},
sources: {
model: { kind: 'cli', detail: '--model' },
apiKey: { kind: 'cli', detail: '--openaiApiKey' },
baseUrl: { kind: 'cli', detail: '--openaiBaseUrl' },
},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.model).toBe('argv-model');
expect(result.apiKey).toBe('argv-key');
expect(result.baseUrl).toBe('https://argv.example.com');
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
cli: {
model: 'argv-model',
apiKey: 'argv-key',
baseUrl: 'https://argv.example.com',
},
}),
);
});
it('should resolve config from settings when argv is not provided', () => {
const argv = {};
const settings = makeMockSettings({
model: { name: 'settings-model' },
security: {
auth: {
apiKey: 'settings-key',
baseUrl: 'https://settings.example.com',
},
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'settings-model',
apiKey: 'settings-key',
baseUrl: 'https://settings.example.com',
},
sources: {
model: { kind: 'settings', detail: 'model.name' },
apiKey: { kind: 'settings', detail: 'security.auth.apiKey' },
baseUrl: { kind: 'settings', detail: 'security.auth.baseUrl' },
},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.model).toBe('settings-model');
expect(result.apiKey).toBe('settings-key');
expect(result.baseUrl).toBe('https://settings.example.com');
});
it('should merge generationConfig from settings', () => {
const argv = {};
const settings = makeMockSettings({
model: {
name: 'test-model',
generationConfig: {
samplingParams: {
temperature: 0.7,
max_tokens: 1000,
},
timeout: 5000,
} as Record<string, unknown>,
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
samplingParams: {
temperature: 0.7,
max_tokens: 1000,
},
timeout: 5000,
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig.samplingParams?.temperature).toBe(0.7);
expect(result.generationConfig.samplingParams?.max_tokens).toBe(1000);
expect(result.generationConfig.timeout).toBe(5000);
});
it('should resolve OpenAI logging from argv', () => {
const argv = {
openaiLogging: true,
openaiLoggingDir: '/custom/log/dir',
};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig.enableOpenAILogging).toBe(true);
expect(result.generationConfig.openAILoggingDir).toBe('/custom/log/dir');
});
it('should resolve OpenAI logging from settings when argv is undefined', () => {
const argv = {};
const settings = makeMockSettings({
model: {
name: 'test-model',
enableOpenAILogging: true,
openAILoggingDir: '/settings/log/dir',
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig.enableOpenAILogging).toBe(true);
expect(result.generationConfig.openAILoggingDir).toBe(
'/settings/log/dir',
);
});
it('should default OpenAI logging to false when not provided', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig.enableOpenAILogging).toBe(false);
});
it('should find modelProvider from settings when authType and model match', () => {
const argv = { model: 'provider-model' };
const modelProvider: ProviderModelConfig = {
id: 'provider-model',
name: 'Provider Model',
generationConfig: {
samplingParams: { temperature: 0.8 },
},
};
const settings = makeMockSettings({
modelProviders: {
[AuthType.USE_OPENAI]: [modelProvider],
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'provider-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider,
}),
);
});
it('should find modelProvider from settings.model.name when argv.model is not provided', () => {
const argv = {};
const modelProvider: ProviderModelConfig = {
id: 'settings-model',
name: 'Settings Model',
generationConfig: {
samplingParams: { temperature: 0.9 },
},
};
const settings = makeMockSettings({
model: { name: 'settings-model' },
modelProviders: {
[AuthType.USE_OPENAI]: [modelProvider],
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'settings-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider,
}),
);
});
it('should not find modelProvider when authType is undefined', () => {
const argv = { model: 'test-model' };
const settings = makeMockSettings({
modelProviders: {
[AuthType.USE_OPENAI]: [{ id: 'test-model', name: 'Test Model' }],
},
});
const selectedAuthType = undefined;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider: undefined,
}),
);
});
it('should not find modelProvider when modelProviders is not an array', () => {
const argv = { model: 'test-model' };
const settings = makeMockSettings({
modelProviders: {
[AuthType.USE_OPENAI]: null as unknown as ProviderModelConfig[],
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider: undefined,
}),
);
});
it('should log warnings from resolveModelConfig', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: ['Warning 1', 'Warning 2'],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(console.warn).toHaveBeenCalledWith('Warning 1');
expect(console.warn).toHaveBeenCalledWith('Warning 2');
});
it('should use custom env when provided', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
const customEnv = {
OPENAI_API_KEY: 'custom-key',
OPENAI_MODEL: 'custom-model',
};
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'custom-model',
apiKey: 'custom-key',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
env: customEnv,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
env: customEnv,
}),
);
});
it('should use process.env when env is not provided', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
}),
);
});
it('should return empty strings for missing model, apiKey, and baseUrl', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: '',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.model).toBe('');
expect(result.apiKey).toBe('');
expect(result.baseUrl).toBe('');
});
it('should merge resolved config with logging settings', () => {
const argv = {
openaiLogging: true,
};
const settings = makeMockSettings({
model: {
name: 'test-model',
generationConfig: {
timeout: 5000,
} as Record<string, unknown>,
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: 'test-key',
baseUrl: 'https://test.com',
samplingParams: { temperature: 0.5 },
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig).toEqual({
model: 'test-model',
apiKey: 'test-key',
baseUrl: 'https://test.com',
samplingParams: { temperature: 0.5 },
enableOpenAILogging: true,
openAILoggingDir: undefined,
});
});
it('should handle settings without model property', () => {
const argv = {};
const settings = makeMockSettings({
model: undefined as unknown as Settings['model'],
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: '',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.model).toBe('');
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
settings: expect.objectContaining({
model: undefined,
}),
}),
);
});
it('should handle settings without security.auth property', () => {
const argv = {};
const settings = makeMockSettings({
security: undefined,
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: '',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
settings: expect.objectContaining({
apiKey: undefined,
baseUrl: undefined,
}),
}),
);
});
});
});

View File

@@ -44,31 +44,20 @@ export interface ResolvedCliGenerationConfig {
}
export function getAuthTypeFromEnv(): AuthType | undefined {
if (process.env['OPENAI_API_KEY']) {
return AuthType.USE_OPENAI;
}
if (process.env['QWEN_OAUTH']) {
return AuthType.QWEN_OAUTH;
}
if (
process.env['OPENAI_API_KEY'] &&
process.env['OPENAI_MODEL'] &&
process.env['OPENAI_BASE_URL']
) {
return AuthType.USE_OPENAI;
}
if (process.env['GEMINI_API_KEY'] && process.env['GEMINI_MODEL']) {
if (process.env['GEMINI_API_KEY']) {
return AuthType.USE_GEMINI;
}
if (process.env['GOOGLE_API_KEY'] && process.env['GOOGLE_MODEL']) {
if (process.env['GOOGLE_API_KEY']) {
return AuthType.USE_VERTEX_AI;
}
if (
process.env['ANTHROPIC_API_KEY'] &&
process.env['ANTHROPIC_MODEL'] &&
process.env['ANTHROPIC_BASE_URL']
) {
if (process.env['ANTHROPIC_API_KEY']) {
return AuthType.USE_ANTHROPIC;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.7.1",
"version": "0.7.2-preview.0",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -708,15 +708,12 @@ export class Config {
* Exclusive for `OpenAIKeyPrompt` to update credentials via `/auth`
* Delegates to ModelsConfig.
*/
updateCredentials(
credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
): void {
this._modelsConfig.updateCredentials(credentials, settingsGenerationConfig);
updateCredentials(credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
}): void {
this._modelsConfig.updateCredentials(credentials);
}
/**

View File

@@ -102,14 +102,16 @@ export const QWEN_OAUTH_ALLOWED_MODELS = [
export const QWEN_OAUTH_MODELS: ModelConfig[] = [
{
id: 'coder-model',
name: 'coder-model',
description: 'The latest Qwen Coder model from Alibaba Cloud ModelStudio',
name: 'Qwen Coder',
description:
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
capabilities: { vision: false },
},
{
id: 'vision-model',
name: 'vision-model',
description: 'The latest Qwen Vision model from Alibaba Cloud ModelStudio',
name: 'Qwen Vision',
description:
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)',
capabilities: { vision: true },
},
];

View File

@@ -191,7 +191,7 @@ describe('ModelsConfig', () => {
expect(gc.apiKeyEnvKey).toBe('API_KEY_SHARED');
});
it('should use provider config when modelId exists in registry even after updateCredentials', () => {
it('should preserve settings generationConfig when model is updated via updateCredentials even if it matches modelProviders', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
@@ -213,7 +213,7 @@ describe('ModelsConfig', () => {
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
model: 'custom-model',
model: 'model-a',
samplingParams: { temperature: 0.9, max_tokens: 999 },
timeout: 9999,
maxRetries: 9,
@@ -235,30 +235,30 @@ describe('ModelsConfig', () => {
},
});
// User manually updates credentials via updateCredentials.
// Note: In practice, handleAuthSelect prevents using a modelId that matches a provider model,
// but if syncAfterAuthRefresh is called with a modelId that exists in registry,
// we should use provider config.
modelsConfig.updateCredentials({ apiKey: 'manual-key' });
// User manually updates the model via updateCredentials (e.g. key prompt flow).
// Even if the model ID matches a modelProviders entry, we must not apply provider defaults
// that would overwrite settings.model.generationConfig.
modelsConfig.updateCredentials({ model: 'model-a' });
// syncAfterAuthRefresh with a modelId that exists in registry should use provider config
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'model-a');
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
const gc = currentGenerationConfig(modelsConfig);
expect(gc.model).toBe('model-a');
// Provider config should be applied
expect(gc.samplingParams?.temperature).toBe(0.1);
expect(gc.samplingParams?.max_tokens).toBe(123);
expect(gc.timeout).toBe(111);
expect(gc.maxRetries).toBe(1);
expect(gc.samplingParams?.temperature).toBe(0.9);
expect(gc.samplingParams?.max_tokens).toBe(999);
expect(gc.timeout).toBe(9999);
expect(gc.maxRetries).toBe(9);
});
it('should preserve settings generationConfig when modelId does not exist in registry', () => {
it('should preserve settings generationConfig across multiple auth refreshes after updateCredentials', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'provider-model',
name: 'Provider Model',
id: 'model-a',
name: 'Model A',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_A',
generationConfig: {
@@ -270,12 +270,11 @@ describe('ModelsConfig', () => {
],
};
// Simulate settings with a custom model (not in registry)
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
model: 'custom-model',
model: 'model-a',
samplingParams: { temperature: 0.9, max_tokens: 999 },
timeout: 9999,
maxRetries: 9,
@@ -297,21 +296,25 @@ describe('ModelsConfig', () => {
},
});
// User manually sets credentials for a custom model (not in registry)
modelsConfig.updateCredentials({
apiKey: 'manual-key',
baseUrl: 'https://manual.example.com/v1',
model: 'custom-model',
model: 'model-a',
});
// First auth refresh - modelId doesn't exist in registry, so credentials should be preserved
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'custom-model');
// First auth refresh
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
// Second auth refresh should still preserve settings generationConfig
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'custom-model');
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
const gc = currentGenerationConfig(modelsConfig);
expect(gc.model).toBe('custom-model');
// Settings-sourced generation config should be preserved since modelId doesn't exist in registry
expect(gc.model).toBe('model-a');
expect(gc.samplingParams?.temperature).toBe(0.9);
expect(gc.samplingParams?.max_tokens).toBe(999);
expect(gc.timeout).toBe(9999);
@@ -678,120 +681,4 @@ describe('ModelsConfig', () => {
expect(modelsConfig.getModel()).toBe('updated-model');
expect(modelsConfig.getGenerationConfig().model).toBe('updated-model');
});
describe('getAllAvailableModels', () => {
it('should return all models across all authTypes', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'openai-model-1',
name: 'OpenAI Model 1',
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
},
{
id: 'openai-model-2',
name: 'OpenAI Model 2',
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
},
],
anthropic: [
{
id: 'anthropic-model-1',
name: 'Anthropic Model 1',
baseUrl: 'https://api.anthropic.com/v1',
envKey: 'ANTHROPIC_API_KEY',
},
],
gemini: [
{
id: 'gemini-model-1',
name: 'Gemini Model 1',
baseUrl: 'https://generativelanguage.googleapis.com/v1',
envKey: 'GEMINI_API_KEY',
},
],
};
const modelsConfig = new ModelsConfig({
modelProvidersConfig,
});
const allModels = modelsConfig.getAllAvailableModels();
// Should include qwen-oauth models (hard-coded)
const qwenModels = allModels.filter(
(m) => m.authType === AuthType.QWEN_OAUTH,
);
expect(qwenModels.length).toBeGreaterThan(0);
// Should include openai models
const openaiModels = allModels.filter(
(m) => m.authType === AuthType.USE_OPENAI,
);
expect(openaiModels.length).toBe(2);
expect(openaiModels.map((m) => m.id)).toContain('openai-model-1');
expect(openaiModels.map((m) => m.id)).toContain('openai-model-2');
// Should include anthropic models
const anthropicModels = allModels.filter(
(m) => m.authType === AuthType.USE_ANTHROPIC,
);
expect(anthropicModels.length).toBe(1);
expect(anthropicModels[0].id).toBe('anthropic-model-1');
// Should include gemini models
const geminiModels = allModels.filter(
(m) => m.authType === AuthType.USE_GEMINI,
);
expect(geminiModels.length).toBe(1);
expect(geminiModels[0].id).toBe('gemini-model-1');
});
it('should return empty array when no models are registered', () => {
const modelsConfig = new ModelsConfig();
const allModels = modelsConfig.getAllAvailableModels();
// Should still include qwen-oauth models (hard-coded)
expect(allModels.length).toBeGreaterThan(0);
const qwenModels = allModels.filter(
(m) => m.authType === AuthType.QWEN_OAUTH,
);
expect(qwenModels.length).toBeGreaterThan(0);
});
it('should return models with correct structure', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'test-model',
name: 'Test Model',
description: 'A test model',
baseUrl: 'https://api.example.com/v1',
envKey: 'TEST_API_KEY',
capabilities: {
vision: true,
},
},
],
};
const modelsConfig = new ModelsConfig({
modelProvidersConfig,
});
const allModels = modelsConfig.getAllAvailableModels();
const testModel = allModels.find((m) => m.id === 'test-model');
expect(testModel).toBeDefined();
expect(testModel?.id).toBe('test-model');
expect(testModel?.label).toBe('Test Model');
expect(testModel?.description).toBe('A test model');
expect(testModel?.authType).toBe(AuthType.USE_OPENAI);
expect(testModel?.isVision).toBe(true);
expect(testModel?.capabilities?.vision).toBe(true);
});
});
});

View File

@@ -203,18 +203,6 @@ export class ModelsConfig {
return this.modelRegistry.getModelsForAuthType(authType);
}
/**
* Get all available models across all authTypes
*/
getAllAvailableModels(): AvailableModel[] {
const allModels: AvailableModel[] = [];
for (const authType of Object.values(AuthType)) {
const models = this.modelRegistry.getModelsForAuthType(authType);
allModels.push(...models);
}
return allModels;
}
/**
* Check if a model exists for the given authType
*/
@@ -319,33 +307,6 @@ export class ModelsConfig {
return this.generationConfigSources;
}
/**
* Merge settings generation config, preserving existing values.
* Used when provider-sourced config is cleared but settings should still apply.
*/
mergeSettingsGenerationConfig(
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
): void {
if (!settingsGenerationConfig) {
return;
}
for (const field of MODEL_GENERATION_CONFIG_FIELDS) {
if (
!(field in this._generationConfig) &&
field in settingsGenerationConfig
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this._generationConfig as any)[field] =
settingsGenerationConfig[field];
this.generationConfigSources[field] = {
kind: 'settings',
detail: `model.generationConfig.${field}`,
};
}
}
}
/**
* Update credentials in generation config.
* Sets a flag to prevent syncAfterAuthRefresh from overriding these credentials.
@@ -353,20 +314,12 @@ export class ModelsConfig {
* When credentials are manually set, we clear all provider-sourced configuration
* to maintain provider atomicity (either fully applied or not at all).
* Other layers (CLI, env, settings, defaults) will participate in resolve.
*
* @param settingsGenerationConfig Optional generation config from settings.json
* to merge after clearing provider-sourced config.
* This ensures settings.model.generationConfig fields
* (e.g., samplingParams, timeout) are preserved.
*/
updateCredentials(
credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
): void {
updateCredentials(credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
}): void {
/**
* If any fields are updated here, we treat the resulting config as manually overridden
* and avoid applying modelProvider defaults during the next auth refresh.
@@ -406,14 +359,6 @@ export class ModelsConfig {
this.strictModelProviderSelection = false;
// Clear apiKeyEnvKey to prevent validation from requiring environment variable
this._generationConfig.apiKeyEnvKey = undefined;
// After clearing provider-sourced config, merge settings.model.generationConfig
// to ensure fields like samplingParams, timeout, etc. are preserved.
// This follows the resolution strategy where settings.model.generationConfig
// has lower priority than programmatic overrides but should still be applied.
if (settingsGenerationConfig) {
this.mergeSettingsGenerationConfig(settingsGenerationConfig);
}
}
/**
@@ -642,88 +587,50 @@ export class ModelsConfig {
}
/**
* Sync state after auth refresh with fallback strategy:
* 1. If modelId can be found in modelRegistry, use the config from modelRegistry.
* 2. Otherwise, if existing credentials exist in resolved generationConfig from other sources
* (not modelProviders), preserve them and update authType/modelId only.
* 3. Otherwise, fall back to default model for the authType.
* 4. If no default is available, leave the generationConfig incomplete and let
* resolveContentGeneratorConfigWithSources throw exceptions as expected.
* Called by Config.refreshAuth to sync state after auth refresh.
*
* IMPORTANT: If credentials were manually set via updateCredentials(),
* we should NOT override them with modelProvider defaults.
* This handles the case where user inputs credentials via OpenAIKeyPrompt
* after removing environment variables for a previously selected model.
*/
syncAfterAuthRefresh(authType: AuthType, modelId?: string): void {
this.strictModelProviderSelection = false;
const previousAuthType = this.currentAuthType;
this.currentAuthType = authType;
// Check if we have manually set credentials that should be preserved
const preserveManualCredentials = this.hasManualCredentials;
// If credentials were manually set, don't apply modelProvider defaults
// Just update the authType and preserve the manually set credentials
if (preserveManualCredentials && authType === AuthType.USE_OPENAI) {
this.strictModelProviderSelection = false;
this.currentAuthType = authType;
if (modelId) {
this._generationConfig.model = modelId;
}
return;
}
this.strictModelProviderSelection = false;
// Step 1: If modelId exists in registry, always use config from modelRegistry
// Manual credentials won't have a modelId that matches a provider model (handleAuthSelect prevents it),
// so if modelId exists in registry, we should always use provider config.
// This handles provider switching even within the same authType.
if (modelId && this.modelRegistry.hasModel(authType, modelId)) {
const resolved = this.modelRegistry.getModel(authType, modelId);
if (resolved) {
// Ensure applyResolvedModelDefaults can correctly apply authType-specific
// behavior (e.g., Qwen OAuth placeholder token) by setting currentAuthType
// before applying defaults.
this.currentAuthType = authType;
this.applyResolvedModelDefaults(resolved);
this.strictModelProviderSelection = true;
return;
}
}
// Step 2: Check if there are existing credentials from other sources (not modelProviders)
const apiKeySource = this.generationConfigSources['apiKey'];
const baseUrlSource = this.generationConfigSources['baseUrl'];
const hasExistingCredentials =
(this._generationConfig.apiKey &&
apiKeySource?.kind !== 'modelProviders') ||
(this._generationConfig.baseUrl &&
baseUrlSource?.kind !== 'modelProviders');
// Only preserve credentials if:
// 1. AuthType hasn't changed (credentials are authType-specific), AND
// 2. The modelId doesn't exist in the registry (if it did, we would have used provider config in Step 1), AND
// 3. Either:
// a. We have manual credentials (set via updateCredentials), OR
// b. We have existing credentials
// Note: Even if authType hasn't changed, switching to a different provider model (that exists in registry)
// will use provider config (Step 1), not preserve old credentials. This ensures credentials change when
// switching providers, independent of authType changes.
const isAuthTypeChange = previousAuthType !== authType;
const shouldPreserveCredentials =
!isAuthTypeChange &&
(modelId === undefined ||
!this.modelRegistry.hasModel(authType, modelId)) &&
(this.hasManualCredentials || hasExistingCredentials);
if (shouldPreserveCredentials) {
// Preserve existing credentials, just update authType and modelId if provided
if (modelId) {
this._generationConfig.model = modelId;
if (!this.generationConfigSources['model']) {
this.generationConfigSources['model'] = {
kind: 'programmatic',
detail: 'auth refresh (preserved credentials)',
};
}
}
return;
}
// Step 3: Fall back to default model for the authType
const defaultModel =
this.modelRegistry.getDefaultModelForAuthType(authType);
if (defaultModel) {
this.applyResolvedModelDefaults(defaultModel);
return;
}
// Step 4: No default available - leave generationConfig incomplete
// resolveContentGeneratorConfigWithSources will throw exceptions as expected
if (modelId) {
this._generationConfig.model = modelId;
if (!this.generationConfigSources['model']) {
this.generationConfigSources['model'] = {
kind: 'programmatic',
detail: 'auth refresh (no default model)',
};
} else {
// If the provided modelId doesn't exist in the registry for the new authType,
// use the default model for that authType instead of keeping the old model.
// This handles the case where switching from one authType (e.g., OPENAI with
// env vars) to another (e.g., qwen-oauth) - we should use the default model
// for the new authType, not the old model.
this.currentAuthType = authType;
const defaultModel =
this.modelRegistry.getDefaultModelForAuthType(authType);
if (defaultModel) {
this.applyResolvedModelDefaults(defaultModel);
}
}
}

View File

@@ -751,7 +751,6 @@ describe('getQwenOAuthClient', () => {
beforeEach(() => {
mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
originalFetch = global.fetch;
@@ -840,7 +839,9 @@ describe('getQwenOAuthClient', () => {
requireCachedCredentials: true,
}),
),
).rejects.toThrow('Please use /auth to re-authenticate.');
).rejects.toThrow(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
expect(global.fetch).not.toHaveBeenCalled();
@@ -1006,7 +1007,6 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
beforeEach(() => {
mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
originalFetch = global.fetch;
@@ -1202,7 +1202,6 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
beforeEach(() => {
mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
originalFetch = global.fetch;
@@ -1406,7 +1405,6 @@ describe('Browser Launch and Error Handling', () => {
beforeEach(() => {
mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
originalFetch = global.fetch;
@@ -2045,7 +2043,6 @@ describe('SharedTokenManager Integration in QwenOAuth2Client', () => {
it('should handle TokenManagerError types correctly in getQwenOAuthClient', async () => {
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
// Test different TokenManagerError types

View File

@@ -516,7 +516,9 @@ export async function getQwenOAuthClient(
}
if (options?.requireCachedCredentials) {
throw new Error('Please use /auth to re-authenticate.');
throw new Error(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
}
// If we couldn't obtain valid credentials via SharedTokenManager, fall back to
@@ -738,9 +740,11 @@ async function authWithQwenDeviceFlow(
// Emit device authorization event for UI integration immediately
qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, deviceAuth);
if (config.isBrowserLaunchSuppressed() || !config.isInteractive()) {
showFallbackMessage(deviceAuth.verification_uri_complete);
}
// Always show the fallback message in non-interactive environments to ensure
// users can see the authorization URL even if browser launching is attempted.
// This is critical for headless/remote environments where browser launching
// may silently fail without throwing an error.
showFallbackMessage(deviceAuth.verification_uri_complete);
// Try to open browser if not suppressed
if (!config.isBrowserLaunchSuppressed()) {

View File

@@ -35,7 +35,6 @@ export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
export const EVENT_SKILL_LAUNCH = 'qwen-code.skill_launch';
export const EVENT_AUTH = 'qwen-code.auth';
export const EVENT_USER_FEEDBACK = 'qwen-code.user_feedback';
// Performance Events
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';

View File

@@ -45,7 +45,6 @@ export {
logNextSpeakerCheck,
logAuth,
logSkillLaunch,
logUserFeedback,
} from './loggers.js';
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
export {
@@ -66,8 +65,6 @@ export {
NextSpeakerCheckEvent,
AuthEvent,
SkillLaunchEvent,
UserFeedbackEvent,
UserFeedbackRating,
} from './types.js';
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
export type { TelemetryEvent } from './types.js';

View File

@@ -38,7 +38,6 @@ import {
EVENT_INVALID_CHUNK,
EVENT_AUTH,
EVENT_SKILL_LAUNCH,
EVENT_USER_FEEDBACK,
} from './constants.js';
import {
recordApiErrorMetrics,
@@ -87,7 +86,6 @@ import type {
InvalidChunkEvent,
AuthEvent,
SkillLaunchEvent,
UserFeedbackEvent,
} from './types.js';
import type { UiEvent } from './uiTelemetry.js';
import { uiTelemetryService } from './uiTelemetry.js';
@@ -889,32 +887,3 @@ export function logSkillLaunch(config: Config, event: SkillLaunchEvent): void {
};
logger.emit(logRecord);
}
export function logUserFeedback(
config: Config,
event: UserFeedbackEvent,
): void {
const uiEvent = {
...event,
'event.name': EVENT_USER_FEEDBACK,
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
config.getChatRecordingService()?.recordUiTelemetryEvent(uiEvent);
QwenLogger.getInstance(config)?.logUserFeedbackEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
'event.name': EVENT_USER_FEEDBACK,
'event.timestamp': new Date().toISOString(),
};
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `User feedback: Rating ${event.rating} for session ${event.session_id}.`,
attributes,
};
logger.emit(logRecord);
}

View File

@@ -39,7 +39,6 @@ import type {
ExtensionDisableEvent,
AuthEvent,
SkillLaunchEvent,
UserFeedbackEvent,
RipgrepFallbackEvent,
EndSessionEvent,
} from '../types.js';
@@ -843,21 +842,6 @@ export class QwenLogger {
this.flushIfNeeded();
}
logUserFeedbackEvent(event: UserFeedbackEvent): void {
const rumEvent = this.createActionEvent('user', 'user_feedback', {
properties: {
session_id: event.session_id,
rating: event.rating,
model: event.model,
approval_mode: event.approval_mode,
prompt_id: event.prompt_id || '',
},
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
logChatCompressionEvent(event: ChatCompressionEvent): void {
const rumEvent = this.createActionEvent('misc', 'chat_compression', {
properties: {

View File

@@ -757,38 +757,6 @@ export class SkillLaunchEvent implements BaseTelemetryEvent {
}
}
export enum UserFeedbackRating {
BAD = 1,
FINE = 2,
GOOD = 3,
}
export class UserFeedbackEvent implements BaseTelemetryEvent {
'event.name': 'user_feedback';
'event.timestamp': string;
session_id: string;
rating: UserFeedbackRating;
model: string;
approval_mode: string;
prompt_id?: string;
constructor(
session_id: string,
rating: UserFeedbackRating,
model: string,
approval_mode: string,
prompt_id?: string,
) {
this['event.name'] = 'user_feedback';
this['event.timestamp'] = new Date().toISOString();
this.session_id = session_id;
this.rating = rating;
this.model = model;
this.approval_mode = approval_mode;
this.prompt_id = prompt_id;
}
}
export type TelemetryEvent =
| StartSessionEvent
| EndSessionEvent
@@ -818,8 +786,7 @@ export type TelemetryEvent =
| ToolOutputTruncatedEvent
| ModelSlashCommandEvent
| AuthEvent
| SkillLaunchEvent
| UserFeedbackEvent;
| SkillLaunchEvent;
export class ExtensionDisableEvent implements BaseTelemetryEvent {
'event.name': 'extension_disable';

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.7.1",
"version": "0.7.2-preview.0",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View File

@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.7.1",
"version": "0.7.2-preview.0",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {

View File

@@ -23,23 +23,3 @@ export const CLIENT_METHODS = {
session_request_permission: 'session/request_permission',
session_update: 'session/update',
} as const;
export const ACP_ERROR_CODES = {
// Parse error: invalid JSON received by server.
PARSE_ERROR: -32700,
// Invalid request: JSON is not a valid Request object.
INVALID_REQUEST: -32600,
// Method not found: method does not exist or is unavailable.
METHOD_NOT_FOUND: -32601,
// Invalid params: invalid method parameter(s).
INVALID_PARAMS: -32602,
// Internal error: implementation-defined server error.
INTERNAL_ERROR: -32603,
// Authentication required: must authenticate before operation.
AUTH_REQUIRED: -32000,
// Resource not found: e.g. missing file.
RESOURCE_NOT_FOUND: -32002,
} as const;
export type AcpErrorCode =
(typeof ACP_ERROR_CODES)[keyof typeof ACP_ERROR_CODES];

View File

@@ -28,7 +28,6 @@ import * as os from 'node:os';
import type { z } from 'zod';
import type { DiffManager } from './diff-manager.js';
import { OpenFilesManager } from './open-files-manager.js';
import { ACP_ERROR_CODES } from './constants/acpSchema.js';
class CORSError extends Error {
constructor(message: string) {
@@ -265,7 +264,7 @@ export class IDEServer {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: ACP_ERROR_CODES.AUTH_REQUIRED,
code: -32000,
message:
'Bad Request: No valid session ID provided for non-initialize request.',
},
@@ -284,7 +283,7 @@ export class IDEServer {
res.status(500).json({
jsonrpc: '2.0' as const,
error: {
code: ACP_ERROR_CODES.INTERNAL_ERROR,
code: -32603,
message: 'Internal server error',
},
id: null,

View File

@@ -5,7 +5,6 @@
*/
import { JSONRPC_VERSION } from '../types/acpTypes.js';
import { ACP_ERROR_CODES } from '../constants/acpSchema.js';
import type {
AcpMessage,
AcpPermissionRequest,
@@ -233,34 +232,12 @@ export class AcpConnection {
})
.catch((error) => {
if ('id' in message && typeof message.id === 'number') {
const errorMessage =
error instanceof Error
? error.message
: typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as { message: unknown }).message === 'string'
? (error as { message: string }).message
: String(error);
let errorCode: number = ACP_ERROR_CODES.INTERNAL_ERROR;
const errorCodeValue =
typeof error === 'object' && error !== null && 'code' in error
? (error as { code?: unknown }).code
: undefined;
if (typeof errorCodeValue === 'number') {
errorCode = errorCodeValue;
} else if (errorCodeValue === 'ENOENT') {
errorCode = ACP_ERROR_CODES.RESOURCE_NOT_FOUND;
}
this.messageHandler.sendResponseMessage(this.child, {
jsonrpc: JSONRPC_VERSION,
id: message.id,
error: {
code: errorCode,
message: errorMessage,
code: -32603,
message: error instanceof Error ? error.message : String(error),
},
});
}

View File

@@ -66,11 +66,6 @@ export class AcpFileHandler {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[ACP] Failed to read file ${params.path}:`, errorMsg);
const nodeError = error as NodeJS.ErrnoException;
if (nodeError?.code === 'ENOENT') {
throw error;
}
throw new Error(`Failed to read file '${params.path}': ${errorMsg}`);
}
}

View File

@@ -4,11 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { ACP_ERROR_CODES } from '../constants/acpSchema.js';
const AUTH_ERROR_PATTERNS = [
'Authentication required', // Standard authentication request message
`(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`, // RPC error code indicates auth failure
'(code: -32000)', // RPC error code -32000 indicates authentication failure
'Unauthorized', // HTTP unauthorized error
'Invalid token', // Invalid token
'Session expired', // Session expired

View File

@@ -8,9 +8,6 @@ import * as vscode from 'vscode';
import { BaseMessageHandler } from './BaseMessageHandler.js';
import type { ChatMessage } from '../../services/qwenAgentManager.js';
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
import { ACP_ERROR_CODES } from '../../constants/acpSchema.js';
const AUTH_REQUIRED_CODE_PATTERN = `(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`;
/**
* Session message handler
@@ -358,7 +355,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
createErr instanceof Error ? createErr.message : String(createErr);
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN)
errorMsg.includes('(code: -32000)')
) {
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to continue using Qwen Code.',
@@ -424,7 +421,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
errorMsg.includes('Session not found') ||
errorMsg.includes('No active ACP session') ||
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token')
) {
@@ -515,7 +512,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -625,7 +622,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -685,7 +682,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors in session creation
if (
createErrorMsg.includes('Authentication required') ||
createErrorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
createErrorMsg.includes('(code: -32000)') ||
createErrorMsg.includes('Unauthorized') ||
createErrorMsg.includes('Invalid token') ||
createErrorMsg.includes('No active ACP session')
@@ -725,7 +722,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -780,7 +777,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -830,7 +827,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -858,7 +855,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -964,7 +961,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -992,7 +989,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')