Compare commits

..

2 Commits

Author SHA1 Message Date
mingholy.lmh
0afc50970f Merge branch 'main' of github.com:QwenLM/qwen-code into mingholy/fix/acp-slashcommands 2025-11-18 13:23:12 +08:00
mingholy.lmh
11ae56c18a fix: basic slash command support 2025-11-13 10:32:28 +08:00
56 changed files with 854 additions and 1714 deletions

View File

@@ -106,7 +106,7 @@ Subagents are configured using Markdown files with YAML frontmatter. This format
---
name: agent-name
description: Brief description of when and how to use this agent
tools:
tools:
- tool1
- tool2
- tool3 # Optional
@@ -170,7 +170,7 @@ Perfect for comprehensive test creation and test-driven development.
---
name: testing-expert
description: Writes comprehensive unit tests, integration tests, and handles test automation with best practices
tools:
tools:
- read_file
- write_file
- read_many_files
@@ -214,7 +214,7 @@ Specialized in creating clear, comprehensive documentation.
---
name: documentation-writer
description: Creates comprehensive documentation, README files, API docs, and user guides
tools:
tools:
- read_file
- write_file
- read_many_files
@@ -267,7 +267,7 @@ Focused on code quality, security, and best practices.
---
name: code-reviewer
description: Reviews code for best practices, security issues, performance, and maintainability
tools:
tools:
- read_file
- read_many_files
---
@@ -311,7 +311,7 @@ Optimized for React development, hooks, and component patterns.
---
name: react-specialist
description: Expert in React development, hooks, component patterns, and modern React best practices
tools:
tools:
- read_file
- write_file
- read_many_files

View File

