Compare commits

..

26 Commits

Author SHA1 Message Date
Mingholy
6eb16c0bcf Merge pull request #1548 from QwenLM/mingholy/fix/qwen-oauth-model-info
Fix: Update Qwen OAuth model information
2026-01-20 16:16:30 +08:00
tanzhenxin
7fa1dcb0e6 Merge pull request #1550 from QwenLM/refactor/acp-error-codes
fix(acp): propagate ENOENT errors correctly and centralize error codes
2026-01-20 16:03:16 +08:00
tanzhenxin
3c68a9a5f6 test(acp): update filesystem tests for error code-based ENOENT handling 2026-01-20 15:40:09 +08:00
tanzhenxin
bdfeec24fb refactor(acp): centralize error codes and add RESOURCE_NOT_FOUND handling for file operations 2026-01-20 15:19:18 +08:00
mingholy.lmh
03f12bfa3f fix: update qwen-oauth models info 2026-01-20 15:11:11 +08:00
tanzhenxin
55a5df46ba Merge pull request #1545 from QwenLM/fix/model-config-utils-test-env-isolation
fix(cli): isolate modelConfigUtils tests from system env vars
2026-01-20 09:51:04 +08:00
tanzhenxin
eb7dc53d2e fix(cli): isolate modelConfigUtils tests from system env vars
Use a clean process.env object instead of shallow-copying the original
environment. This prevents test failures when system has auth-related
env vars (e.g., OPENAI_API_KEY) that would interfere with test assertions.
2026-01-20 09:36:28 +08:00
tanzhenxin
de47c4e98b Merge pull request #1465 from QwenLM/feat/add-user-feedback-dialog
feat: add user feedback dialog
2026-01-19 19:26:20 +08:00
tanzhenxin
eed46447da Merge pull request #1519 from afarber/1208-fix-key-conflict
fix: resolve arrow key navigation conflict between history and completion
2026-01-19 19:23:22 +08:00
Mingholy
8de81b6299 Merge pull request #1510 from QwenLM/mingholy/fix/merge-settings-generationConfig
Fix credential management and authentication flows with improved generation config preservation
2026-01-19 19:01:56 +08:00
mingholy.lmh
b13c5bf090 feat: implement getAllAvailableModels method and add corresponding unit tests 2026-01-19 17:47:41 +08:00
mingholy.lmh
0a64fa78f5 test: add unit tests for modelConfigUtils functions 2026-01-19 16:57:01 +08:00
DragonnZhang
f99295462d feat: Rename lastShownTimestamp to feedbackLastShownTimestamp and check QWEN_OAUTH for feedback dialog showing 2026-01-19 16:19:35 +08:00
DragonnZhang
e8356c5f9e feat: Add lastShownTimestamp to settings schema and update feedback dialog logic 2026-01-19 13:46:07 +08:00
Alexander Farber
0901b228a7 Resolve arrow key navigation conflict between history and completion 2026-01-16 22:41:01 +01:00
mingholy.lmh
da8c49cb9d fix: localize default base URL display in ModelDialog 2026-01-15 20:15:37 +08:00
mingholy.lmh
d7d3371ddf fix: improve qwen-oauth error message/fallback message 2026-01-15 19:42:06 +08:00
mingholy.lmh
4213d06ab9 fix: the default resolution behavior of authType and effective model 2026-01-15 17:57:13 +08:00
DragonnZhang
45236b6ec5 feat: Integrate UI state management into feedback dialog logic 2026-01-15 11:01:05 +08:00
DragonnZhang
9e8724a749 feat: Implement feedback history management with fatigue mechanism 2026-01-15 11:01:04 +08:00
DragonnZhang
d91e372c72 feat: Refactor feedback dialog to a non-blocking popup, allow user input while it is rendered 2026-01-15 11:01:04 +08:00
DragonnZhang
9325721811 feat: Add minimum requirements for showing feedback dialog based on tool calls and user messages 2026-01-15 11:01:04 +08:00
DragonnZhang
56391b11ad feat: Update feedback options in multiple languages and adjust dialog text 2026-01-15 11:01:04 +08:00
DragonnZhang
e748532e6d feat: Update feedback dialog text to reference Qwen instead of Claude 2026-01-15 11:01:03 +08:00
DragonnZhang
d095a8b3f1 feat: Refactor feedback dialog logic into a custom hook 2026-01-15 11:01:03 +08:00
DragonnZhang
f7585153b7 feat: Add user feedback dialog 2026-01-15 11:01:03 +08:00
47 changed files with 1769 additions and 196 deletions

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.2-preview.0",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.7.2-preview.0",
"version": "0.7.1",
"workspaces": [
"packages/*"
],
@@ -17310,7 +17310,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.7.2-preview.0",
"version": "0.7.1",
"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.2-preview.0",
"version": "0.7.1",
"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.2-preview.0",
"version": "0.7.1",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -21420,7 +21420,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.7.2-preview.0",
"version": "0.7.1",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.2-preview.0",
"version": "0.7.1",
"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.2-preview.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
},
"scripts": {
"start": "cross-env node scripts/start.js",

View File

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

View File

@@ -8,6 +8,7 @@
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';
@@ -349,27 +350,51 @@ export class RequestError extends Error {
}
static parseError(details?: string): RequestError {
return new RequestError(-32700, 'Parse error', details);
return new RequestError(
ACP_ERROR_CODES.PARSE_ERROR,
'Parse error',
details,
);
}
static invalidRequest(details?: string): RequestError {
return new RequestError(-32600, 'Invalid request', details);
return new RequestError(
ACP_ERROR_CODES.INVALID_REQUEST,
'Invalid request',
details,
);
}
static methodNotFound(details?: string): RequestError {
return new RequestError(-32601, 'Method not found', details);
return new RequestError(
ACP_ERROR_CODES.METHOD_NOT_FOUND,
'Method not found',
details,
);
}
static invalidParams(details?: string): RequestError {
return new RequestError(-32602, 'Invalid params', details);
return new RequestError(
ACP_ERROR_CODES.INVALID_PARAMS,
'Invalid params',
details,
);
}
static internalError(details?: string): RequestError {
return new RequestError(-32603, 'Internal error', details);
return new RequestError(
ACP_ERROR_CODES.INTERNAL_ERROR,
'Internal error',
details,
);
}
static authRequired(details?: string): RequestError {
return new RequestError(-32000, 'Authentication required', details);
return new RequestError(
ACP_ERROR_CODES.AUTH_REQUIRED,
'Authentication required',
details,
);
}
toResult<T>(): Result<T> {

View File

@@ -0,0 +1,25 @@
/**
* @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,6 +7,7 @@
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(),
@@ -16,11 +17,13 @@ const createFallback = (): FileSystemService => ({
describe('AcpFileSystemService', () => {
describe('readTextFile ENOENT handling', () => {
it('parses path from ACP ENOENT message (quoted)', async () => {
it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => {
const resourceNotFoundError = {
code: ACP_ERROR_CODES.RESOURCE_NOT_FOUND,
message: 'File not found',
};
const client = {
readTextFile: vi
.fn()
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
readTextFile: vi.fn().mockRejectedValue(resourceNotFoundError),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
@@ -30,15 +33,20 @@ describe('AcpFileSystemService', () => {
createFallback(),
);
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
code: 'ENOENT',
path: '/remote/file.txt',
errno: -2,
path: '/some/file.txt',
});
});
it('falls back to requested path when none provided', async () => {
it('re-throws other errors unchanged', async () => {
const otherError = {
code: ACP_ERROR_CODES.INTERNAL_ERROR,
message: 'Internal error',
};
const client = {
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
readTextFile: vi.fn().mockRejectedValue(otherError),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
@@ -48,12 +56,34 @@ describe('AcpFileSystemService', () => {
createFallback(),
);
await expect(
svc.readTextFile('/fallback/path.txt'),
).rejects.toMatchObject({
code: 'ENOENT',
path: '/fallback/path.txt',
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
code: ACP_ERROR_CODES.INTERNAL_ERROR,
message: 'Internal error',
});
});
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,6 +6,7 @@
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
@@ -23,25 +24,31 @@ export class AcpFileSystemService implements FileSystemService {
return this.fallback.readTextFile(filePath);
}
const response = await this.client.readTextFile({
path: filePath,
sessionId: this.sessionId,
line: null,
limit: null,
});
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;
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;
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;
}
return response.content;

View File

@@ -122,9 +122,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Auto-completion
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
// 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 }],
// 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' }],
// Text input
// Must also exclude shift to allow shift+enter for newline

View File

@@ -434,6 +434,16 @@ 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',
@@ -464,6 +474,15 @@ 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,6 +289,13 @@ 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,6 +286,13 @@ 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,6 +289,13 @@ 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,6 +277,12 @@ 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,6 +45,7 @@ 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';
@@ -1195,6 +1196,19 @@ 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],
@@ -1291,6 +1305,8 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
}),
[
isThemeDialogOpen,
@@ -1381,6 +1397,8 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
],
);
@@ -1421,6 +1439,10 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
}),
[
handleThemeSelect,
@@ -1456,6 +1478,10 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
],
);

View File

@@ -0,0 +1,61 @@
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,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
import type {
Config,
ContentGeneratorConfig,
ModelProvidersConfig,
} from '@qwen-code/qwen-code-core';
import {
AuthEvent,
AuthType,
@@ -214,11 +218,19 @@ export const useAuthCommand = (
if (authType === AuthType.USE_OPENAI) {
if (credentials) {
config.updateCredentials({
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
});
// 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,
);
await performAuth(authType, credentials);
}
return;
@@ -226,7 +238,13 @@ export const useAuthCommand = (
await performAuth(authType);
},
[config, performAuth, isProviderManagedModel, onAuthError],
[
config,
performAuth,
isProviderManagedModel,
onAuthError,
settings.merged.model?.generationConfig,
],
);
const openAuthDialog = useCallback(() => {

View File

@@ -26,6 +26,7 @@ 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 = () => {
@@ -134,6 +135,8 @@ export const Composer = () => {
</OverflowProvider>
)}
{uiState.isFeedbackDialogOpen && <FeedbackDialog />}
{uiState.isInputActive && (
<InputPrompt
buffer={uiState.buffer}

View File

@@ -33,6 +33,9 @@ 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[] = [
{
@@ -278,7 +281,7 @@ describe('InputPrompt', () => {
unmount();
});
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
it('should call completion.navigateUp for up arrow when suggestions are showing', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
@@ -293,19 +296,22 @@ describe('InputPrompt', () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Test up arrow
// Test up arrow for completion navigation
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(2);
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1);
expect(mockInputHistory.navigateUp).toHaveBeenCalled();
unmount();
});
it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
it('should call completion.navigateDown for down arrow when suggestions are showing', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
@@ -319,14 +325,17 @@ describe('InputPrompt', () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Test down arrow
// Test down arrow for completion navigation
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(2);
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1);
expect(mockInputHistory.navigateDown).toHaveBeenCalled();
unmount();
});
@@ -764,6 +773,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -791,6 +802,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -818,6 +831,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -845,6 +860,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -872,6 +889,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -900,6 +919,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -927,6 +948,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -955,6 +978,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -983,6 +1008,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1011,6 +1038,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1039,6 +1068,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1069,6 +1100,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1097,6 +1130,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1127,6 +1162,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();

View File

@@ -36,6 +36,8 @@ 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;
@@ -100,6 +102,7 @@ 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);
@@ -135,6 +138,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
commandContext,
reverseSearchActive,
config,
// Suppress completion when history navigation just occurred
!justNavigatedHistory,
);
const reverseSearchCompletion = useReverseSearchCompletion(
@@ -219,9 +224,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const inputHistory = useInputHistory({
userMessages,
onSubmit: handleSubmitAndClear,
isActive:
(!completion.showSuggestions || completion.suggestions.length === 1) &&
!shellModeActive,
// History navigation (Ctrl+P/N) now always works since completion navigation
// only uses arrow keys. Only disable in shell mode.
isActive: !shellModeActive,
currentQuery: buffer.text,
onChange: customSetTextAndResetCompletionSignal,
});
@@ -326,6 +331,14 @@ 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) {
@@ -670,6 +683,7 @@ 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 ?? '(default)';
const baseUrl = after?.baseUrl ?? t('(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 ?? ''}
value={effectiveConfig?.baseUrl ?? t('(default)')}
badge={formatSourceBadge(sources['baseUrl'])}
/>
<ConfigRow

View File

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

View File

@@ -45,6 +45,8 @@ 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,
@@ -152,7 +154,11 @@ export function useCommandCompletion(
}, [suggestions, setActiveSuggestionIndex, setVisibleStartIndex]);
useEffect(() => {
if (completionMode === CompletionMode.IDLE || reverseSearchActive) {
if (
completionMode === CompletionMode.IDLE ||
reverseSearchActive ||
!active
) {
resetCompletionState();
return;
}
@@ -163,6 +169,7 @@ export function useCommandCompletion(
suggestions.length,
isLoadingSuggestions,
reverseSearchActive,
active,
resetCompletionState,
setShowSuggestions,
]);

View File

@@ -0,0 +1,178 @@
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),
[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'),
// 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.ESCAPE]: (key: Key) => key.name === 'escape',
[Command.SUBMIT]: (key: Key) =>
key.name === 'return' && !key.ctrl && !key.meta && !key.paste,
@@ -164,14 +164,26 @@ 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'), createKey('p', { ctrl: true })],
negative: [createKey('p'), createKey('down')],
positive: [createKey('up')],
negative: [
createKey('p'),
createKey('down'),
createKey('p', { ctrl: true }),
],
},
{
// 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'), createKey('n', { ctrl: true })],
negative: [createKey('n'), createKey('up')],
positive: [createKey('down')],
negative: [
createKey('n'),
createKey('up'),
createKey('n', { ctrl: true }),
],
},
// Text input

View File

@@ -0,0 +1,722 @@
/**
* @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,20 +44,31 @@ 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['GEMINI_API_KEY']) {
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']) {
return AuthType.USE_GEMINI;
}
if (process.env['GOOGLE_API_KEY']) {
if (process.env['GOOGLE_API_KEY'] && process.env['GOOGLE_MODEL']) {
return AuthType.USE_VERTEX_AI;
}
if (process.env['ANTHROPIC_API_KEY']) {
if (
process.env['ANTHROPIC_API_KEY'] &&
process.env['ANTHROPIC_MODEL'] &&
process.env['ANTHROPIC_BASE_URL']
) {
return AuthType.USE_ANTHROPIC;
}

View File

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

View File

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

View File

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

View File

@@ -191,7 +191,7 @@ describe('ModelsConfig', () => {
expect(gc.apiKeyEnvKey).toBe('API_KEY_SHARED');
});
it('should preserve settings generationConfig when model is updated via updateCredentials even if it matches modelProviders', () => {
it('should use provider config when modelId exists in registry even after updateCredentials', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
@@ -213,7 +213,7 @@ describe('ModelsConfig', () => {
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
model: 'model-a',
model: 'custom-model',
samplingParams: { temperature: 0.9, max_tokens: 999 },
timeout: 9999,
maxRetries: 9,
@@ -235,30 +235,30 @@ describe('ModelsConfig', () => {
},
});
// 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' });
// 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' });
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
// syncAfterAuthRefresh with a modelId that exists in registry should use provider config
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'model-a');
const gc = currentGenerationConfig(modelsConfig);
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);
expect(gc.maxRetries).toBe(9);
// 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);
});
it('should preserve settings generationConfig across multiple auth refreshes after updateCredentials', () => {
it('should preserve settings generationConfig when modelId does not exist in registry', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'model-a',
name: 'Model A',
id: 'provider-model',
name: 'Provider Model',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_A',
generationConfig: {
@@ -270,11 +270,12 @@ describe('ModelsConfig', () => {
],
};
// Simulate settings with a custom model (not in registry)
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
model: 'model-a',
model: 'custom-model',
samplingParams: { temperature: 0.9, max_tokens: 999 },
timeout: 9999,
maxRetries: 9,
@@ -296,25 +297,21 @@ 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: 'model-a',
model: 'custom-model',
});
// First auth refresh
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
// First auth refresh - modelId doesn't exist in registry, so credentials should be preserved
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'custom-model');
// Second auth refresh should still preserve settings generationConfig
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'custom-model');
const gc = currentGenerationConfig(modelsConfig);
expect(gc.model).toBe('model-a');
expect(gc.model).toBe('custom-model');
// Settings-sourced generation config should be preserved since modelId doesn't exist in registry
expect(gc.samplingParams?.temperature).toBe(0.9);
expect(gc.samplingParams?.max_tokens).toBe(999);
expect(gc.timeout).toBe(9999);
@@ -681,4 +678,120 @@ 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,6 +203,18 @@ 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
*/
@@ -307,6 +319,33 @@ 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.
@@ -314,12 +353,20 @@ 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;
}): void {
updateCredentials(
credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
): 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.
@@ -359,6 +406,14 @@ 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);
}
}
/**
@@ -587,50 +642,88 @@ export class ModelsConfig {
}
/**
* 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.
* 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.
*/
syncAfterAuthRefresh(authType: AuthType, modelId?: string): void {
// Check if we have manually set credentials that should be preserved
const preserveManualCredentials = this.hasManualCredentials;
this.strictModelProviderSelection = false;
const previousAuthType = this.currentAuthType;
this.currentAuthType = authType;
// 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;
// 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) {
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;
}
this.strictModelProviderSelection = false;
// Step 3: Fall back to default model for the authType
const defaultModel =
this.modelRegistry.getDefaultModelForAuthType(authType);
if (defaultModel) {
this.applyResolvedModelDefaults(defaultModel);
return;
}
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);
}
} 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);
// 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)',
};
}
}
}