@@ -14,13 +14,6 @@ This guide provides solutions to common issues and debugging tips, including top
- **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file.
- Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt`
- **Issue: Unable to display UI after authentication failure**
- **Cause:** If authentication fails after selecting an authentication type, the `security.auth.selectedType` setting may be persisted in `settings.json`. On restart, the CLI may get stuck trying to authenticate with the failed auth type and fail to display the UI.
- **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file:
- Open `~/.qwen/settings.json` (or `./.qwen/settings.json` for project-specific settings)
- Remove the `security.auth.selectedType` field
- Restart the CLI to allow it to prompt for authentication again
## Frequently asked questions (FAQs)
- **Q: How do I update Qwen Code to the latest version?**

View File

@@ -21,21 +21,23 @@ describe('Interactive Mode', () => {
it.skipIf(process.platform === 'win32')(
'should trigger chat compression with /compress command',
async () => {
await rig.setup('interactive-compress-test', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
await rig.setup('interactive-compress-test');
const { ptyProcess } = rig.runInteractive();
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000);
expect(
@@ -66,43 +68,49 @@ describe('Interactive Mode', () => {
},
);
it.skip('should handle compression failure on token inflation', async () => {
await rig.setup('interactive-compress-test', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
it.skipIf(process.platform === 'win32')(
'should handle compression failure on token inflation',
async () => {
await rig.setup('interactive-compress-test');
const { ptyProcess } = rig.runInteractive();
const { ptyProcess } = rig.runInteractive();
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 25000);
expect(isReady, 'CLI did not start up in interactive mode correctly').toBe(
true,
);
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
await type(ptyProcess, '/compress');
await new Promise((resolve) => setTimeout(resolve, 1000));
await type(ptyProcess, '\r');
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
90000,
);
expect(foundEvent).toBe(true);
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 25000);
expect(
isReady,
'CLI did not start up in interactive mode correctly',
).toBe(true);
const compressionFailed = await rig.waitForText(
'Nothing to compress.',
25000,
);
await type(ptyProcess, '/compress');
await new Promise((resolve) => setTimeout(resolve, 100));
await type(ptyProcess, '\r');
expect(compressionFailed).toBe(true);
});
const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
90000,
);
expect(foundEvent).toBe(true);
const compressionFailed = await rig.waitForText(
'compression was not beneficial',
25000,
);
expect(compressionFailed).toBe(true);
},
);
});

View File

@@ -22,19 +22,21 @@ describe('Interactive file system', () => {
'should perform a read-then-write sequence in interactive mode',
async () => {
const fileName = 'version.txt';
await rig.setup('interactive-read-then-write', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
await rig.setup('interactive-read-then-write');
rig.createFile(fileName, '1.0.0');
const { ptyProcess } = rig.runInteractive();
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000);
expect(

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.2.3",
"version": "0.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.2.3",
"version": "0.2.2",
"workspaces": [
"packages/*"
],
@@ -16024,7 +16024,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.2.3",
"version": "0.2.2",
"dependencies": {
"@google/genai": "1.16.0",
"@iarna/toml": "^2.2.5",
@@ -16139,7 +16139,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.2.3",
"version": "0.2.2",
"hasInstallScript": true,
"dependencies": {
"@google/genai": "1.16.0",
@@ -16278,7 +16278,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.2.3",
"version": "0.2.2",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -16290,7 +16290,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.2.3",
"version": "0.2.2",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.2.3",
"version": "0.2.2",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.3"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.2"
},
"dependencies": {
"@google/genai": "1.16.0",

View File

@@ -839,6 +839,5 @@ export function saveSettings(settingsFile: SettingsFile): void {
);
} catch (error) {
console.error('Error saving user settings file:', error);
throw error;
}
}

View File

@@ -8,8 +8,6 @@ import {
type AuthType,
type Config,
getErrorMessage,
logAuth,
AuthEvent,
} from '@qwen-code/qwen-code-core';
/**
@@ -27,21 +25,11 @@ export async function performInitialAuth(
}
try {
await config.refreshAuth(authType, true);
await config.refreshAuth(authType);
// The console.log is intentionally left out here.
// We can add a dedicated startup message later if needed.
// Log authentication success
const authEvent = new AuthEvent(authType, 'auto', 'success');
logAuth(config, authEvent);
} catch (e) {
const errorMessage = `Failed to login. Message: ${getErrorMessage(e)}`;
// Log authentication failure
const authEvent = new AuthEvent(authType, 'auto', 'error', errorMessage);
logAuth(config, authEvent);
return errorMessage;
return `Failed to login. Message: ${getErrorMessage(e)}`;
}
return null;

View File

@@ -11,7 +11,7 @@ import {
logIdeConnection,
type Config,
} from '@qwen-code/qwen-code-core';
import { type LoadedSettings, SettingScope } from '../config/settings.js';
import { type LoadedSettings } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
@@ -33,18 +33,10 @@ export async function initializeApp(
config: Config,
settings: LoadedSettings,
): Promise<InitializationResult> {
const authType = settings.merged.security?.auth?.selectedType;
const authError = await performInitialAuth(config, authType);
// Fallback to user select when initial authentication fails
if (authError) {
settings.setValue(
SettingScope.User,
'security.auth.selectedType',
undefined,
);
}
const authError = await performInitialAuth(
config,
settings.merged.security?.auth?.selectedType,
);
const themeError = validateTheme(settings);
const shouldOpenAuthDialog =

View File

@@ -25,6 +25,7 @@ import {
type HistoryItem,
ToolCallStatus,
type HistoryItemWithoutId,
AuthState,
} from './types.js';
import { MessageType, StreamingState } from './types.js';
import {
@@ -47,6 +48,7 @@ import { useHistory } from './hooks/useHistoryManager.js';
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './auth/useAuth.js';
import { useQwenAuth } from './hooks/useQwenAuth.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
@@ -91,12 +93,10 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js';
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@@ -348,12 +348,19 @@ export const AppContainer = (props: AppContainerProps) => {
onAuthError,
isAuthDialogOpen,
isAuthenticating,
pendingAuthType,
qwenAuthState,
handleAuthSelect,
openAuthDialog,
cancelAuthentication,
} = useAuthCommand(settings, config, historyManager.addItem);
} = useAuthCommand(settings, config);
// Qwen OAuth authentication state
const {
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
cancelQwenAuth,
} = useQwenAuth(settings, isAuthenticating);
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
config,
@@ -363,7 +370,19 @@ export const AppContainer = (props: AppContainerProps) => {
setModelSwitchedFromQuotaError,
});
useInitializationAuthError(initializationResult.authError, onAuthError);
// Handle Qwen OAuth timeout
const handleQwenAuthTimeout = useCallback(() => {
onAuthError('Qwen OAuth authentication timed out. Please try again.');
cancelQwenAuth();
setAuthState(AuthState.Updating);
}, [onAuthError, cancelQwenAuth, setAuthState]);
// Handle Qwen OAuth cancel
const handleQwenAuthCancel = useCallback(() => {
onAuthError('Qwen OAuth authentication cancelled.');
cancelQwenAuth();
setAuthState(AuthState.Updating);
}, [onAuthError, cancelQwenAuth, setAuthState]);
// Sync user tier from config when authentication changes
// TODO: Implement getUserTier() method on Config if needed
@@ -375,8 +394,6 @@ export const AppContainer = (props: AppContainerProps) => {
// Check for enforced auth type mismatch
useEffect(() => {
// Check for initialization error first
if (
settings.merged.security?.auth?.enforcedType &&
settings.merged.security?.auth.selectedType &&
@@ -927,12 +944,6 @@ export const AppContainer = (props: AppContainerProps) => {
settings.merged.ui?.customWittyPhrases,
);
useAttentionNotifications({
isFocused,
streamingState,
elapsedTime,
});
// Dialog close functionality
const { closeAnyOpenDialog } = useDialogClose({
isThemeDialogOpen,
@@ -941,7 +952,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleApprovalModeSelect,
isAuthDialogOpen,
handleAuthSelect,
pendingAuthType,
selectedAuthType: settings.merged.security?.auth?.selectedType,
isEditorDialogOpen,
exitEditorDialog,
isSettingsDialogOpen,
@@ -1183,7 +1194,7 @@ export const AppContainer = (props: AppContainerProps) => {
isVisionSwitchDialogOpen ||
isPermissionsDialogOpen ||
isAuthDialogOpen ||
isAuthenticating ||
(isAuthenticating && isQwenAuthenticating) ||
isEditorDialogOpen ||
showIdeRestartPrompt ||
!!proQuotaRequest ||
@@ -1206,9 +1217,12 @@ export const AppContainer = (props: AppContainerProps) => {
isConfigInitialized,
authError,
isAuthDialogOpen,
pendingAuthType,
// Qwen OAuth state
qwenAuthState,
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
editorError,
isEditorDialogOpen,
corgiMode,
@@ -1298,9 +1312,12 @@ export const AppContainer = (props: AppContainerProps) => {
isConfigInitialized,
authError,
isAuthDialogOpen,
pendingAuthType,
// Qwen OAuth state
qwenAuthState,
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
editorError,
isEditorDialogOpen,
corgiMode,
@@ -1394,7 +1411,9 @@ export const AppContainer = (props: AppContainerProps) => {
handleAuthSelect,
setAuthState,
onAuthError,
cancelAuthentication,
// Qwen OAuth handlers
handleQwenAuthTimeout,
handleQwenAuthCancel,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,
@@ -1428,7 +1447,9 @@ export const AppContainer = (props: AppContainerProps) => {
handleAuthSelect,
setAuthState,
onAuthError,
cancelAuthentication,
// Qwen OAuth handlers
handleQwenAuthTimeout,
handleQwenAuthCancel,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,

View File

@@ -9,53 +9,6 @@ import { AuthDialog } from './AuthDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../test-utils/render.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { UIActionsContext } from '../contexts/UIActionsContext.js';
import type { UIState } from '../contexts/UIStateContext.js';
import type { UIActions } from '../contexts/UIActionsContext.js';
const createMockUIState = (overrides: Partial<UIState> = {}): UIState => {
// AuthDialog only uses authError and pendingAuthType
const baseState = {
authError: null,
pendingAuthType: undefined,
} as Partial<UIState>;
return {
...baseState,
...overrides,
} as UIState;
};
const createMockUIActions = (overrides: Partial<UIActions> = {}): UIActions => {
// AuthDialog only uses handleAuthSelect
const baseActions = {
handleAuthSelect: vi.fn(),
} as Partial<UIActions>;
return {
...baseActions,
...overrides,
} as UIActions;
};
const renderAuthDialog = (
settings: LoadedSettings,
uiStateOverrides: Partial<UIState> = {},
uiActionsOverrides: Partial<UIActions> = {},
) => {
const uiState = createMockUIState(uiStateOverrides);
const uiActions = createMockUIActions(uiActionsOverrides);
return renderWithProviders(
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<AuthDialog />
</UIActionsContext.Provider>
</UIStateContext.Provider>,
{ settings },
);
};
describe('AuthDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -113,9 +66,13 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderAuthDialog(settings, {
authError: 'GEMINI_API_KEY environment variable not found',
});
const { lastFrame } = renderWithProviders(
<AuthDialog
onSelect={() => {}}
settings={settings}
initialErrorMessage="GEMINI_API_KEY environment variable not found"
/>,
);
expect(lastFrame()).toContain(
'GEMINI_API_KEY environment variable not found',
@@ -159,7 +116,9 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderAuthDialog(settings);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog only shows OpenAI option now,
// it won't show GEMINI_API_KEY messages
@@ -203,7 +162,9 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderAuthDialog(settings);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
expect(lastFrame()).not.toContain(
'Existing API key detected (GEMINI_API_KEY)',
@@ -247,7 +208,9 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderAuthDialog(settings);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog only shows OpenAI option now,
// it won't show GEMINI_API_KEY messages
@@ -292,7 +255,9 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderAuthDialog(settings);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// This is a bit brittle, but it's the best way to check which item is selected.
expect(lastFrame()).toContain('● 2. OpenAI');
@@ -332,7 +297,9 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderAuthDialog(settings);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Default is Qwen OAuth (first option)
expect(lastFrame()).toContain('● 1. Qwen OAuth');
@@ -374,7 +341,9 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderAuthDialog(settings);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore,
// it will just show the default Qwen OAuth option
@@ -383,7 +352,7 @@ describe('AuthDialog', () => {
});
it('should prevent exiting when no auth method is selected and show error message', async () => {
const handleAuthSelect = vi.fn();
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -417,10 +386,8 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame, stdin, unmount } = renderAuthDialog(
settings,
{},
{ handleAuthSelect },
const { lastFrame, stdin, unmount } = renderWithProviders(
<AuthDialog onSelect={onSelect} settings={settings} />,
);
await wait();
@@ -428,16 +395,16 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key
await wait();
// Should show error message instead of calling handleAuthSelect
// Should show error message instead of calling onSelect
expect(lastFrame()).toContain(
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
);
expect(handleAuthSelect).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
unmount();
});
it('should not exit if there is already an error message', async () => {
const handleAuthSelect = vi.fn();
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -471,10 +438,12 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame, stdin, unmount } = renderAuthDialog(
settings,
{ authError: 'Initial error' },
{ handleAuthSelect },
const { lastFrame, stdin, unmount } = renderWithProviders(
<AuthDialog
onSelect={onSelect}
settings={settings}
initialErrorMessage="Initial error"
/>,
);
await wait();
@@ -484,13 +453,13 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key
await wait();
// Should not call handleAuthSelect
expect(handleAuthSelect).not.toHaveBeenCalled();
// Should not call onSelect
expect(onSelect).not.toHaveBeenCalled();
unmount();
});
it('should allow exiting when auth method is already selected', async () => {
const handleAuthSelect = vi.fn();
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -524,10 +493,8 @@ describe('AuthDialog', () => {
new Set(),
);
const { stdin, unmount } = renderAuthDialog(
settings,
{},
{ handleAuthSelect },
const { stdin, unmount } = renderWithProviders(
<AuthDialog onSelect={onSelect} settings={settings} />,
);
await wait();
@@ -535,8 +502,8 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key
await wait();
// Should call handleAuthSelect with undefined to exit
expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
// Should call onSelect with undefined to exit
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
unmount();
});
});

View File

@@ -8,13 +8,26 @@ import type React from 'react';
import { useState } from 'react';
import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import { SettingScope } from '../../config/settings.js';
import { validateAuthMethod } from '../../config/auth.js';
import { type LoadedSettings, SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { OpenAIKeyPrompt } from '../components/OpenAIKeyPrompt.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
interface AuthDialogProps {
onSelect: (
authMethod: AuthType | undefined,
scope: SettingScope,
credentials?: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
) => void;
settings: LoadedSettings;
initialErrorMessage?: string | null;
}
function parseDefaultAuthType(
defaultAuthType: string | undefined,
@@ -28,14 +41,15 @@ function parseDefaultAuthType(
return null;
}
export function AuthDialog(): React.JSX.Element {
const { pendingAuthType, authError } = useUIState();
const { handleAuthSelect: onAuthSelect } = useUIActions();
const settings = useSettings();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
export function AuthDialog({
onSelect,
settings,
initialErrorMessage,
}: AuthDialogProps): React.JSX.Element {
const [errorMessage, setErrorMessage] = useState<string | null>(
initialErrorMessage || null,
);
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
const items = [
{
key: AuthType.QWEN_OAUTH,
@@ -48,17 +62,10 @@ export function AuthDialog(): React.JSX.Element {
const initialAuthIndex = Math.max(
0,
items.findIndex((item) => {
// Priority 1: pendingAuthType
if (pendingAuthType) {
return item.value === pendingAuthType;
}
// Priority 2: settings.merged.security?.auth?.selectedType
if (settings.merged.security?.auth?.selectedType) {
return item.value === settings.merged.security?.auth?.selectedType;
}
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
const defaultAuthType = parseDefaultAuthType(
process.env['QWEN_DEFAULT_AUTH_TYPE'],
);
@@ -66,29 +73,49 @@ export function AuthDialog(): React.JSX.Element {
return item.value === defaultAuthType;
}
// Priority 4: default to QWEN_OAUTH
return item.value === AuthType.QWEN_OAUTH;
}),
);
const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey);
const currentSelectedAuthType =
selectedIndex !== null
? items[selectedIndex]?.value
: items[initialAuthIndex]?.value;
const handleAuthSelect = async (authMethod: AuthType) => {
setErrorMessage(null);
await onAuthSelect(authMethod, SettingScope.User);
const handleAuthSelect = (authMethod: AuthType) => {
if (authMethod === AuthType.USE_OPENAI) {
setShowOpenAIKeyPrompt(true);
setErrorMessage(null);
} else {
const error = validateAuthMethod(authMethod);
if (error) {
setErrorMessage(error);
} else {
setErrorMessage(null);
onSelect(authMethod, SettingScope.User);
}
}
};
const handleHighlight = (authMethod: AuthType) => {
const index = items.findIndex((item) => item.value === authMethod);
setSelectedIndex(index);
const handleOpenAIKeySubmit = (
apiKey: string,
baseUrl: string,
model: string,
) => {
setShowOpenAIKeyPrompt(false);
onSelect(AuthType.USE_OPENAI, SettingScope.User, {
apiKey,
baseUrl,
model,
});
};
const handleOpenAIKeyCancel = () => {
setShowOpenAIKeyPrompt(false);
setErrorMessage('OpenAI API key is required to use OpenAI authentication.');
};
useKeypress(
(key) => {
if (showOpenAIKeyPrompt) {
return;
}
if (key.name === 'escape') {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
@@ -102,11 +129,33 @@ export function AuthDialog(): React.JSX.Element {
);
return;
}
onAuthSelect(undefined, SettingScope.User);
onSelect(undefined, SettingScope.User);
}
},
{ isActive: true },
);
const getDefaultOpenAIConfig = () => {
const fromSettings = settings.merged.security?.auth;
const modelSettings = settings.merged.model;
return {
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
};
};
if (showOpenAIKeyPrompt) {
const defaults = getDefaultOpenAIConfig();
return (
<OpenAIKeyPrompt
defaultApiKey={defaults.apiKey}
defaultBaseUrl={defaults.baseUrl}
defaultModel={defaults.model}
onSubmit={handleOpenAIKeySubmit}
onCancel={handleOpenAIKeyCancel}
/>
);
}
return (
<Box
@@ -125,26 +174,16 @@ export function AuthDialog(): React.JSX.Element {
items={items}
initialIndex={initialAuthIndex}
onSelect={handleAuthSelect}
onHighlight={handleHighlight}
/>
</Box>
{(authError || errorMessage) && (
{errorMessage && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{authError || errorMessage}</Text>
<Text color={Colors.AccentRed}>{errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text>
</Box>
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
<Box marginTop={1}>
<Text color={Colors.Gray}>
Note: Your existing API key in settings.json will not be cleared
when using Qwen OAuth. You can switch back to OpenAI authentication
later if needed.
</Text>
</Box>
)}
<Box marginTop={1}>
<Text>Terms of Services and Privacy Notice for Qwen Code</Text>
</Box>

View File

@@ -4,28 +4,31 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@qwen-code/qwen-code-core';
import { useState, useCallback, useEffect } from 'react';
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import type { AuthType, Config } from '@qwen-code/qwen-code-core';
import {
AuthEvent,
AuthType,
clearCachedCredentialFile,
getErrorMessage,
logAuth,
} from '@qwen-code/qwen-code-core';
import { useCallback, useEffect, useState } from 'react';
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { AuthState, MessageType } from '../types.js';
import type { HistoryItem } from '../types.js';
import { AuthState } from '../types.js';
import { validateAuthMethod } from '../../config/auth.js';
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
export const useAuthCommand = (
export function validateAuthMethodWithSettings(
authType: AuthType,
settings: LoadedSettings,
config: Config,
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
) => {
): string | null {
const enforcedType = settings.merged.security?.auth?.enforcedType;
if (enforcedType && enforcedType !== authType) {
return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
}
if (settings.merged.security?.auth?.useExternal) {
return null;
}
return validateAuthMethod(authType);
}
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
const unAuthenticated =
settings.merged.security?.auth?.selectedType === undefined;
@@ -37,14 +40,6 @@ export const useAuthCommand = (
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated);
const [pendingAuthType, setPendingAuthType] = useState<AuthType | undefined>(
undefined,
);
const { qwenAuthState, cancelQwenAuth } = useQwenAuth(
pendingAuthType,
isAuthenticating,
);
const onAuthError = useCallback(
(error: string | null) => {
@@ -57,132 +52,90 @@ export const useAuthCommand = (
[setAuthError, setAuthState],
);
const handleAuthFailure = useCallback(
(error: unknown) => {
setIsAuthenticating(false);
const errorMessage = `Failed to authenticate. Message: ${getErrorMessage(error)}`;
onAuthError(errorMessage);
// Log authentication failure
if (pendingAuthType) {
const authEvent = new AuthEvent(
pendingAuthType,
'manual',
'error',
errorMessage,
);
logAuth(config, authEvent);
// Authentication flow
useEffect(() => {
const authFlow = async () => {
const authType = settings.merged.security?.auth?.selectedType;
if (isAuthDialogOpen || !authType) {
return;
}
const validationError = validateAuthMethodWithSettings(
authType,
settings,
);
if (validationError) {
onAuthError(validationError);
return;
}
},
[onAuthError, pendingAuthType, config],
);
const handleAuthSuccess = useCallback(
async (
authType: AuthType,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
try {
settings.setValue(scope, 'security.auth.selectedType', authType);
setIsAuthenticating(true);
await config.refreshAuth(authType);
console.log(`Authenticated via "${authType}".`);
setAuthError(null);
setAuthState(AuthState.Authenticated);
} catch (e) {
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
} finally {
setIsAuthenticating(false);
}
};
// Only update credentials if not switching to QWEN_OAUTH,
// so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
if (authType !== AuthType.QWEN_OAUTH && credentials) {
if (credentials?.apiKey != null) {
void authFlow();
}, [isAuthDialogOpen, settings, config, onAuthError]);
// Handle auth selection from dialog
const handleAuthSelect = useCallback(
async (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
) => {
if (authType) {
await clearCachedCredentialFile();
// Save OpenAI credentials if provided
if (credentials) {
// Update Config's internal generationConfig before calling refreshAuth
// This ensures refreshAuth has access to the new credentials
config.updateCredentials({
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
});
// Also set environment variables for compatibility with other parts of the code
if (credentials.apiKey) {
settings.setValue(
scope,
'security.auth.apiKey',
credentials.apiKey,
);
}
if (credentials?.baseUrl != null) {
if (credentials.baseUrl) {
settings.setValue(
scope,
'security.auth.baseUrl',
credentials.baseUrl,
);
}
if (credentials?.model != null) {
if (credentials.model) {
settings.setValue(scope, 'model.name', credentials.model);
}
await clearCachedCredentialFile();
}
} catch (error) {
handleAuthFailure(error);
return;
settings.setValue(scope, 'security.auth.selectedType', authType);
}
setAuthError(null);
setAuthState(AuthState.Authenticated);
setPendingAuthType(undefined);
setIsAuthDialogOpen(false);
setIsAuthenticating(false);
// Log authentication success
const authEvent = new AuthEvent(authType, 'manual', 'success');
logAuth(config, authEvent);
// Show success message
addItem(
{
type: MessageType.INFO,
text: `Authenticated successfully with ${authType} credentials.`,
},
Date.now(),
);
},
[settings, handleAuthFailure, config, addItem],
);
const performAuth = useCallback(
async (
authType: AuthType,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
try {
await config.refreshAuth(authType);
handleAuthSuccess(authType, scope, credentials);
} catch (e) {
handleAuthFailure(e);
}
},
[config, handleAuthSuccess, handleAuthFailure],
);
const handleAuthSelect = useCallback(
async (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
if (!authType) {
setIsAuthDialogOpen(false);
setAuthError(null);
return;
}
setPendingAuthType(authType);
setAuthError(null);
setIsAuthDialogOpen(false);
setIsAuthenticating(true);
if (authType === AuthType.USE_OPENAI) {
if (credentials) {
config.updateCredentials({
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
});
await performAuth(authType, scope, credentials);
}
return;
}
await performAuth(authType, scope);
},
[config, performAuth],
[settings, config],
);
const openAuthDialog = useCallback(() => {
@@ -190,45 +143,8 @@ export const useAuthCommand = (
}, []);
const cancelAuthentication = useCallback(() => {
if (isAuthenticating && pendingAuthType === AuthType.QWEN_OAUTH) {
cancelQwenAuth();
}
// Log authentication cancellation
if (isAuthenticating && pendingAuthType) {
const authEvent = new AuthEvent(pendingAuthType, 'manual', 'cancelled');
logAuth(config, authEvent);
}
// Do not reset pendingAuthType here, persist the previously selected type.
setIsAuthenticating(false);
setIsAuthDialogOpen(true);
setAuthError(null);
}, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]);
/**
/**
* We previously used a useEffect to trigger authentication automatically when
* settings.security.auth.selectedType changed. This caused problems: if authentication failed,
* the UI could get stuck, since settings.json would update before success. Now, we
* update selectedType in settings only when authentication fully succeeds.
* Authentication is triggered explicitly—either during initial app startup or when the
* user switches methods—not reactively through settings changes. This avoids repeated
* or broken authentication cycles.
*/
useEffect(() => {
const defaultAuthType = process.env['QWEN_DEFAULT_AUTH_TYPE'];
if (
defaultAuthType &&
![AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].includes(
defaultAuthType as AuthType,
)
) {
onAuthError(
`Invalid QWEN_DEFAULT_AUTH_TYPE value: "${defaultAuthType}". Valid values are: ${[AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', ')}`,
);
}
}, [onAuthError]);
}, []);
return {
authState,
@@ -237,8 +153,6 @@ export const useAuthCommand = (
onAuthError,
isAuthDialogOpen,
isAuthenticating,
pendingAuthType,
qwenAuthState,
handleAuthSelect,
openAuthDialog,
cancelAuthentication,

View File

@@ -12,9 +12,9 @@ import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js';
import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { AuthInProgress } from '../auth/AuthInProgress.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
@@ -26,9 +26,6 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { SettingScope } from '../../config/settings.js';
import { AuthState } from '../types.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import process from 'node:process';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
@@ -59,16 +56,6 @@ export const DialogManager = ({
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
uiState;
const getDefaultOpenAIConfig = () => {
const fromSettings = settings.merged.security?.auth;
const modelSettings = settings.merged.model;
return {
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
};
};
if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) {
return (
<WelcomeBackDialog
@@ -220,56 +207,39 @@ export const DialogManager = ({
if (uiState.isVisionSwitchDialogOpen) {
return <ModelSwitchDialog onSelect={uiActions.handleVisionSwitchSelect} />;
}
if (uiState.isAuthDialogOpen || uiState.authError) {
return (
<Box flexDirection="column">
<AuthDialog />
</Box>
);
}
if (uiState.isAuthenticating) {
if (uiState.pendingAuthType === AuthType.USE_OPENAI) {
const defaults = getDefaultOpenAIConfig();
return (
<OpenAIKeyPrompt
onSubmit={(apiKey, baseUrl, model) => {
uiActions.handleAuthSelect(AuthType.USE_OPENAI, SettingScope.User, {
apiKey,
baseUrl,
model,
});
}}
onCancel={() => {
uiActions.cancelAuthentication();
uiActions.setAuthState(AuthState.Updating);
}}
defaultApiKey={defaults.apiKey}
defaultBaseUrl={defaults.baseUrl}
defaultModel={defaults.model}
/>
);
}
if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) {
// Show Qwen OAuth progress if it's Qwen auth and OAuth is active
if (uiState.isQwenAuth && uiState.isQwenAuthenticating) {
return (
<QwenOAuthProgress
deviceAuth={uiState.qwenAuthState.deviceAuth || undefined}
authStatus={uiState.qwenAuthState.authStatus}
authMessage={uiState.qwenAuthState.authMessage}
onTimeout={() => {
uiActions.onAuthError('Qwen OAuth authentication timed out.');
uiActions.cancelAuthentication();
uiActions.setAuthState(AuthState.Updating);
}}
onCancel={() => {
uiActions.cancelAuthentication();
uiActions.setAuthState(AuthState.Updating);
}}
deviceAuth={uiState.deviceAuth || undefined}
authStatus={uiState.authStatus}
authMessage={uiState.authMessage}
onTimeout={uiActions.handleQwenAuthTimeout}
onCancel={uiActions.handleQwenAuthCancel}
/>
);
}
// Default auth progress for other auth types
return (
<AuthInProgress
onTimeout={() => {
uiActions.onAuthError('Authentication cancelled.');
}}
/>
);
}
if (uiState.isAuthDialogOpen) {
return (
<Box flexDirection="column">
<AuthDialog
onSelect={uiActions.handleAuthSelect}
settings={settings}
initialErrorMessage={uiState.authError}
/>
</Box>
);
}
if (uiState.isEditorDialogOpen) {
return (

View File

@@ -6,7 +6,6 @@
import type React from 'react';
import { useState } from 'react';
import { z } from 'zod';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -19,16 +18,6 @@ interface OpenAIKeyPromptProps {
defaultModel?: string;
}
export const credentialSchema = z.object({
apiKey: z.string().min(1, 'API key is required'),
baseUrl: z
.union([z.string().url('Base URL must be a valid URL'), z.literal('')])
.optional(),
model: z.string().min(1, 'Model must be a non-empty string').optional(),
});
export type OpenAICredentials = z.infer<typeof credentialSchema>;
export function OpenAIKeyPrompt({
onSubmit,
onCancel,
@@ -42,34 +31,6 @@ export function OpenAIKeyPrompt({
const [currentField, setCurrentField] = useState<
'apiKey' | 'baseUrl' | 'model'
>('apiKey');
const [validationError, setValidationError] = useState<string | null>(null);
const validateAndSubmit = () => {
setValidationError(null);
try {
const validated = credentialSchema.parse({
apiKey: apiKey.trim(),
baseUrl: baseUrl.trim() || undefined,
model: model.trim() || undefined,
});
onSubmit(
validated.apiKey,
validated.baseUrl === '' ? '' : validated.baseUrl || '',
validated.model || '',
);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessage = error.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ');
setValidationError(`Invalid credentials: ${errorMessage}`);
} else {
setValidationError('Failed to validate credentials');
}
}
};
useKeypress(
(key) => {
@@ -91,7 +52,7 @@ export function OpenAIKeyPrompt({
} else if (currentField === 'model') {
// 只有在提交时才检查 API key 是否为空
if (apiKey.trim()) {
validateAndSubmit();
onSubmit(apiKey.trim(), baseUrl.trim(), model.trim());
} else {
// 如果 API key 为空,回到 API key 字段
setCurrentField('apiKey');
@@ -207,11 +168,6 @@ export function OpenAIKeyPrompt({
<Text bold color={Colors.AccentBlue}>
OpenAI Configuration Required
</Text>
{validationError && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{validationError}</Text>
</Box>
)}
<Box marginTop={1}>
<Text>
Please enter your OpenAI configuration. You can get an API key from{' '}

View File

@@ -8,7 +8,7 @@
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
import { useKeypress } from '../hooks/useKeypress.js';
import type { Key } from '../contexts/KeypressContext.js';
@@ -42,13 +42,12 @@ describe('QwenOAuthProgress', () => {
let keypressHandler: ((key: Key) => void) | null = null;
const createMockDeviceAuth = (
overrides: Partial<DeviceAuthorizationData> = {},
): DeviceAuthorizationData => ({
overrides: Partial<DeviceAuthorizationInfo> = {},
): DeviceAuthorizationInfo => ({
verification_uri: 'https://example.com/device',
verification_uri_complete: 'https://example.com/device?user_code=ABC123',
user_code: 'ABC123',
expires_in: 300,
device_code: 'test-device-code',
...overrides,
});
@@ -56,7 +55,7 @@ describe('QwenOAuthProgress', () => {
const renderComponent = (
props: Partial<{
deviceAuth: DeviceAuthorizationData;
deviceAuth: DeviceAuthorizationInfo;
authStatus:
| 'idle'
| 'polling'
@@ -159,7 +158,7 @@ describe('QwenOAuthProgress', () => {
});
it('should format time correctly', () => {
const deviceAuthWithCustomTime: DeviceAuthorizationData = {
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = {
...mockDeviceAuth,
expires_in: 125, // 2 minutes and 5 seconds
};
@@ -177,7 +176,7 @@ describe('QwenOAuthProgress', () => {
});
it('should format single digit seconds with leading zero', () => {
const deviceAuthWithCustomTime: DeviceAuthorizationData = {
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = {
...mockDeviceAuth,
expires_in: 67, // 1 minute and 7 seconds
};
@@ -197,7 +196,7 @@ describe('QwenOAuthProgress', () => {
describe('Timer functionality', () => {
it('should countdown and call onTimeout when timer expires', async () => {
const deviceAuthWithShortTime: DeviceAuthorizationData = {
const deviceAuthWithShortTime: DeviceAuthorizationInfo = {
...mockDeviceAuth,
expires_in: 2, // 2 seconds
};
@@ -521,7 +520,7 @@ describe('QwenOAuthProgress', () => {
describe('Props changes', () => {
it('should display initial timer value from deviceAuth', () => {
const deviceAuthWith10Min: DeviceAuthorizationData = {
const deviceAuthWith10Min: DeviceAuthorizationInfo = {
...mockDeviceAuth,
expires_in: 600, // 10 minutes
};

View File

@@ -11,13 +11,13 @@ import Spinner from 'ink-spinner';
import Link from 'ink-link';
import qrcode from 'qrcode-terminal';
import { Colors } from '../colors.js';
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface QwenOAuthProgressProps {
onTimeout: () => void;
onCancel: () => void;
deviceAuth?: DeviceAuthorizationData;
deviceAuth?: DeviceAuthorizationInfo;
authStatus?:
| 'idle'
| 'polling'
@@ -131,8 +131,8 @@ export function QwenOAuthProgress({
useKeypress(
(key) => {
if (authStatus === 'timeout' || authStatus === 'error') {
// Any key press in timeout or error state should trigger cancel to return to auth dialog
if (authStatus === 'timeout') {
// Any key press in timeout state should trigger cancel to return to auth dialog
onCancel();
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onCancel();
@@ -234,35 +234,6 @@ export function QwenOAuthProgress({
);
}
if (authStatus === 'error') {
return (
<Box
borderStyle="round"
borderColor={Colors.AccentRed}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentRed}>
Qwen OAuth Authentication Error
</Text>
<Box marginTop={1}>
<Text>
{authMessage ||
'An error occurred during authentication. Please try again.'}
</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
Press any key to return to authentication type selection.
</Text>
</Box>
</Box>
);
}
// Show loading state when no device auth is available yet
if (!deviceAuth) {
return (

View File

@@ -487,11 +487,8 @@ describe('SettingsDialog', () => {
it('loops back when reaching the end of an enum', async () => {
vi.mocked(saveModifiedSettings).mockClear();
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
const settings = createMockSettings({
ui: {
theme: StringEnum.BAZ,
},
});
const settings = createMockSettings();
settings.setValue(SettingScope.User, 'ui.theme', StringEnum.BAZ);
const onSelect = vi.fn();
const component = (
<KeypressProvider kittyProtocolEnabled={false}>

View File

@@ -47,7 +47,7 @@ export function CompressionMessage({
case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
return 'Could not compress chat history due to a token counting error.';
case CompressionStatus.NOOP:
return 'Nothing to compress.';
return 'Chat history is already compressed.';
default:
return '';
}

View File

@@ -16,7 +16,6 @@ import {
import { type SettingScope } from '../../config/settings.js';
import type { AuthState } from '../types.js';
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
import { type OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
export interface UIActions {
handleThemeSelect: (
@@ -31,11 +30,12 @@ export interface UIActions {
handleAuthSelect: (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: OpenAICredentials,
) => Promise<void>;
) => void;
setAuthState: (state: AuthState) => void;
onAuthError: (error: string) => void;
cancelAuthentication: () => void;
// Qwen OAuth handlers
handleQwenAuthTimeout: () => void;
handleQwenAuthCancel: () => void;
handleEditorSelect: (
editorType: EditorType | undefined,
scope: SettingScope,

View File

@@ -16,11 +16,10 @@ import type {
HistoryItemWithoutId,
StreamingState,
} from '../types.js';
import type { QwenAuthState } from '../hooks/useQwenAuth.js';
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import type {
AuthType,
IdeContext,
ApprovalMode,
UserTierId,
@@ -50,9 +49,18 @@ export interface UIState {
isConfigInitialized: boolean;
authError: string | null;
isAuthDialogOpen: boolean;
pendingAuthType: AuthType | undefined;
// Qwen OAuth state
qwenAuthState: QwenAuthState;
isQwenAuth: boolean;
isQwenAuthenticating: boolean;
deviceAuth: DeviceAuthorizationInfo | null;
authStatus:
| 'idle'
| 'polling'
| 'success'
| 'error'
| 'timeout'
| 'rate_limit';
authMessage: string | null;
editorError: string | null;
isEditorDialogOpen: boolean;
corgiMode: boolean;

View File

@@ -1,151 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { StreamingState } from '../types.js';
import {
AttentionNotificationReason,
notifyTerminalAttention,
} from '../../utils/attentionNotification.js';
import {
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS,
useAttentionNotifications,
} from './useAttentionNotifications.js';
vi.mock('../../utils/attentionNotification.js', () => ({
notifyTerminalAttention: vi.fn(),
AttentionNotificationReason: {
ToolApproval: 'tool_approval',
LongTaskComplete: 'long_task_complete',
},
}));
const mockedNotify = vi.mocked(notifyTerminalAttention);
describe('useAttentionNotifications', () => {
beforeEach(() => {
mockedNotify.mockReset();
});
const render = (
props?: Partial<Parameters<typeof useAttentionNotifications>[0]>,
) =>
renderHook(({ hookProps }) => useAttentionNotifications(hookProps), {
initialProps: {
hookProps: {
isFocused: true,
streamingState: StreamingState.Idle,
elapsedTime: 0,
...props,
},
},
});
it('notifies when tool approval is required while unfocused', () => {
const { rerender } = render();
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.ToolApproval,
);
});
it('notifies when focus is lost after entering approval wait state', () => {
const { rerender } = render({
isFocused: true,
streamingState: StreamingState.WaitingForConfirmation,
});
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
},
});
expect(mockedNotify).toHaveBeenCalledTimes(1);
});
it('sends a notification when a long task finishes while unfocused', () => {
const { rerender } = render();
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.Responding,
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5,
},
});
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.Idle,
elapsedTime: 0,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.LongTaskComplete,
);
});
it('does not notify about long tasks when the CLI is focused', () => {
const { rerender } = render();
rerender({
hookProps: {
isFocused: true,
streamingState: StreamingState.Responding,
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2,
},
});
rerender({
hookProps: {
isFocused: true,
streamingState: StreamingState.Idle,
elapsedTime: 0,
},
});
expect(mockedNotify).not.toHaveBeenCalledWith(
AttentionNotificationReason.LongTaskComplete,
expect.anything(),
);
});
it('does not treat short responses as long tasks', () => {
const { rerender } = render();
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.Responding,
elapsedTime: 5,
},
});
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.Idle,
elapsedTime: 0,
},
});
expect(mockedNotify).not.toHaveBeenCalled();
});
});

View File

@@ -1,63 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useRef } from 'react';
import { StreamingState } from '../types.js';
import {
notifyTerminalAttention,
AttentionNotificationReason,
} from '../../utils/attentionNotification.js';
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
interface UseAttentionNotificationsOptions {
isFocused: boolean;
streamingState: StreamingState;
elapsedTime: number;
}
export const useAttentionNotifications = ({
isFocused,
streamingState,
elapsedTime,
}: UseAttentionNotificationsOptions) => {
const awaitingNotificationSentRef = useRef(false);
const respondingElapsedRef = useRef(0);
useEffect(() => {
if (
streamingState === StreamingState.WaitingForConfirmation &&
!isFocused &&
!awaitingNotificationSentRef.current
) {
notifyTerminalAttention(AttentionNotificationReason.ToolApproval);
awaitingNotificationSentRef.current = true;
}
if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) {
awaitingNotificationSentRef.current = false;
}
}, [isFocused, streamingState]);
useEffect(() => {
if (streamingState === StreamingState.Responding) {
respondingElapsedRef.current = elapsedTime;
return;
}
if (streamingState === StreamingState.Idle) {
const wasLongTask =
respondingElapsedRef.current >=
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS;
if (wasLongTask && !isFocused) {
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete);
}
// Reset tracking for next task
respondingElapsedRef.current = 0;
return;
}
}, [streamingState, elapsedTime, isFocused]);
};

View File

@@ -7,7 +7,6 @@
import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js';
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
export interface DialogCloseOptions {
// Theme dialog
@@ -26,9 +25,8 @@ export interface DialogCloseOptions {
handleAuthSelect: (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: OpenAICredentials,
) => Promise<void>;
pendingAuthType: AuthType | undefined;
selectedAuthType: AuthType | undefined;
// Editor dialog
isEditorDialogOpen: boolean;

View File

@@ -1,47 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useRef } from 'react';
/**
* Hook that handles initialization authentication error only once.
* This ensures that if an auth error occurred during app initialization,
* it is reported to the user exactly once, even if the component re-renders.
*
* @param authError - The authentication error from initialization, or null if no error.
* @param onAuthError - Callback function to handle the authentication error.
*
* @example
* ```tsx
* useInitializationAuthError(
* initializationResult.authError,
* onAuthError
* );
* ```
*/
export const useInitializationAuthError = (
authError: string | null,
onAuthError: (error: string) => void,
): void => {
const hasHandled = useRef(false);
const authErrorRef = useRef(authError);
const onAuthErrorRef = useRef(onAuthError);
// Update refs to always use latest values
authErrorRef.current = authError;
onAuthErrorRef.current = onAuthError;
useEffect(() => {
if (hasHandled.current) {
return;
}
if (authErrorRef.current) {
hasHandled.current = true;
onAuthErrorRef.current(authErrorRef.current);
}
}, [authError, onAuthError]);
};

View File

@@ -6,13 +6,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
import type { DeviceAuthorizationInfo } from './useQwenAuth.js';
import { useQwenAuth } from './useQwenAuth.js';
import {
AuthType,
qwenOAuth2Events,
QwenOAuth2Event,
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
// Mock the qwenOAuth2Events
vi.mock('@qwen-code/qwen-code-core', async () => {
@@ -35,14 +36,24 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
const mockQwenOAuth2Events = vi.mocked(qwenOAuth2Events);
describe('useQwenAuth', () => {
const mockDeviceAuth: DeviceAuthorizationData = {
const mockDeviceAuth: DeviceAuthorizationInfo = {
verification_uri: 'https://oauth.qwen.com/device',
verification_uri_complete: 'https://oauth.qwen.com/device?user_code=ABC123',
user_code: 'ABC123',
expires_in: 1800,
device_code: 'device_code_123',
};
const createMockSettings = (authType: AuthType): LoadedSettings =>
({
merged: {
security: {
auth: {
selectedType: authType,
},
},
},
}) as LoadedSettings;
beforeEach(() => {
vi.clearAllMocks();
});
@@ -52,33 +63,36 @@ describe('useQwenAuth', () => {
});
it('should initialize with default state when not Qwen auth', () => {
const { result } = renderHook(() =>
useQwenAuth(AuthType.USE_GEMINI, false),
);
const settings = createMockSettings(AuthType.USE_GEMINI);
const { result } = renderHook(() => useQwenAuth(settings, false));
expect(result.current.qwenAuthState).toEqual({
expect(result.current).toEqual({
isQwenAuthenticating: false,
deviceAuth: null,
authStatus: 'idle',
authMessage: null,
isQwenAuth: false,
cancelQwenAuth: expect.any(Function),
});
expect(result.current.cancelQwenAuth).toBeInstanceOf(Function);
});
it('should initialize with default state when Qwen auth but not authenticating', () => {
const { result } = renderHook(() =>
useQwenAuth(AuthType.QWEN_OAUTH, false),
);
const settings = createMockSettings(AuthType.QWEN_OAUTH);
const { result } = renderHook(() => useQwenAuth(settings, false));
expect(result.current.qwenAuthState).toEqual({
expect(result.current).toEqual({
isQwenAuthenticating: false,
deviceAuth: null,
authStatus: 'idle',
authMessage: null,
isQwenAuth: true,
cancelQwenAuth: expect.any(Function),
});
expect(result.current.cancelQwenAuth).toBeInstanceOf(Function);
});
it('should set up event listeners when Qwen auth and authenticating', () => {
renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const settings = createMockSettings(AuthType.QWEN_OAUTH);
renderHook(() => useQwenAuth(settings, true));
expect(mockQwenOAuth2Events.on).toHaveBeenCalledWith(
QwenOAuth2Event.AuthUri,
@@ -91,7 +105,8 @@ describe('useQwenAuth', () => {
});
it('should handle device auth event', () => {
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) {
@@ -100,17 +115,19 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events;
});
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
act(() => {
handleDeviceAuth!(mockDeviceAuth);
});
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.qwenAuthState.authStatus).toBe('polling');
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.authStatus).toBe('polling');
expect(result.current.isQwenAuthenticating).toBe(true);
});
it('should handle auth progress event - success', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string,
@@ -123,19 +140,18 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events;
});
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
act(() => {
handleAuthProgress!('success', 'Authentication successful!');
});
expect(result.current.qwenAuthState.authStatus).toBe('success');
expect(result.current.qwenAuthState.authMessage).toBe(
'Authentication successful!',
);
expect(result.current.authStatus).toBe('success');
expect(result.current.authMessage).toBe('Authentication successful!');
});
it('should handle auth progress event - error', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string,
@@ -148,19 +164,18 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events;
});
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
act(() => {
handleAuthProgress!('error', 'Authentication failed');
});
expect(result.current.qwenAuthState.authStatus).toBe('error');
expect(result.current.qwenAuthState.authMessage).toBe(
'Authentication failed',
);
expect(result.current.authStatus).toBe('error');
expect(result.current.authMessage).toBe('Authentication failed');
});
it('should handle auth progress event - polling', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string,
@@ -173,19 +188,20 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events;
});
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
act(() => {
handleAuthProgress!('polling', 'Waiting for user authorization...');
});
expect(result.current.qwenAuthState.authStatus).toBe('polling');
expect(result.current.qwenAuthState.authMessage).toBe(
expect(result.current.authStatus).toBe('polling');
expect(result.current.authMessage).toBe(
'Waiting for user authorization...',
);
});
it('should handle auth progress event - rate_limit', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string,
@@ -198,7 +214,7 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events;
});
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
act(() => {
handleAuthProgress!(
@@ -207,13 +223,14 @@ describe('useQwenAuth', () => {
);
});
expect(result.current.qwenAuthState.authStatus).toBe('rate_limit');
expect(result.current.qwenAuthState.authMessage).toBe(
expect(result.current.authStatus).toBe('rate_limit');
expect(result.current.authMessage).toBe(
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
);
});
it('should handle auth progress event without message', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string,
@@ -226,30 +243,27 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events;
});
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
act(() => {
handleAuthProgress!('success');
});
expect(result.current.qwenAuthState.authStatus).toBe('success');
expect(result.current.qwenAuthState.authMessage).toBe(null);
expect(result.current.authStatus).toBe('success');
expect(result.current.authMessage).toBe(null);
});
it('should clean up event listeners when auth type changes', () => {
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
const { rerender } = renderHook(
({ pendingAuthType, isAuthenticating }) =>
useQwenAuth(pendingAuthType, isAuthenticating),
{
initialProps: {
pendingAuthType: AuthType.QWEN_OAUTH,
isAuthenticating: true,
},
},
({ settings, isAuthenticating }) =>
useQwenAuth(settings, isAuthenticating),
{ initialProps: { settings: qwenSettings, isAuthenticating: true } },
);
// Change to non-Qwen auth
rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true });
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
rerender({ settings: geminiSettings, isAuthenticating: true });
expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith(
QwenOAuth2Event.AuthUri,
@@ -262,9 +276,9 @@ describe('useQwenAuth', () => {
});
it('should clean up event listeners when authentication stops', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
const { rerender } = renderHook(
({ isAuthenticating }) =>
useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating),
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating),
{ initialProps: { isAuthenticating: true } },
);
@@ -282,9 +296,8 @@ describe('useQwenAuth', () => {
});
it('should clean up event listeners on unmount', () => {
const { unmount } = renderHook(() =>
useQwenAuth(AuthType.QWEN_OAUTH, true),
);
const settings = createMockSettings(AuthType.QWEN_OAUTH);
const { unmount } = renderHook(() => useQwenAuth(settings, true));
unmount();
@@ -299,7 +312,8 @@ describe('useQwenAuth', () => {
});
it('should reset state when switching from Qwen auth to another auth type', () => {
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) {
@@ -309,14 +323,9 @@ describe('useQwenAuth', () => {
});
const { result, rerender } = renderHook(
({ pendingAuthType, isAuthenticating }) =>
useQwenAuth(pendingAuthType, isAuthenticating),
{
initialProps: {
pendingAuthType: AuthType.QWEN_OAUTH,
isAuthenticating: true,
},
},
({ settings, isAuthenticating }) =>
useQwenAuth(settings, isAuthenticating),
{ initialProps: { settings: qwenSettings, isAuthenticating: true } },
);
// Simulate device auth
@@ -324,19 +333,22 @@ describe('useQwenAuth', () => {
handleDeviceAuth!(mockDeviceAuth);
});
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.qwenAuthState.authStatus).toBe('polling');
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.authStatus).toBe('polling');
// Switch to different auth type
rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true });
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
rerender({ settings: geminiSettings, isAuthenticating: true });
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(result.current.qwenAuthState.authMessage).toBe(null);
expect(result.current.isQwenAuthenticating).toBe(false);
expect(result.current.deviceAuth).toBe(null);
expect(result.current.authStatus).toBe('idle');
expect(result.current.authMessage).toBe(null);
});
it('should reset state when authentication stops', () => {
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) {
@@ -346,8 +358,7 @@ describe('useQwenAuth', () => {
});
const { result, rerender } = renderHook(
({ isAuthenticating }) =>
useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating),
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating),
{ initialProps: { isAuthenticating: true } },
);
@@ -356,19 +367,21 @@ describe('useQwenAuth', () => {
handleDeviceAuth!(mockDeviceAuth);
});
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.qwenAuthState.authStatus).toBe('polling');
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.authStatus).toBe('polling');
// Stop authentication
rerender({ isAuthenticating: false });
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(result.current.qwenAuthState.authMessage).toBe(null);
expect(result.current.isQwenAuthenticating).toBe(false);
expect(result.current.deviceAuth).toBe(null);
expect(result.current.authStatus).toBe('idle');
expect(result.current.authMessage).toBe(null);
});
it('should handle cancelQwenAuth function', () => {
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) {
@@ -377,49 +390,53 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events;
});
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
// Set up some state
act(() => {
handleDeviceAuth!(mockDeviceAuth);
});
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
// Cancel auth
act(() => {
result.current.cancelQwenAuth();
});
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(result.current.qwenAuthState.authMessage).toBe(null);
expect(result.current.isQwenAuthenticating).toBe(false);
expect(result.current.deviceAuth).toBe(null);
expect(result.current.authStatus).toBe('idle');
expect(result.current.authMessage).toBe(null);
});
it('should handle different auth types correctly', () => {
// Test with Qwen OAuth - should set up event listeners when authenticating
it('should maintain isQwenAuth flag correctly', () => {
// Test with Qwen OAuth
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
const { result: qwenResult } = renderHook(() =>
useQwenAuth(AuthType.QWEN_OAUTH, true),
useQwenAuth(qwenSettings, false),
);
expect(qwenResult.current.qwenAuthState.authStatus).toBe('idle');
expect(mockQwenOAuth2Events.on).toHaveBeenCalled();
expect(qwenResult.current.isQwenAuth).toBe(true);
// Test with other auth types - should not set up event listeners
// Test with other auth types
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
const { result: geminiResult } = renderHook(() =>
useQwenAuth(AuthType.USE_GEMINI, true),
useQwenAuth(geminiSettings, false),
);
expect(geminiResult.current.qwenAuthState.authStatus).toBe('idle');
expect(geminiResult.current.isQwenAuth).toBe(false);
const oauthSettings = createMockSettings(AuthType.LOGIN_WITH_GOOGLE);
const { result: oauthResult } = renderHook(() =>
useQwenAuth(AuthType.LOGIN_WITH_GOOGLE, true),
useQwenAuth(oauthSettings, false),
);
expect(oauthResult.current.qwenAuthState.authStatus).toBe('idle');
expect(oauthResult.current.isQwenAuth).toBe(false);
});
it('should initialize with idle status when starting authentication with Qwen auth', () => {
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
it('should set isQwenAuthenticating to true when starting authentication with Qwen auth', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
const { result } = renderHook(() => useQwenAuth(settings, true));
expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(mockQwenOAuth2Events.on).toHaveBeenCalled();
expect(result.current.isQwenAuthenticating).toBe(true);
expect(result.current.authStatus).toBe('idle');
});
});

View File

@@ -5,15 +5,23 @@
*/
import { useState, useCallback, useEffect } from 'react';
import type { LoadedSettings } from '../../config/settings.js';
import {
AuthType,
qwenOAuth2Events,
QwenOAuth2Event,
type DeviceAuthorizationData,
} from '@qwen-code/qwen-code-core';
export interface QwenAuthState {
deviceAuth: DeviceAuthorizationData | null;
export interface DeviceAuthorizationInfo {
verification_uri: string;
verification_uri_complete: string;
user_code: string;
expires_in: number;
}
interface QwenAuthState {
isQwenAuthenticating: boolean;
deviceAuth: DeviceAuthorizationInfo | null;
authStatus:
| 'idle'
| 'polling'
@@ -25,22 +33,25 @@ export interface QwenAuthState {
}
export const useQwenAuth = (
pendingAuthType: AuthType | undefined,
settings: LoadedSettings,
isAuthenticating: boolean,
) => {
const [qwenAuthState, setQwenAuthState] = useState<QwenAuthState>({
isQwenAuthenticating: false,
deviceAuth: null,
authStatus: 'idle',
authMessage: null,
});
const isQwenAuth = pendingAuthType === AuthType.QWEN_OAUTH;
const isQwenAuth =
settings.merged.security?.auth?.selectedType === AuthType.QWEN_OAUTH;
// Set up event listeners when authentication starts
useEffect(() => {
if (!isQwenAuth || !isAuthenticating) {
// Reset state when not authenticating or not Qwen auth
setQwenAuthState({
isQwenAuthenticating: false,
deviceAuth: null,
authStatus: 'idle',
authMessage: null,
@@ -50,11 +61,12 @@ export const useQwenAuth = (
setQwenAuthState((prev) => ({
...prev,
isQwenAuthenticating: true,
authStatus: 'idle',
}));
// Set up event listeners
const handleDeviceAuth = (deviceAuth: DeviceAuthorizationData) => {
const handleDeviceAuth = (deviceAuth: DeviceAuthorizationInfo) => {
setQwenAuthState((prev) => ({
...prev,
deviceAuth: {
@@ -62,7 +74,6 @@ export const useQwenAuth = (
verification_uri_complete: deviceAuth.verification_uri_complete,
user_code: deviceAuth.user_code,
expires_in: deviceAuth.expires_in,
device_code: deviceAuth.device_code,
},
authStatus: 'polling',
}));
@@ -95,6 +106,7 @@ export const useQwenAuth = (
qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel);
setQwenAuthState({
isQwenAuthenticating: false,
deviceAuth: null,
authStatus: 'idle',
authMessage: null,
@@ -102,7 +114,8 @@ export const useQwenAuth = (
}, []);
return {
qwenAuthState,
...qwenAuthState,
isQwenAuth,
cancelQwenAuth,
};
};

View File

@@ -13,7 +13,6 @@ import {
isSlashCommand,
copyToClipboard,
getUrlOpenCommand,
CodePage,
} from './commandUtils.js';
// Mock child_process
@@ -189,10 +188,7 @@ describe('commandUtils', () => {
await copyToClipboard(testText);
expect(mockSpawn).toHaveBeenCalledWith('cmd', [
'/c',
`chcp ${CodePage.UTF8} >nul && clip`,
]);
expect(mockSpawn).toHaveBeenCalledWith('clip', []);
expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
expect(mockChild.stdin.end).toHaveBeenCalled();
});

View File

@@ -7,23 +7,6 @@
import type { SpawnOptions } from 'node:child_process';
import { spawn } from 'node:child_process';
/**
* Common Windows console code pages (CP) used for encoding conversions.
*
* @remarks
* - `UTF8` (65001): Unicode (UTF-8) — recommended for cross-language scripts.
* - `GBK` (936): Simplified Chinese — default on most Chinese Windows systems.
* - `BIG5` (950): Traditional Chinese.
* - `LATIN1` (1252): Western European — default on many Western systems.
*/
export const CodePage = {
UTF8: 65001,
GBK: 936,
BIG5: 950,
LATIN1: 1252,
} as const;
export type CodePage = (typeof CodePage)[keyof typeof CodePage];
/**
* Checks if a query string potentially represents an '@' command.
* It triggers if the query starts with '@' or contains '@' preceded by whitespace
@@ -97,7 +80,7 @@ export const copyToClipboard = async (text: string): Promise<void> => {
switch (process.platform) {
case 'win32':
return run('cmd', ['/c', `chcp ${CodePage.UTF8} >nul && clip`]);
return run('clip', []);
case 'darwin':
return run('pbcopy', []);
case 'linux':

View File

@@ -1,72 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
notifyTerminalAttention,
AttentionNotificationReason,
} from './attentionNotification.js';
describe('notifyTerminalAttention', () => {
let stream: { write: ReturnType<typeof vi.fn>; isTTY: boolean };
beforeEach(() => {
stream = { write: vi.fn().mockReturnValue(true), isTTY: true };
});
it('emits terminal bell character', () => {
const result = notifyTerminalAttention(
AttentionNotificationReason.ToolApproval,
{
stream,
},
);
expect(result).toBe(true);
expect(stream.write).toHaveBeenCalledWith('\u0007');
});
it('returns false when not running inside a tty', () => {
stream.isTTY = false;
const result = notifyTerminalAttention(
AttentionNotificationReason.ToolApproval,
{ stream },
);
expect(result).toBe(false);
expect(stream.write).not.toHaveBeenCalled();
});
it('returns false when stream write fails', () => {
stream.write = vi.fn().mockImplementation(() => {
throw new Error('Write failed');
});
const result = notifyTerminalAttention(
AttentionNotificationReason.ToolApproval,
{ stream },
);
expect(result).toBe(false);
});
it('works with different notification reasons', () => {
const reasons = [
AttentionNotificationReason.ToolApproval,
AttentionNotificationReason.LongTaskComplete,
];
reasons.forEach((reason) => {
stream.write.mockClear();
const result = notifyTerminalAttention(reason, { stream });
expect(result).toBe(true);
expect(stream.write).toHaveBeenCalledWith('\u0007');
});
});
});

View File

@@ -1,43 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
export enum AttentionNotificationReason {
ToolApproval = 'tool_approval',
LongTaskComplete = 'long_task_complete',
}
export interface TerminalNotificationOptions {
stream?: Pick<NodeJS.WriteStream, 'write' | 'isTTY'>;
}
const TERMINAL_BELL = '\u0007';
/**
* Grabs the user's attention by emitting the terminal bell character.
* This causes the terminal to flash or play a sound, alerting the user
* to check the CLI for important events.
*
* @returns true when the bell was successfully written to the terminal.
*/
export function notifyTerminalAttention(
_reason: AttentionNotificationReason,
options: TerminalNotificationOptions = {},
): boolean {
const stream = options.stream ?? process.stdout;
if (!stream?.write || stream.isTTY === false) {
return false;
}
try {
stream.write(TERMINAL_BELL);
return true;
} catch (error) {
console.warn('Failed to send terminal bell:', error);
return false;
}
}

View File

@@ -30,7 +30,6 @@ export {
logExtensionEnable,
logIdeConnection,
logExtensionDisable,
logAuth,
} from './src/telemetry/loggers.js';
export {
@@ -41,7 +40,6 @@ export {
ExtensionEnableEvent,
ExtensionUninstallEvent,
ModelSlashCommandEvent,
AuthEvent,
} from './src/telemetry/types.js';
export { makeFakeConfig } from './src/test-utils/config.js';
export * from './src/utils/pathReader.js';

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.2.3",
"version": "0.2.2",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -20,81 +20,66 @@ const vendorDir = path.join(packageRoot, 'vendor', 'ripgrep');
/**
* Remove quarantine attribute and set executable permissions on macOS/Linux
* This script never throws errors to avoid blocking npm workflows.
*/
function setupRipgrepBinaries() {
if (!fs.existsSync(vendorDir)) {
console.log('Vendor directory not found, skipping ripgrep setup');
return;
}
const platform = process.platform;
const arch = process.arch;
// Determine the binary directory based on platform and architecture
let binaryDir;
if (platform === 'darwin' || platform === 'linux') {
const archStr = arch === 'x64' || arch === 'arm64' ? arch : null;
if (archStr) {
binaryDir = path.join(vendorDir, `${archStr}-${platform}`);
}
} else if (platform === 'win32') {
// Windows doesn't need these fixes
return;
}
if (!binaryDir || !fs.existsSync(binaryDir)) {
console.log(
`Binary directory not found for ${platform}-${arch}, skipping ripgrep setup`,
);
return;
}
const rgBinary = path.join(binaryDir, 'rg');
if (!fs.existsSync(rgBinary)) {
console.log(`Ripgrep binary not found at ${rgBinary}`);
return;
}
try {
if (!fs.existsSync(vendorDir)) {
console.log(' Vendor directory not found, skipping ripgrep setup');
return;
}
// Set executable permissions
fs.chmodSync(rgBinary, 0o755);
console.log(`✓ Set executable permissions on ${rgBinary}`);
const platform = process.platform;
const arch = process.arch;
// Determine the binary directory based on platform and architecture
let binaryDir;
if (platform === 'darwin' || platform === 'linux') {
const archStr = arch === 'x64' || arch === 'arm64' ? arch : null;
if (archStr) {
binaryDir = path.join(vendorDir, `${archStr}-${platform}`);
}
} else if (platform === 'win32') {
// Windows doesn't need these fixes
console.log(' Windows detected, skipping ripgrep setup');
return;
}
if (!binaryDir || !fs.existsSync(binaryDir)) {
console.log(
` Binary directory not found for ${platform}-${arch}, skipping ripgrep setup`,
);
return;
}
const rgBinary = path.join(binaryDir, 'rg');
if (!fs.existsSync(rgBinary)) {
console.log(` Ripgrep binary not found at ${rgBinary}, skipping setup`);
return;
}
try {
// Set executable permissions
fs.chmodSync(rgBinary, 0o755);
console.log(`✓ Set executable permissions on ${rgBinary}`);
// On macOS, remove quarantine attribute
if (platform === 'darwin') {
try {
execSync(`xattr -d com.apple.quarantine "${rgBinary}"`, {
stdio: 'pipe',
});
console.log(`✓ Removed quarantine attribute from ${rgBinary}`);
} catch {
// Quarantine attribute might not exist, which is fine
console.log(' Quarantine attribute not present or already removed');
// On macOS, remove quarantine attribute
if (platform === 'darwin') {
try {
execSync(`xattr -d com.apple.quarantine "${rgBinary}"`, {
stdio: 'pipe',
});
console.log(`✓ Removed quarantine attribute from ${rgBinary}`);
} catch (error) {
// Quarantine attribute might not exist, which is fine
if (error.message && !error.message.includes('No such xattr')) {
console.warn(
`Warning: Could not remove quarantine attribute: ${error.message}`,
);
}
}
} catch (error) {
console.log(
`⚠ Could not complete ripgrep setup: ${error.message || 'Unknown error'}`,
);
console.log(' This is not critical - ripgrep may still work correctly');
}
} catch (error) {
console.log(
`⚠ Ripgrep setup encountered an issue: ${error.message || 'Unknown error'}`,
);
console.log(' Continuing anyway - this should not affect functionality');
console.error(`Error setting up ripgrep binary: ${error.message}`);
}
}
// Wrap the entire execution to ensure no errors escape to npm
try {
setupRipgrepBinaries();
} catch {
// Last resort catch - never let errors block npm
console.log('⚠ Postinstall script encountered an unexpected error');
console.log(' This will not affect the installation');
}
setupRipgrepBinaries();

View File

@@ -562,7 +562,7 @@ export class Config {
}
}
async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) {
async refreshAuth(authMethod: AuthType) {
// Vertex and Genai have incompatible encryption and sending history with
// throughtSignature from Genai to Vertex will fail, we need to strip them
if (
@@ -582,7 +582,6 @@ export class Config {
newContentGeneratorConfig,
this,
this.getSessionId(),
isInitialAuth,
);
// Only assign to instance properties after successful initialization
this.contentGeneratorConfig = newContentGeneratorConfig;

View File

@@ -120,7 +120,6 @@ export async function createContentGenerator(
config: ContentGeneratorConfig,
gcConfig: Config,
sessionId?: string,
isInitialAuth?: boolean,
): Promise<ContentGenerator> {
const version = process.env['CLI_VERSION'] || process.version;
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
@@ -192,17 +191,13 @@ export async function createContentGenerator(
try {
// Get the Qwen OAuth client (now includes integrated token management)
// If this is initial auth, require cached credentials to detect missing credentials
const qwenClient = await getQwenOauthClient(
gcConfig,
isInitialAuth ? { requireCachedCredentials: true } : undefined,
);
const qwenClient = await getQwenOauthClient(gcConfig);
// Create the content generator with dynamic token management
return new QwenContentGenerator(qwenClient, config, gcConfig);
} catch (error) {
throw new Error(
`${error instanceof Error ? error.message : String(error)}`,
`Failed to initialize Qwen: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -13,7 +13,6 @@ import { OpenAIContentGenerator } from './openaiContentGenerator.js';
import {
DashScopeOpenAICompatibleProvider,
DeepSeekOpenAICompatibleProvider,
ModelScopeOpenAICompatibleProvider,
OpenRouterOpenAICompatibleProvider,
type OpenAICompatibleProvider,
DefaultOpenAICompatibleProvider,
@@ -79,14 +78,6 @@ export function determineProvider(
);
}
// Check for ModelScope provider
if (ModelScopeOpenAICompatibleProvider.isModelScopeProvider(config)) {
return new ModelScopeOpenAICompatibleProvider(
contentGeneratorConfig,
cliConfig,
);
}
// Default provider for standard OpenAI-compatible APIs
return new DefaultOpenAICompatibleProvider(contentGeneratorConfig, cliConfig);
}

View File

@@ -1,4 +1,3 @@
export { ModelScopeOpenAICompatibleProvider } from './modelscope.js';
export { DashScopeOpenAICompatibleProvider } from './dashscope.js';
export { DeepSeekOpenAICompatibleProvider } from './deepseek.js';
export { OpenRouterOpenAICompatibleProvider } from './openrouter.js';

View File

@@ -1,96 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type OpenAI from 'openai';
import { ModelScopeOpenAICompatibleProvider } from './modelscope.js';
import type { Config } from '../../../config/config.js';
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
vi.mock('openai');
describe('ModelScopeOpenAICompatibleProvider', () => {
let provider: ModelScopeOpenAICompatibleProvider;
let mockContentGeneratorConfig: ContentGeneratorConfig;
let mockCliConfig: Config;
beforeEach(() => {
mockContentGeneratorConfig = {
apiKey: 'test-api-key',
baseUrl: 'https://api.modelscope.cn/v1',
model: 'qwen-max',
} as ContentGeneratorConfig;
mockCliConfig = {
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
provider = new ModelScopeOpenAICompatibleProvider(
mockContentGeneratorConfig,
mockCliConfig,
);
});
describe('isModelScopeProvider', () => {
it('should return true if baseUrl includes "modelscope"', () => {
const config = { baseUrl: 'https://api.modelscope.cn/v1' };
expect(
ModelScopeOpenAICompatibleProvider.isModelScopeProvider(
config as ContentGeneratorConfig,
),
).toBe(true);
});
it('should return false if baseUrl does not include "modelscope"', () => {
const config = { baseUrl: 'https://api.openai.com/v1' };
expect(
ModelScopeOpenAICompatibleProvider.isModelScopeProvider(
config as ContentGeneratorConfig,
),
).toBe(false);
});
});
describe('buildRequest', () => {
it('should remove stream_options when stream is false', () => {
const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'qwen-max',
messages: [{ role: 'user', content: 'Hello!' }],
stream: false,
stream_options: { include_usage: true },
};
const result = provider.buildRequest(originalRequest, 'prompt-id');
expect(result).not.toHaveProperty('stream_options');
});
it('should keep stream_options when stream is true', () => {
const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'qwen-max',
messages: [{ role: 'user', content: 'Hello!' }],
stream: true,
stream_options: { include_usage: true },
};
const result = provider.buildRequest(originalRequest, 'prompt-id');
expect(result).toHaveProperty('stream_options');
});
it('should handle requests without stream_options', () => {
const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'qwen-max',
messages: [{ role: 'user', content: 'Hello!' }],
stream: false,
};
const result = provider.buildRequest(originalRequest, 'prompt-id');
expect(result).not.toHaveProperty('stream_options');
});
});
});