View File

@@ -751,6 +751,7 @@ describe('getQwenOAuthClient', () => {
beforeEach(() => {
mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
originalFetch = global.fetch;
@@ -839,9 +840,7 @@ describe('getQwenOAuthClient', () => {
requireCachedCredentials: true,
}),
),
).rejects.toThrow(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
).rejects.toThrow('Please use /auth to re-authenticate.');
expect(global.fetch).not.toHaveBeenCalled();
@@ -1007,6 +1006,7 @@ 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,6 +1202,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
beforeEach(() => {
mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
originalFetch = global.fetch;
@@ -1405,6 +1406,7 @@ 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;
@@ -2043,6 +2045,7 @@ 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,9 +516,7 @@ export async function getQwenOAuthClient(
}
if (options?.requireCachedCredentials) {
throw new Error(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
throw new Error('Please use /auth to re-authenticate.');
}
// If we couldn't obtain valid credentials via SharedTokenManager, fall back to
@@ -740,11 +738,9 @@ async function authWithQwenDeviceFlow(
// Emit device authorization event for UI integration immediately
qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, deviceAuth);
// 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);
if (config.isBrowserLaunchSuppressed() || !config.isInteractive()) {
showFallbackMessage(deviceAuth.verification_uri_complete);
}
// Try to open browser if not suppressed
if (!config.isBrowserLaunchSuppressed()) {

View File

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

View File

@@ -38,6 +38,7 @@ import {
EVENT_INVALID_CHUNK,
EVENT_AUTH,
EVENT_SKILL_LAUNCH,
EVENT_USER_FEEDBACK,
} from './constants.js';
import {
recordApiErrorMetrics,
@@ -86,6 +87,7 @@ import type {
InvalidChunkEvent,
AuthEvent,
SkillLaunchEvent,
UserFeedbackEvent,
} from './types.js';
import type { UiEvent } from './uiTelemetry.js';
import { uiTelemetryService } from './uiTelemetry.js';
@@ -887,3 +889,32 @@ 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,6 +39,7 @@ import type {
ExtensionDisableEvent,
AuthEvent,
SkillLaunchEvent,
UserFeedbackEvent,
RipgrepFallbackEvent,
EndSessionEvent,
} from '../types.js';
@@ -842,6 +843,21 @@ 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,6 +757,38 @@ 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
@@ -786,7 +818,8 @@ export type TelemetryEvent =
| ToolOutputTruncatedEvent
| ModelSlashCommandEvent
| AuthEvent
| SkillLaunchEvent;
| SkillLaunchEvent
| UserFeedbackEvent;
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.2-preview.0",
"version": "0.7.1",
"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.2-preview.0",
"version": "0.7.1",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {

View File

@@ -23,3 +23,23 @@ 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,6 +28,7 @@ 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) {
@@ -264,7 +265,7 @@ export class IDEServer {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
code: ACP_ERROR_CODES.AUTH_REQUIRED,
message:
'Bad Request: No valid session ID provided for non-initialize request.',
},
@@ -283,7 +284,7 @@ export class IDEServer {
res.status(500).json({
jsonrpc: '2.0' as const,
error: {
code: -32603,
code: ACP_ERROR_CODES.INTERNAL_ERROR,
message: 'Internal server error',
},
id: null,

View File

@@ -5,6 +5,7 @@
*/
import { JSONRPC_VERSION } from '../types/acpTypes.js';
import { ACP_ERROR_CODES } from '../constants/acpSchema.js';
import type {
AcpMessage,
AcpPermissionRequest,
@@ -232,12 +233,34 @@ 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: -32603,
message: error instanceof Error ? error.message : String(error),
code: errorCode,
message: errorMessage,
},
});
}

View File

@@ -66,6 +66,11 @@ 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,9 +4,11 @@
* 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: -32000)', // RPC error code -32000 indicates authentication failure
`(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`, // RPC error code indicates auth failure
'Unauthorized', // HTTP unauthorized error
'Invalid token', // Invalid token
'Session expired', // Session expired

View File

@@ -8,6 +8,9 @@ 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
@@ -355,7 +358,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
createErr instanceof Error ? createErr.message : String(createErr);
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)')
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN)
) {
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to continue using Qwen Code.',
@@ -421,7 +424,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
errorMsg.includes('Session not found') ||
errorMsg.includes('No active ACP session') ||
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token')
) {
@@ -512,7 +515,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -622,7 +625,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -682,7 +685,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors in session creation
if (
createErrorMsg.includes('Authentication required') ||
createErrorMsg.includes('(code: -32000)') ||
createErrorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
createErrorMsg.includes('Unauthorized') ||
createErrorMsg.includes('Invalid token') ||
createErrorMsg.includes('No active ACP session')
@@ -722,7 +725,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -777,7 +780,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -827,7 +830,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -855,7 +858,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -961,7 +964,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -989,7 +992,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')