View File

@@ -1,32 +0,0 @@
import type OpenAI from 'openai';
import { DefaultOpenAICompatibleProvider } from './default.js';
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
/**
* Provider for ModelScope API
*/
export class ModelScopeOpenAICompatibleProvider extends DefaultOpenAICompatibleProvider {
/**
* Checks if the configuration is for ModelScope.
*/
static isModelScopeProvider(config: ContentGeneratorConfig): boolean {
return !!config.baseUrl?.includes('modelscope');
}
/**
* ModelScope does not support `stream_options` when `stream` is false.
* This method removes `stream_options` if `stream` is not true.
*/
override buildRequest(
request: OpenAI.Chat.ChatCompletionCreateParams,
userPromptId: string,
): OpenAI.Chat.ChatCompletionCreateParams {
const newRequest = super.buildRequest(request, userPromptId);
if (!newRequest.stream) {
delete (newRequest as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming)
.stream_options;
}
return newRequest;
}
}

View File

@@ -623,16 +623,14 @@ describe('QwenOAuth2Client', () => {
});
it('should handle authorization_pending with HTTP 400 according to RFC 8628', async () => {
const errorData = {
error: 'authorization_pending',
error_description: 'The authorization request is still pending',
};
const mockResponse = {
ok: false,
status: 400,
statusText: 'Bad Request',
text: async () => JSON.stringify(errorData),
json: async () => errorData,
json: async () => ({
error: 'authorization_pending',
error_description: 'The authorization request is still pending',
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
@@ -648,16 +646,14 @@ describe('QwenOAuth2Client', () => {
});
it('should handle slow_down with HTTP 429 according to RFC 8628', async () => {
const errorData = {
error: 'slow_down',
error_description: 'The client is polling too frequently',
};
const mockResponse = {
ok: false,
status: 429,
statusText: 'Too Many Requests',
text: async () => JSON.stringify(errorData),
json: async () => errorData,
json: async () => ({
error: 'slow_down',
error_description: 'The client is polling too frequently',
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
@@ -829,7 +825,7 @@ describe('getQwenOAuthClient', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Device authorization flow failed');
).rejects.toThrow('Qwen OAuth authentication failed');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -987,7 +983,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Device authorization flow failed');
).rejects.toThrow('Qwen OAuth authentication failed');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -1036,7 +1032,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Authorization timeout, please restart the process.');
).rejects.toThrow('Qwen OAuth authentication timed out');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -1086,7 +1082,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow(
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
'Too many request for Qwen OAuth authentication, please try again later.',
);
SharedTokenManager.getInstance = originalGetInstance;
@@ -1123,7 +1119,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Device authorization flow failed');
).rejects.toThrow('Qwen OAuth authentication failed');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -1181,7 +1177,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Device authorization flow failed');
).rejects.toThrow('Qwen OAuth authentication failed');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -1268,9 +1264,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow(
'Device code expired or invalid, please restart the authorization process.',
);
).rejects.toThrow('Qwen OAuth authentication failed');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -1997,16 +1991,14 @@ describe('Enhanced Error Handling and Edge Cases', () => {
});
it('should handle authorization_pending with correct status', async () => {
const errorData = {
error: 'authorization_pending',
error_description: 'Authorization request is pending',
};
const mockResponse = {
ok: false,
status: 400,
statusText: 'Bad Request',
text: vi.fn().mockResolvedValue(JSON.stringify(errorData)),
json: vi.fn().mockResolvedValue(errorData),
json: vi.fn().mockResolvedValue({
error: 'authorization_pending',
error_description: 'Authorization request is pending',
}),
};
vi.mocked(global.fetch).mockResolvedValue(

View File

@@ -345,47 +345,44 @@ export class QwenOAuth2Client implements IQwenOAuth2Client {
});
if (!response.ok) {
// Read response body as text first (can only be read once)
const responseText = await response.text();
// Try to parse as JSON to check for OAuth RFC 8628 standard errors
let errorData: ErrorData | null = null;
// Parse the response as JSON to check for OAuth RFC 8628 standard errors
try {
errorData = JSON.parse(responseText) as ErrorData;
} catch (_parseError) {
// If JSON parsing fails, use text response
const errorData = (await response.json()) as ErrorData;
// According to OAuth RFC 8628, handle standard polling responses
if (
response.status === 400 &&
errorData.error === 'authorization_pending'
) {
// User has not yet approved the authorization request. Continue polling.
return { status: 'pending' } as DeviceTokenPendingData;
}
if (response.status === 429 && errorData.error === 'slow_down') {
// Client is polling too frequently. Return pending with slowDown flag.
return {
status: 'pending',
slowDown: true,
} as DeviceTokenPendingData;
}
// Handle other 400 errors (access_denied, expired_token, etc.) as real errors
// For other errors, throw with proper error information
const error = new Error(
`Device token poll failed: ${response.status} ${response.statusText}. Response: ${responseText}`,
`Device token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description || 'No details provided'}`,
);
(error as Error & { status?: number }).status = response.status;
throw error;
} catch (_parseError) {
// If JSON parsing fails, fall back to text response
const errorData = await response.text();
const error = new Error(
`Device token poll failed: ${response.status} ${response.statusText}. Response: ${errorData}`,
);
(error as Error & { status?: number }).status = response.status;
throw error;
}
// According to OAuth RFC 8628, handle standard polling responses
if (
response.status === 400 &&
errorData.error === 'authorization_pending'
) {
// User has not yet approved the authorization request. Continue polling.
return { status: 'pending' } as DeviceTokenPendingData;
}
if (response.status === 429 && errorData.error === 'slow_down') {
// Client is polling too frequently. Return pending with slowDown flag.
return {
status: 'pending',
slowDown: true,
} as DeviceTokenPendingData;
}
// Handle other 400 errors (access_denied, expired_token, etc.) as real errors
// For other errors, throw with proper error information
const error = new Error(
`Device token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description}`,
);
(error as Error & { status?: number }).status = response.status;
throw error;
}
return (await response.json()) as DeviceTokenResponse;
@@ -470,7 +467,6 @@ export type AuthResult =
| {
success: false;
reason: 'timeout' | 'cancelled' | 'error' | 'rate_limit';
message?: string; // Detailed error message for better error reporting
};
/**
@@ -480,7 +476,6 @@ export const qwenOAuth2Events = new EventEmitter();
export async function getQwenOAuthClient(
config: Config,
options?: { requireCachedCredentials?: boolean },
): Promise<QwenOAuth2Client> {
const client = new QwenOAuth2Client();
@@ -493,6 +488,11 @@ export async function getQwenOAuthClient(
client.setCredentials(credentials);
return client;
} catch (error: unknown) {
console.debug(
'Shared token manager failed, attempting device flow:',
error,
);
// Handle specific token manager errors
if (error instanceof TokenManagerError) {
switch (error.type) {
@@ -520,20 +520,12 @@ export async function getQwenOAuthClient(
// Try device flow instead of forcing refresh
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Use detailed error message if available, otherwise use default
const errorMessage =
result.message || 'Qwen OAuth authentication failed';
throw new Error(errorMessage);
throw new Error('Qwen OAuth authentication failed');
}
return client;
}
if (options?.requireCachedCredentials) {
throw new Error(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
}
// No cached credentials, use device authorization flow for authentication
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Only emit timeout event if the failure reason is actually timeout
@@ -546,24 +538,20 @@ export async function getQwenOAuthClient(
);
}
// Use detailed error message if available, otherwise use default based on reason
const errorMessage =
result.message ||
(() => {
switch (result.reason) {
case 'timeout':
return 'Qwen OAuth authentication timed out';
case 'cancelled':
return 'Qwen OAuth authentication was cancelled by user';
case 'rate_limit':
return 'Too many request for Qwen OAuth authentication, please try again later.';
case 'error':
default:
return 'Qwen OAuth authentication failed';
}
})();
throw new Error(errorMessage);
// Throw error with appropriate message based on failure reason
switch (result.reason) {
case 'timeout':
throw new Error('Qwen OAuth authentication timed out');
case 'cancelled':
throw new Error('Qwen OAuth authentication was cancelled by user');
case 'rate_limit':
throw new Error(
'Too many request for Qwen OAuth authentication, please try again later.',
);
case 'error':
default:
throw new Error('Qwen OAuth authentication failed');
}
}
return client;
@@ -656,10 +644,13 @@ async function authWithQwenDeviceFlow(
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Check if authentication was cancelled
if (isCancelled) {
const message = 'Authentication cancelled by user.';
console.debug('\n' + message);
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
return { success: false, reason: 'cancelled', message };
console.debug('\nAuthentication cancelled by user.');
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
'error',
'Authentication cancelled by user.',
);
return { success: false, reason: 'cancelled' };
}
try {
@@ -747,14 +738,13 @@ async function authWithQwenDeviceFlow(
// Check for cancellation after waiting
if (isCancelled) {
const message = 'Authentication cancelled by user.';
console.debug('\n' + message);
console.debug('\nAuthentication cancelled by user.');
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
'error',
message,
'Authentication cancelled by user.',
);
return { success: false, reason: 'cancelled', message };
return { success: false, reason: 'cancelled' };
}
continue;
@@ -768,7 +758,7 @@ async function authWithQwenDeviceFlow(
);
}
} catch (error: unknown) {
// Extract error information
// Handle specific error cases
const errorMessage =
error instanceof Error ? error.message : String(error);
const statusCode =
@@ -776,49 +766,42 @@ async function authWithQwenDeviceFlow(
? (error as Error & { status?: number }).status
: null;
// Helper function to handle error and stop polling
const handleError = (
reason: 'error' | 'rate_limit',
message: string,
eventType: 'error' | 'rate_limit' = 'error',
): AuthResult => {
if (errorMessage.includes('401') || statusCode === 401) {
const message =
'Device code expired or invalid, please restart the authorization process.';
// Emit error event
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
return { success: false, reason: 'error' };
}
// Handle 429 Too Many Requests error
if (errorMessage.includes('429') || statusCode === 429) {
const message =
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.';
// Emit rate limit event to notify user
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
eventType,
'rate_limit',
message,
);
console.error('\n' + message);
return { success: false, reason, message };
};
// Handle credential caching failures - stop polling immediately
if (errorMessage.includes('Failed to cache credentials')) {
return handleError('error', errorMessage);
}
console.log('\n' + message);
// Handle 401 Unauthorized - device code expired or invalid
if (errorMessage.includes('401') || statusCode === 401) {
return handleError(
'error',
'Device code expired or invalid, please restart the authorization process.',
);
}
// Handle 429 Too Many Requests - rate limiting
if (errorMessage.includes('429') || statusCode === 429) {
return handleError(
'rate_limit',
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
'rate_limit',
);
// Return false to stop polling and go back to auth selection
return { success: false, reason: 'rate_limit' };
}
const message = `Error polling for token: ${errorMessage}`;
// Emit error event
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
// Check for cancellation before waiting
if (isCancelled) {
const message = 'Authentication cancelled by user.';
return { success: false, reason: 'cancelled', message };
return { success: false, reason: 'cancelled' };
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
@@ -835,12 +818,11 @@ async function authWithQwenDeviceFlow(
);
console.error('\n' + timeoutMessage);
return { success: false, reason: 'timeout', message: timeoutMessage };
return { success: false, reason: 'timeout' };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
const message = `Device authorization flow failed: ${errorMessage}`;
console.error(message);
return { success: false, reason: 'error', message };
console.error('Device authorization flow failed:', errorMessage);
return { success: false, reason: 'error' };
} finally {
// Clean up event listener
qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler);
@@ -870,30 +852,10 @@ async function loadCachedQwenCredentials(
async function cacheQwenCredentials(credentials: QwenCredentials) {
const filePath = getQwenCachedCredentialPath();
try {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.mkdir(path.dirname(filePath), { recursive: true });
const credString = JSON.stringify(credentials, null, 2);
await fs.writeFile(filePath, credString);
} catch (error: unknown) {
// Handle file system errors (e.g., EACCES permission denied)
const errorMessage = error instanceof Error ? error.message : String(error);
const errorCode =
error instanceof Error && 'code' in error
? (error as Error & { code?: string }).code
: undefined;
if (errorCode === 'EACCES') {
throw new Error(
`Failed to cache credentials: Permission denied (EACCES). Current user has no permission to access \`${filePath}\`. Please check permissions.`,
);
}
// Throw error for other file system failures
throw new Error(
`Failed to cache credentials: error when creating folder \`${path.dirname(filePath)}\` and writing to \`${filePath}\`. ${errorMessage}. Please check permissions.`,
);
}
const credString = JSON.stringify(credentials, null, 2);
await fs.writeFile(filePath, credString);
}
/**

View File

@@ -33,7 +33,6 @@ export const EVENT_MALFORMED_JSON_RESPONSE =
export const EVENT_FILE_OPERATION = 'qwen-code.file_operation';
export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
export const EVENT_AUTH = 'qwen-code.auth';
// Performance Events
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';

View File

@@ -43,7 +43,6 @@ export {
logExtensionUninstall,
logRipgrepFallback,
logNextSpeakerCheck,
logAuth,
} from './loggers.js';
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
export {
@@ -62,7 +61,6 @@ export {
ToolOutputTruncatedEvent,
RipgrepFallbackEvent,
NextSpeakerCheckEvent,
AuthEvent,
} from './types.js';
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
export type { TelemetryEvent } from './types.js';

View File

@@ -37,7 +37,6 @@ import {
EVENT_SUBAGENT_EXECUTION,
EVENT_MALFORMED_JSON_RESPONSE,
EVENT_INVALID_CHUNK,
EVENT_AUTH,
} from './constants.js';
import {
recordApiErrorMetrics,
@@ -84,7 +83,6 @@ import type {
SubagentExecutionEvent,
MalformedJsonResponseEvent,
InvalidChunkEvent,
AuthEvent,
} from './types.js';
import type { UiEvent } from './uiTelemetry.js';
import { uiTelemetryService } from './uiTelemetry.js';
@@ -840,29 +838,3 @@ export function logExtensionDisable(
};
logger.emit(logRecord);
}
export function logAuth(config: Config, event: AuthEvent): void {
QwenLogger.getInstance(config)?.logAuthEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
'event.name': EVENT_AUTH,
'event.timestamp': new Date().toISOString(),
auth_type: event.auth_type,
action_type: event.action_type,
status: event.status,
};
if (event.error_message) {
attributes['error.message'] = event.error_message;
}
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `Auth event: ${event.action_type} ${event.status} for ${event.auth_type}`,
attributes,
};
logger.emit(logRecord);
}

View File

@@ -19,21 +19,6 @@ export interface RumView {
name: string;
}
export interface RumOS {
type?: string;
version?: string;
container?: string;
container_version?: string;
}
export interface RumDevice {
id?: string;
name?: string;
type?: string;
brand?: string;
model?: string;
}
export interface RumEvent {
timestamp?: number;
event_type?: 'view' | 'action' | 'exception' | 'resource';
@@ -93,8 +78,6 @@ export interface RumPayload {
user: RumUser;
session: RumSession;
view: RumView;
os?: RumOS;
device?: RumDevice;
events: RumEvent[];
properties?: Record<string, unknown>;
_v: string;

View File

@@ -13,10 +13,8 @@ import {
afterEach,
afterAll,
} from 'vitest';
import * as os from 'node:os';
import { QwenLogger, TEST_ONLY } from './qwen-logger.js';
import type { Config } from '../../config/config.js';
import { AuthType } from '../../core/contentGenerator.js';
import {
StartSessionEvent,
EndSessionEvent,
@@ -24,7 +22,7 @@ import {
KittySequenceOverflowEvent,
IdeConnectionType,
} from '../types.js';
import type { RumEvent, RumPayload } from './event-types.js';
import type { RumEvent } from './event-types.js';
// Mock dependencies
vi.mock('../../utils/user_id.js', () => ({
@@ -48,7 +46,6 @@ const makeFakeConfig = (overrides: Partial<Config> = {}): Config => {
getCliVersion: () => '1.0.0',
getProxy: () => undefined,
getContentGeneratorConfig: () => ({ authType: 'test-auth' }),
getAuthType: () => AuthType.QWEN_OAUTH,
getMcpServers: () => ({}),
getModel: () => 'test-model',
getEmbeddingModel: () => 'test-embedding',
@@ -105,24 +102,6 @@ describe('QwenLogger', () => {
});
});
describe('createRumPayload', () => {
it('includes os metadata in payload', async () => {
const logger = QwenLogger.getInstance(mockConfig)!;
const payload = await (
logger as unknown as {
createRumPayload(): Promise<RumPayload>;
}
).createRumPayload();
expect(payload.os).toEqual(
expect.objectContaining({
type: os.platform(),
version: os.release(),
}),
);
});
});
describe('event queue management', () => {
it('should handle event overflow gracefully', () => {
const debugConfig = makeFakeConfig({ getDebugMode: () => true });

View File

@@ -6,7 +6,6 @@
import { Buffer } from 'buffer';
import * as https from 'https';
import * as os from 'node:os';
import { HttpsProxyAgent } from 'https-proxy-agent';
import type {
@@ -37,7 +36,6 @@ import type {
ExtensionEnableEvent,
ModelSlashCommandEvent,
ExtensionDisableEvent,
AuthEvent,
} from '../types.js';
import { EndSessionEvent } from '../types.js';
import type {
@@ -47,7 +45,6 @@ import type {
RumResourceEvent,
RumExceptionEvent,
RumPayload,
RumOS,
} from './event-types.js';
import type { Config } from '../../config/config.js';
import { safeJsonStringify } from '../../utils/safeJsonStringify.js';
@@ -217,17 +214,9 @@ export class QwenLogger {
return this.createRumEvent('exception', type, name, properties);
}
private getOsMetadata(): RumOS {
return {
type: os.platform(),
version: os.release(),
};
}
async createRumPayload(): Promise<RumPayload> {
const authType = this.config?.getAuthType();
const version = this.config?.getCliVersion() || 'unknown';
const osMetadata = this.getOsMetadata();
return {
app: {
@@ -246,7 +235,6 @@ export class QwenLogger {
id: this.sessionId,
name: 'qwen-code-cli',
},
os: osMetadata,
events: this.events.toArray() as RumEvent[],
properties: {
@@ -747,25 +735,6 @@ export class QwenLogger {
this.flushIfNeeded();
}
logAuthEvent(event: AuthEvent): void {
const snapshots: Record<string, unknown> = {
auth_type: event.auth_type,
action_type: event.action_type,
status: event.status,
};
if (event.error_message) {
snapshots['error_message'] = event.error_message;
}
const rumEvent = this.createActionEvent('auth', 'auth', {
snapshots: JSON.stringify(snapshots),
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
// misc events
logFlashFallbackEvent(event: FlashFallbackEvent): void {
const rumEvent = this.createActionEvent('misc', 'flash_fallback', {

View File

@@ -686,29 +686,6 @@ export class SubagentExecutionEvent implements BaseTelemetryEvent {
}
}
export class AuthEvent implements BaseTelemetryEvent {
'event.name': 'auth';
'event.timestamp': string;
auth_type: AuthType;
action_type: 'auto' | 'manual';
status: 'success' | 'error' | 'cancelled';
error_message?: string;
constructor(
auth_type: AuthType,
action_type: 'auto' | 'manual',
status: 'success' | 'error' | 'cancelled',
error_message?: string,
) {
this['event.name'] = 'auth';
this['event.timestamp'] = new Date().toISOString();
this.auth_type = auth_type;
this.action_type = action_type;
this.status = status;
this.error_message = error_message;
}
}
export type TelemetryEvent =
| StartSessionEvent
| EndSessionEvent
@@ -736,8 +713,7 @@ export type TelemetryEvent =
| ExtensionInstallEvent
| ExtensionUninstallEvent
| ToolOutputTruncatedEvent
| ModelSlashCommandEvent
| AuthEvent;
| ModelSlashCommandEvent;
export class ExtensionDisableEvent implements BaseTelemetryEvent {
'event.name': 'extension_disable';

View File

@@ -22,12 +22,12 @@ import type { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { getRipgrepCommand } from '../utils/ripgrepUtils.js';
import { ensureRipgrepPath } from '../utils/ripgrepUtils.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
// Mock ripgrepUtils
vi.mock('../utils/ripgrepUtils.js', () => ({
getRipgrepCommand: vi.fn(),
ensureRipgrepPath: vi.fn(),
}));
// Mock child_process for ripgrep calls
@@ -109,7 +109,7 @@ describe('RipGrepTool', () => {
beforeEach(async () => {
vi.clearAllMocks();
(getRipgrepCommand as Mock).mockResolvedValue('/mock/path/to/rg');
(ensureRipgrepPath as Mock).mockResolvedValue('/mock/path/to/rg');
mockSpawn.mockReset();
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
fileExclusionsMock = {
@@ -588,15 +588,18 @@ describe('RipGrepTool', () => {
});
it('should throw an error if ripgrep is not available', async () => {
(getRipgrepCommand as Mock).mockResolvedValue(null);
// Make ensureRipgrepBinary throw
(ensureRipgrepPath as Mock).mockRejectedValue(
new Error('Ripgrep binary not found'),
);
const params: RipGrepToolParams = { pattern: 'world' };
const invocation = grepTool.build(params);
expect(await invocation.execute(abortSignal)).toStrictEqual({
llmContent:
'Error during grep search operation: ripgrep binary not found.',
returnDisplay: 'Error: ripgrep binary not found.',
'Error during grep search operation: Ripgrep binary not found',
returnDisplay: 'Error: Ripgrep binary not found',
});
});
});

View File

@@ -6,6 +6,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { EOL } from 'node:os';
import { spawn } from 'node:child_process';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
@@ -13,7 +14,7 @@ import { ToolNames } from './tool-names.js';
import { resolveAndValidatePath } from '../utils/paths.js';
import { getErrorMessage } from '../utils/errors.js';
import type { Config } from '../config/config.js';
import { getRipgrepCommand } from '../utils/ripgrepUtils.js';
import { ensureRipgrepPath } from '../utils/ripgrepUtils.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
import type { FileFilteringOptions } from '../config/constants.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
@@ -87,7 +88,7 @@ class GrepToolInvocation extends BaseToolInvocation<
}
// Split into lines and count total matches
const allLines = rawOutput.split('\n').filter((line) => line.trim());
const allLines = rawOutput.split(EOL).filter((line) => line.trim());
const totalMatches = allLines.length;
const matchTerm = totalMatches === 1 ? 'match' : 'matches';
@@ -158,7 +159,7 @@ class GrepToolInvocation extends BaseToolInvocation<
returnDisplay: displayMessage,
};
} catch (error) {
console.error(`Error during ripgrep search operation: ${error}`);
console.error(`Error during GrepLogic execution: ${error}`);
const errorMessage = getErrorMessage(error);
return {
llmContent: `Error during grep search operation: ${errorMessage}`,
@@ -209,15 +210,11 @@ class GrepToolInvocation extends BaseToolInvocation<
rgArgs.push(absolutePath);
try {
const rgCommand = await getRipgrepCommand(
this.config.getUseBuiltinRipgrep(),
);
if (!rgCommand) {
throw new Error('ripgrep binary not found.');
}
const rgPath = this.config.getUseBuiltinRipgrep()
? await ensureRipgrepPath()
: 'rg';
const output = await new Promise<string>((resolve, reject) => {
const child = spawn(rgCommand, rgArgs, {
const child = spawn(rgPath, rgArgs, {
windowsHide: true,
});
@@ -237,7 +234,7 @@ class GrepToolInvocation extends BaseToolInvocation<
child.on('error', (err) => {
options.signal.removeEventListener('abort', cleanup);
reject(new Error(`failed to start ripgrep: ${err.message}.`));
reject(new Error(`Failed to start ripgrep: ${err.message}.`));
});
child.on('close', (code) => {
@@ -259,7 +256,7 @@ class GrepToolInvocation extends BaseToolInvocation<
return output;
} catch (error: unknown) {
console.error(`Ripgrep failed: ${getErrorMessage(error)}`);
console.error(`GrepLogic: ripgrep failed: ${getErrorMessage(error)}`);
throw error;
}
}

View File

@@ -7,8 +7,8 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
canUseRipgrep,
getRipgrepCommand,
getBuiltinRipgrep,
ensureRipgrepPath,
getRipgrepPath,
} from './ripgrepUtils.js';
import { fileExists } from './fileUtils.js';
import path from 'node:path';
@@ -27,7 +27,7 @@ describe('ripgrepUtils', () => {
vi.clearAllMocks();
});
describe('getBulltinRipgrepPath', () => {
describe('getRipgrepPath', () => {
it('should return path with .exe extension on Windows', () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
@@ -36,7 +36,7 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
Object.defineProperty(process, 'arch', { value: 'x64' });
const rgPath = getBuiltinRipgrep();
const rgPath = getRipgrepPath();
expect(rgPath).toContain('x64-win32');
expect(rgPath).toContain('rg.exe');
@@ -55,7 +55,7 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
Object.defineProperty(process, 'arch', { value: 'arm64' });
const rgPath = getBuiltinRipgrep();
const rgPath = getRipgrepPath();
expect(rgPath).toContain('arm64-darwin');
expect(rgPath).toContain('rg');
@@ -75,7 +75,7 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'x64' });
const rgPath = getBuiltinRipgrep();
const rgPath = getRipgrepPath();
expect(rgPath).toContain('x64-linux');
expect(rgPath).toContain('rg');
@@ -87,7 +87,7 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'arch', { value: originalArch });
});
it('should return null for unsupported platform', () => {
it('should throw error for unsupported platform', () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
@@ -95,14 +95,14 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'platform', { value: 'freebsd' });
Object.defineProperty(process, 'arch', { value: 'x64' });
expect(getBuiltinRipgrep()).toBeNull();
expect(() => getRipgrepPath()).toThrow('Unsupported platform: freebsd');
// Restore original values
Object.defineProperty(process, 'platform', { value: originalPlatform });
Object.defineProperty(process, 'arch', { value: originalArch });
});
it('should return null for unsupported architecture', () => {
it('should throw error for unsupported architecture', () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
@@ -110,7 +110,7 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
Object.defineProperty(process, 'arch', { value: 'ia32' });
expect(getBuiltinRipgrep()).toBeNull();
expect(() => getRipgrepPath()).toThrow('Unsupported architecture: ia32');
// Restore original values
Object.defineProperty(process, 'platform', { value: originalPlatform });
@@ -136,7 +136,7 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'platform', { value: platform });
Object.defineProperty(process, 'arch', { value: arch });
const rgPath = getBuiltinRipgrep();
const rgPath = getRipgrepPath();
const binaryName = platform === 'win32' ? 'rg.exe' : 'rg';
const expectedPathSegment = path.join(
`${arch}-${platform}`,
@@ -169,77 +169,107 @@ describe('ripgrepUtils', () => {
expect(result).toBe(true);
expect(fileExists).toHaveBeenCalledOnce();
});
it('should fall back to system rg if bundled ripgrep binary does not exist', async () => {
(fileExists as Mock).mockResolvedValue(false);
// When useBuiltin is true but bundled binary doesn't exist,
// it should fall back to checking system rg (which will spawn a process)
// In this test environment, system rg is likely available, so result should be true
// unless spawn fails
const result = await canUseRipgrep();
// The test may pass or fail depending on system rg availability
// Just verify that fileExists was called to check bundled binary first
expect(fileExists).toHaveBeenCalledOnce();
// Result depends on whether system rg is installed
expect(typeof result).toBe('boolean');
});
// Note: Tests for system ripgrep detection (useBuiltin=false) would require mocking
// the child_process spawn function, which is complex in ESM. These cases are tested
// indirectly through integration tests.
it('should return false if platform is unsupported', async () => {
const originalPlatform = process.platform;
// Mock unsupported platform
Object.defineProperty(process, 'platform', { value: 'aix' });
const result = await canUseRipgrep();
expect(result).toBe(false);
expect(fileExists).not.toHaveBeenCalled();
// Restore original value
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should return false if architecture is unsupported', async () => {
const originalArch = process.arch;
// Mock unsupported architecture
Object.defineProperty(process, 'arch', { value: 's390x' });
const result = await canUseRipgrep();
expect(result).toBe(false);
expect(fileExists).not.toHaveBeenCalled();
// Restore original value
Object.defineProperty(process, 'arch', { value: originalArch });
});
});
describe('ensureRipgrepPath', () => {
it('should return bundled ripgrep path if binary exists (useBuiltin=true)', async () => {
describe('ensureRipgrepBinary', () => {
it('should return ripgrep path if binary exists', async () => {
(fileExists as Mock).mockResolvedValue(true);
const rgPath = await getRipgrepCommand(true);
const rgPath = await ensureRipgrepPath();
expect(rgPath).toBeDefined();
expect(rgPath).toContain('rg');
expect(rgPath).not.toBe('rg'); // Should be full path, not just 'rg'
expect(fileExists).toHaveBeenCalledOnce();
expect(fileExists).toHaveBeenCalledWith(rgPath);
});
it('should return bundled ripgrep path if binary exists (default)', async () => {
(fileExists as Mock).mockResolvedValue(true);
const rgPath = await getRipgrepCommand();
expect(rgPath).toBeDefined();
expect(rgPath).toContain('rg');
expect(fileExists).toHaveBeenCalledOnce();
});
it('should fall back to system rg if bundled binary does not exist', async () => {
it('should throw error if binary does not exist', async () => {
(fileExists as Mock).mockResolvedValue(false);
// When useBuiltin is true but bundled binary doesn't exist,
// it should fall back to checking system rg
// The test result depends on whether system rg is actually available
const rgPath = await getRipgrepCommand(true);
await expect(ensureRipgrepPath()).rejects.toThrow(
/Ripgrep binary not found/,
);
await expect(ensureRipgrepPath()).rejects.toThrow(/Platform:/);
await expect(ensureRipgrepPath()).rejects.toThrow(/Architecture:/);
expect(fileExists).toHaveBeenCalledOnce();
// If system rg is available, it should return 'rg' (or 'rg.exe' on Windows)
// This test will pass if system ripgrep is installed
expect(rgPath).toBeDefined();
expect(fileExists).toHaveBeenCalled();
});
it('should use system rg when useBuiltin=false', async () => {
// When useBuiltin is false, should skip bundled check and go straight to system rg
const rgPath = await getRipgrepCommand(false);
// Should not check for bundled binary
expect(fileExists).not.toHaveBeenCalled();
// If system rg is available, it should return 'rg' (or 'rg.exe' on Windows)
expect(rgPath).toBeDefined();
});
it('should throw error if neither bundled nor system ripgrep is available', async () => {
// This test only makes sense in an environment where system rg is not installed
// We'll skip this test in CI/local environments where rg might be available
// Instead, we test the error message format
const originalPlatform = process.platform;
// Use an unsupported platform to trigger the error path
Object.defineProperty(process, 'platform', { value: 'freebsd' });
it('should throw error with correct path information', async () => {
(fileExists as Mock).mockResolvedValue(false);
try {
await getRipgrepCommand();
// If we get here without error, system rg was available, which is fine
await ensureRipgrepPath();
// Should not reach here
expect(true).toBe(false);
} catch (error) {
expect(error).toBeInstanceOf(Error);
const errorMessage = (error as Error).message;
// Should contain helpful error information
expect(
errorMessage.includes('Ripgrep binary not found') ||
errorMessage.includes('Failed to locate ripgrep') ||
errorMessage.includes('Unsupported platform'),
).toBe(true);
expect(errorMessage).toContain('Ripgrep binary not found at');
expect(errorMessage).toContain(process.platform);
expect(errorMessage).toContain(process.arch);
}
});
it('should throw error if platform is unsupported', async () => {
const originalPlatform = process.platform;
// Mock unsupported platform
Object.defineProperty(process, 'platform', { value: 'openbsd' });
await expect(ensureRipgrepPath()).rejects.toThrow(
'Unsupported platform: openbsd',
);
// Restore original value
Object.defineProperty(process, 'platform', { value: originalPlatform });

View File

@@ -18,42 +18,37 @@ type Architecture = 'x64' | 'arm64';
/**
* Maps process.platform values to vendor directory names
*/
function getPlatformString(platform: string): Platform | undefined {
function getPlatformString(platform: string): Platform {
switch (platform) {
case 'darwin':
case 'linux':
case 'win32':
return platform;
default:
return undefined;
throw new Error(`Unsupported platform: ${platform}`);
}
}
/**
* Maps process.arch values to vendor directory names
*/
function getArchitectureString(arch: string): Architecture | undefined {
function getArchitectureString(arch: string): Architecture {
switch (arch) {
case 'x64':
case 'arm64':
return arch;
default:
return undefined;
throw new Error(`Unsupported architecture: ${arch}`);
}
}
/**
* Returns the path to the bundled ripgrep binary for the current platform
* @returns The path to the bundled ripgrep binary, or null if not available
*/
export function getBuiltinRipgrep(): string | null {
export function getRipgrepPath(): string {
const platform = getPlatformString(process.platform);
const arch = getArchitectureString(process.arch);
if (!platform || !arch) {
return null;
}
// Binary name includes .exe on Windows
const binaryName = platform === 'win32' ? 'rg.exe' : 'rg';
@@ -88,51 +83,6 @@ export function getBuiltinRipgrep(): string | null {
return vendorPath;
}
/**
* Checks if system ripgrep is available and returns the command to use
* @returns The ripgrep command ('rg' or 'rg.exe') if available, or null if not found
*/
export async function getSystemRipgrep(): Promise<string | null> {
try {
const { spawn } = await import('node:child_process');
const rgCommand = process.platform === 'win32' ? 'rg.exe' : 'rg';
const isAvailable = await new Promise<boolean>((resolve) => {
const proc = spawn(rgCommand, ['--version']);
proc.on('error', () => resolve(false));
proc.on('exit', (code) => resolve(code === 0));
});
return isAvailable ? rgCommand : null;
} catch (_error) {
return null;
}
}
/**
* Checks if ripgrep binary exists and returns its path
* @param useBuiltin If true, tries bundled ripgrep first, then falls back to system ripgrep.
* If false, only checks for system ripgrep.
* @returns The path to ripgrep binary ('rg' or 'rg.exe' for system ripgrep, or full path for bundled), or null if not available
*/
export async function getRipgrepCommand(
useBuiltin: boolean = true,
): Promise<string | null> {
try {
if (useBuiltin) {
// Try bundled ripgrep first
const rgPath = getBuiltinRipgrep();
if (rgPath && (await fileExists(rgPath))) {
return rgPath;
}
// Fallback to system rg if bundled binary is not available
}
// Check for system ripgrep
return await getSystemRipgrep();
} catch (_error) {
return null;
}
}
/**
* Checks if ripgrep binary is available
* @param useBuiltin If true, tries bundled ripgrep first, then falls back to system ripgrep.
@@ -141,6 +91,42 @@ export async function getRipgrepCommand(
export async function canUseRipgrep(
useBuiltin: boolean = true,
): Promise<boolean> {
const rgPath = await getRipgrepCommand(useBuiltin);
return rgPath !== null;
try {
if (useBuiltin) {
// Try bundled ripgrep first
const rgPath = getRipgrepPath();
if (await fileExists(rgPath)) {
return true;
}
// Fallback to system rg if bundled binary is not available
}
// Check for system ripgrep by trying to spawn 'rg --version'
const { spawn } = await import('node:child_process');
return await new Promise<boolean>((resolve) => {
const proc = spawn('rg', ['--version']);
proc.on('error', () => resolve(false));
proc.on('exit', (code) => resolve(code === 0));
});
} catch (_error) {
// Unsupported platform/arch or other error
return false;
}
}
/**
* Ensures ripgrep binary exists and returns its path
* @throws Error if ripgrep binary is not available
*/
export async function ensureRipgrepPath(): Promise<string> {
const rgPath = getRipgrepPath();
if (!(await fileExists(rgPath))) {
throw new Error(
`Ripgrep binary not found at ${rgPath}. ` +
`Platform: ${process.platform}, Architecture: ${process.arch}`,
);
}
return rgPath;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.2.3",
"version": "0.2.2",
"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.2.3",
"version": "0.2.2",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {