Compare commits

..

17 Commits

Author SHA1 Message Date
pomelo-nwu
82170e96c6 refactor(cli): centralize system information collection 2025-11-06 10:42:52 +08:00
pomelo
553a36302a Merge pull request #972 from QwenLM/custom-logging-dir
feat: support for custom OpenAI logging directory configuration
2025-11-05 19:22:46 +08:00
pomelo
498d7a083a Merge pull request #970 from seems20/fix-kimi2-token-limits
Fix kimi2 token limits
2025-11-05 19:22:30 +08:00
pomelo-nwu
3a69931791 feat: add docs for logging dir configuration 2025-11-05 18:58:53 +08:00
pomelo-nwu
d4ab328671 feat: support for custom OpenAI logging directory configuration 2025-11-05 18:49:04 +08:00
chenhuanjie
90500ea67b Merge branch 'main' into fix-kimi2-token-limits 2025-11-05 17:36:02 +08:00
pomelo
335e765df0 Merge pull request #936 from QwenLM/fix-AbortError
fix: handle AbortError gracefully when loading commands
2025-11-05 16:38:14 +08:00
pomelo-nwu
448e30bf88 feat: support custom working directory for child process in start.js 2025-11-05 16:06:35 +08:00
chenhuanjie
26215b6d0a Merge branch 'main' into fix-kimi2-token-limits 2025-11-05 15:44:39 +08:00
chenhuanjie
f6f76a17e6 fix 2025-11-05 15:12:20 +08:00
chenhuanjie
55a3b69a8e fix 2025-11-05 15:10:52 +08:00
pomelo
22bd108775 Merge pull request #885 from QwenLM/web-search
chore: Web Search Tool Refactoring with Multi-Provider Support
2025-11-05 14:51:40 +08:00
tanzhenxin
7e827833bf chore: pump version to 0.1.4 (#962) 2025-11-04 19:22:37 +08:00
tanzhenxin
45f1000dea fix (#958) 2025-11-04 15:53:31 +08:00
tanzhenxin
04f0996327 fix: /ide install failed to run on Windows (#957) 2025-11-04 15:53:03 +08:00
tanzhenxin
d8cc0a1f04 fix: #923 missing macos seatbelt files in npm package (#949) 2025-11-04 15:52:46 +08:00
pomelo-nwu
50d5cc2f6a fix: handle AbortError gracefully when loading commands 2025-10-31 17:00:28 +08:00
40 changed files with 1540 additions and 429 deletions

View File

@@ -541,6 +541,9 @@ Arguments passed directly when running the CLI can override other configurations
- Displays the version of the CLI.
- **`--openai-logging`**:
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
- **`--openai-logging-dir <directory>`**:
- Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion.
- **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging`
- **`--tavily-api-key <api_key>`**:
- Sets the Tavily API key for web search functionality for this session.
- Example: `qwen --tavily-api-key tvly-your-api-key-here`

View File

@@ -171,6 +171,18 @@ Settings are organized into categories. All settings should be placed within the
- **Description:** Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions.
- **Default:** `false`
- **`model.enableOpenAILogging`** (boolean):
- **Description:** Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files.
- **Default:** `false`
- **`model.openAILoggingDir`** (string):
- **Description:** Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory).
- **Default:** `undefined`
- **Examples:**
- `"~/qwen-logs"` - Logs to `~/qwen-logs` directory
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs`
#### `context`
- **`context.fileName`** (string or array of strings):
@@ -387,6 +399,8 @@ Here is an example of a `settings.json` file with the nested structure, new as o
"model": {
"name": "qwen3-coder-plus",
"maxSessionTurns": 10,
"enableOpenAILogging": false,
"openAILoggingDir": "~/qwen-logs",
"summarizeToolOutput": {
"run_shell_command": {
"tokenBudget": 100
@@ -557,6 +571,9 @@ Arguments passed directly when running the CLI can override other configurations
- Displays the version of the CLI.
- **`--openai-logging`**:
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
- **`--openai-logging-dir <directory>`**:
- Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion.
- **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging`
- **`--tavily-api-key <api_key>`**:
- Sets the Tavily API key for web search functionality for this session.
- Example: `qwen --tavily-api-key tvly-your-api-key-here`

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.1.3",
"version": "0.1.4",
"workspaces": [
"packages/*"
],
@@ -16024,7 +16024,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.1.3",
"version": "0.1.4",
"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.1.3",
"version": "0.1.4",
"hasInstallScript": true,
"dependencies": {
"@google/genai": "1.16.0",
@@ -16278,7 +16278,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.1.3",
"version": "0.1.4",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -16290,7 +16290,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.1.3",
"version": "0.1.4",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

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

View File

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

View File

@@ -114,6 +114,7 @@ export interface CliArgs {
openaiLogging: boolean | undefined;
openaiApiKey: string | undefined;
openaiBaseUrl: string | undefined;
openaiLoggingDir: string | undefined;
proxy: string | undefined;
includeDirectories: string[] | undefined;
tavilyApiKey: string | undefined;
@@ -317,6 +318,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
description:
'Enable logging of OpenAI API calls for debugging and analysis',
})
.option('openai-logging-dir', {
type: 'string',
description:
'Custom directory path for OpenAI API logs. Overrides settings files.',
})
.option('openai-api-key', {
type: 'string',
description: 'OpenAI API key to use for authentication',
@@ -764,6 +770,8 @@ export async function loadCliConfig(
(typeof argv.openaiLogging === 'undefined'
? settings.model?.enableOpenAILogging
: argv.openaiLogging) ?? false,
openAILoggingDir:
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
},
cliVersion: await getCliVersion(),
webSearch: buildWebSearchConfig(

View File

@@ -558,6 +558,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable OpenAI logging.',
showInDialog: true,
},
openAILoggingDir: {
type: 'string',
label: 'OpenAI Logging Directory',
category: 'Model',
requiresRestart: false,
default: undefined as string | undefined,
description:
'Custom directory path for OpenAI API logs. If not specified, defaults to logs/openai in the current working directory.',
showInDialog: true,
},
generationConfig: {
type: 'object',
label: 'Generation Configuration',

View File

@@ -327,6 +327,7 @@ describe('gemini.tsx main function kitty protocol', () => {
openaiLogging: undefined,
openaiApiKey: undefined,
openaiBaseUrl: undefined,
openaiLoggingDir: undefined,
proxy: undefined,
includeDirectories: undefined,
tavilyApiKey: undefined,

View File

@@ -1227,4 +1227,28 @@ describe('FileCommandLoader', () => {
expect(commands).toHaveLength(0);
});
});
describe('AbortError handling', () => {
it('should silently ignore AbortError when operation is cancelled', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test1.toml': 'prompt = "Prompt 1"',
'test2.toml': 'prompt = "Prompt 2"',
},
});
const loader = new FileCommandLoader(null);
const controller = new AbortController();
const signal = controller.signal;
// Start loading and immediately abort
const loadPromise = loader.loadCommands(signal);
controller.abort();
// Should not throw or print errors
const commands = await loadPromise;
expect(commands).toHaveLength(0);
});
});
});

View File

@@ -120,7 +120,11 @@ export class FileCommandLoader implements ICommandLoader {
// Add all commands without deduplication
allCommands.push(...commands);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
// Ignore ENOENT (directory doesn't exist) and AbortError (operation was cancelled)
const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT';
const isAbortError =
error instanceof Error && error.name === 'AbortError';
if (!isEnoent && !isAbortError) {
console.error(
`[FileCommandLoader] Error loading commands from ${dirInfo.path}:`,
error,

View File

@@ -916,17 +916,9 @@ export const AppContainer = (props: AppContainerProps) => {
(result: IdeIntegrationNudgeResult) => {
if (result.userSelection === 'yes') {
handleSlashCommand('/ide install');
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
} else if (result.userSelection === 'dismiss') {
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
}
setIdePromptAnswered(true);
},

View File

@@ -8,38 +8,22 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { aboutCommand } from './aboutCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import * as versionUtils from '../../utils/version.js';
import { MessageType } from '../types.js';
import { IdeClient } from '@qwen-code/qwen-code-core';
import * as systemInfoUtils from '../../utils/systemInfo.js';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
IdeClient: {
getInstance: vi.fn().mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
}),
},
};
});
vi.mock('../../utils/version.js', () => ({
getCliVersion: vi.fn(),
}));
vi.mock('../../utils/systemInfo.js');
describe('aboutCommand', () => {
let mockContext: CommandContext;
const originalPlatform = process.platform;
const originalEnv = { ...process.env };
beforeEach(() => {
mockContext = createMockCommandContext({
services: {
config: {
getModel: vi.fn(),
getModel: vi.fn().mockReturnValue('test-model'),
getIdeMode: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
},
settings: {
merged: {
@@ -56,21 +40,25 @@ describe('aboutCommand', () => {
},
} as unknown as CommandContext);
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue(
'test-model',
);
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
Object.defineProperty(process, 'platform', {
value: 'test-os',
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
});
});
afterEach(() => {
vi.unstubAllEnvs();
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
process.env = originalEnv;
vi.clearAllMocks();
});
@@ -81,30 +69,55 @@ describe('aboutCommand', () => {
});
it('should call addItem with all version info', async () => {
process.env['SANDBOX'] = '';
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
await aboutCommand.action(mockContext, '');
expect(systemInfoUtils.getExtendedSystemInfo).toHaveBeenCalledWith(
mockContext,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
expect.objectContaining({
type: MessageType.ABOUT,
cliVersion: 'test-version',
osVersion: 'test-os',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
gcpProject: 'test-gcp-project',
ideClient: 'test-ide',
},
systemInfo: expect.objectContaining({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
}),
}),
expect.any(Number),
);
});
it('should show the correct sandbox environment variable', async () => {
process.env['SANDBOX'] = 'gemini-sandbox';
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'gemini-sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
});
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
@@ -113,15 +126,32 @@ describe('aboutCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
sandboxEnv: 'gemini-sandbox',
type: MessageType.ABOUT,
systemInfo: expect.objectContaining({
sandboxEnv: 'gemini-sandbox',
}),
}),
expect.any(Number),
);
});
it('should show sandbox-exec profile when applicable', async () => {
process.env['SANDBOX'] = 'sandbox-exec';
process.env['SEATBELT_PROFILE'] = 'test-profile';
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'sandbox-exec (test-profile)',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
});
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
@@ -130,18 +160,31 @@ describe('aboutCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
sandboxEnv: 'sandbox-exec (test-profile)',
systemInfo: expect.objectContaining({
sandboxEnv: 'sandbox-exec (test-profile)',
}),
}),
expect.any(Number),
);
});
it('should not show ide client when it is not detected', async () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue(undefined),
} as unknown as IdeClient);
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: '',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
});
process.env['SANDBOX'] = '';
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
@@ -151,13 +194,87 @@ describe('aboutCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ABOUT,
cliVersion: 'test-version',
osVersion: 'test-os',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
gcpProject: 'test-gcp-project',
ideClient: '',
systemInfo: expect.objectContaining({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: '',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
}),
}),
expect.any(Number),
);
});
it('should show unknown npmVersion when npm command fails', async () => {
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: 'unknown',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
});
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
await aboutCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
systemInfo: expect.objectContaining({
npmVersion: 'unknown',
}),
}),
expect.any(Number),
);
});
it('should show unknown sessionId when config is not available', async () => {
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'Unknown',
selectedAuthType: 'test-auth',
ideClient: '',
sessionId: 'unknown',
memoryUsage: '100 MB',
baseUrl: undefined,
});
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
await aboutCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
systemInfo: expect.objectContaining({
sessionId: 'unknown',
}),
}),
expect.any(Number),
);

View File

@@ -4,53 +4,23 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { getCliVersion } from '../../utils/version.js';
import type { CommandContext, SlashCommand } from './types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import process from 'node:process';
import { MessageType, type HistoryItemAbout } from '../types.js';
import { IdeClient } from '@qwen-code/qwen-code-core';
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
export const aboutCommand: SlashCommand = {
name: 'about',
description: 'show version info',
kind: CommandKind.BUILT_IN,
action: async (context) => {
const osVersion = process.platform;
let sandboxEnv = 'no sandbox';
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
sandboxEnv = process.env['SANDBOX'];
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
sandboxEnv = `sandbox-exec (${
process.env['SEATBELT_PROFILE'] || 'unknown'
})`;
}
const modelVersion = context.services.config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion();
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
const ideClient = await getIdeClientName(context);
const systemInfo = await getExtendedSystemInfo(context);
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
type: MessageType.ABOUT,
cliVersion,
osVersion,
sandboxEnv,
modelVersion,
selectedAuthType,
gcpProject,
ideClient,
systemInfo,
};
context.ui.addItem(aboutItem, Date.now());
},
};
async function getIdeClientName(context: CommandContext) {
if (!context.services.config?.getIdeMode()) {
return '';
}
const ideClient = await IdeClient.getInstance();
return ideClient?.getDetectedIdeDisplayName() ?? '';
}

View File

@@ -8,41 +8,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import open from 'open';
import { bugCommand } from './bugCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { getCliVersion } from '../../utils/version.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import * as systemInfoUtils from '../../utils/systemInfo.js';
// Mock dependencies
vi.mock('open');
vi.mock('../../utils/version.js');
vi.mock('../utils/formatters.js');
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
IdeClient: {
getInstance: () => ({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'),
}),
},
};
});
vi.mock('node:process', () => ({
default: {
platform: 'test-platform',
version: 'v20.0.0',
// Keep other necessary process properties if needed by other parts of the code
env: process.env,
memoryUsage: () => ({ rss: 0 }),
},
}));
vi.mock('../../utils/systemInfo.js');
describe('bugCommand', () => {
beforeEach(() => {
vi.mocked(getCliVersion).mockResolvedValue('0.1.0');
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: '0.1.0',
osPlatform: 'test-platform',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'test',
modelVersion: 'qwen3-coder-plus',
selectedAuthType: '',
ideClient: 'VSCode',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
gitCommit:
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? GIT_COMMIT_INFO
: undefined,
});
vi.stubEnv('SANDBOX', 'qwen-test');
});
@@ -55,19 +48,7 @@ describe('bugCommand', () => {
const mockContext = createMockCommandContext({
services: {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => undefined,
getIdeMode: () => true,
getSessionId: () => 'test-session-id',
},
settings: {
merged: {
security: {
auth: {
selectedType: undefined,
},
},
},
},
},
});
@@ -75,14 +56,21 @@ describe('bugCommand', () => {
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'A test bug');
const gitCommitLine =
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
: '';
const expectedInfo = `
* **CLI Version:** 0.1.0
* **Git Commit:** ${GIT_COMMIT_INFO}
${gitCommitLine}* **Model:** qwen3-coder-plus
* **Sandbox:** test
* **OS Platform:** test-platform
* **OS Arch:** x64
* **OS Release:** 22.0.0
* **Node.js Version:** v20.0.0
* **NPM Version:** 10.0.0
* **Session ID:** test-session-id
* **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test
* **Auth Type:**
* **Model Version:** qwen3-coder-plus
* **Auth Method:**
* **Memory Usage:** 100 MB
* **IDE Client:** VSCode
`;
@@ -99,19 +87,7 @@ describe('bugCommand', () => {
const mockContext = createMockCommandContext({
services: {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => ({ urlTemplate: customTemplate }),
getIdeMode: () => true,
getSessionId: () => 'test-session-id',
},
settings: {
merged: {
security: {
auth: {
selectedType: undefined,
},
},
},
},
},
});
@@ -119,14 +95,21 @@ describe('bugCommand', () => {
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'A custom bug');
const gitCommitLine =
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
: '';
const expectedInfo = `
* **CLI Version:** 0.1.0
* **Git Commit:** ${GIT_COMMIT_INFO}
${gitCommitLine}* **Model:** qwen3-coder-plus
* **Sandbox:** test
* **OS Platform:** test-platform
* **OS Arch:** x64
* **OS Release:** 22.0.0
* **Node.js Version:** v20.0.0
* **NPM Version:** 10.0.0
* **Session ID:** test-session-id
* **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test
* **Auth Type:**
* **Model Version:** qwen3-coder-plus
* **Auth Method:**
* **Memory Usage:** 100 MB
* **IDE Client:** VSCode
`;
@@ -138,25 +121,30 @@ describe('bugCommand', () => {
});
it('should include Base URL when auth type is OpenAI', async () => {
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: '0.1.0',
osPlatform: 'test-platform',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'test',
modelVersion: 'qwen3-coder-plus',
selectedAuthType: AuthType.USE_OPENAI,
ideClient: 'VSCode',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: 'https://api.openai.com/v1',
gitCommit:
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? GIT_COMMIT_INFO
: undefined,
});
const mockContext = createMockCommandContext({
services: {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => undefined,
getIdeMode: () => true,
getSessionId: () => 'test-session-id',
getContentGeneratorConfig: () => ({
baseUrl: 'https://api.openai.com/v1',
}),
},
settings: {
merged: {
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
},
},
},
});
@@ -164,15 +152,22 @@ describe('bugCommand', () => {
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'OpenAI bug');
const gitCommitLine =
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
: '';
const expectedInfo = `
* **CLI Version:** 0.1.0
* **Git Commit:** ${GIT_COMMIT_INFO}
${gitCommitLine}* **Model:** qwen3-coder-plus
* **Sandbox:** test
* **OS Platform:** test-platform
* **OS Arch:** x64
* **OS Release:** 22.0.0
* **Node.js Version:** v20.0.0
* **NPM Version:** 10.0.0
* **Session ID:** test-session-id
* **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test
* **Auth Type:** ${AuthType.USE_OPENAI}
* **Auth Method:** ${AuthType.USE_OPENAI}
* **Base URL:** https://api.openai.com/v1
* **Model Version:** qwen3-coder-plus
* **Memory Usage:** 100 MB
* **IDE Client:** VSCode
`;

View File

@@ -5,17 +5,17 @@
*/
import open from 'open';
import process from 'node:process';
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import { MessageType } from '../types.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import { getCliVersion } from '../../utils/version.js';
import { IdeClient, AuthType } from '@qwen-code/qwen-code-core';
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
import {
getSystemInfoFields,
getFieldValue,
} from '../../utils/systemInfoFields.js';
export const bugCommand: SlashCommand = {
name: 'bug',
@@ -23,50 +23,20 @@ export const bugCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string): Promise<void> => {
const bugDescription = (args || '').trim();
const { config } = context.services;
const systemInfo = await getExtendedSystemInfo(context);
const osVersion = `${process.platform} ${process.version}`;
let sandboxEnv = 'no sandbox';
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
sandboxEnv = process.env['SANDBOX'].replace(/^qwen-(?:code-)?/, '');
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
sandboxEnv = `sandbox-exec (${
process.env['SEATBELT_PROFILE'] || 'unknown'
})`;
}
const modelVersion = config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion();
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
const ideClient = await getIdeClientName(context);
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
const baseUrl =
selectedAuthType === AuthType.USE_OPENAI
? config?.getContentGeneratorConfig()?.baseUrl
: undefined;
const fields = getSystemInfoFields(systemInfo);
let info = `
* **CLI Version:** ${cliVersion}
* **Git Commit:** ${GIT_COMMIT_INFO}
* **Session ID:** ${config?.getSessionId() || 'unknown'}
* **Operating System:** ${osVersion}
* **Sandbox Environment:** ${sandboxEnv}
* **Auth Type:** ${selectedAuthType}`;
if (baseUrl) {
info += `\n* **Base URL:** ${baseUrl}`;
}
info += `
* **Model Version:** ${modelVersion}
* **Memory Usage:** ${memoryUsage}
`;
if (ideClient) {
info += `* **IDE Client:** ${ideClient}\n`;
// Generate bug report info using the same field configuration
let info = '\n';
for (const field of fields) {
info += `* **${field.label}:** ${getFieldValue(field, systemInfo)}\n`;
}
let bugReportUrl =
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}';
const bugCommandSettings = config?.getBugCommand();
const bugCommandSettings = context.services.config?.getBugCommand();
if (bugCommandSettings?.urlTemplate) {
bugReportUrl = bugCommandSettings.urlTemplate;
}
@@ -98,11 +68,3 @@ export const bugCommand: SlashCommand = {
}
},
};
async function getIdeClientName(context: CommandContext) {
if (!context.services.config?.getIdeMode()) {
return '';
}
const ideClient = await IdeClient.getInstance();
return ideClient.getDetectedIdeDisplayName() ?? '';
}

View File

@@ -7,127 +7,46 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import type { ExtendedSystemInfo } from '../../utils/systemInfo.js';
import {
getSystemInfoFields,
getFieldValue,
type SystemInfoField,
} from '../../utils/systemInfoFields.js';
interface AboutBoxProps {
cliVersion: string;
osVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
gcpProject: string;
ideClient: string;
}
type AboutBoxProps = ExtendedSystemInfo;
export const AboutBox: React.FC<AboutBoxProps> = ({
cliVersion,
osVersion,
sandboxEnv,
modelVersion,
selectedAuthType,
gcpProject,
ideClient,
}) => (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
marginY={1}
width="100%"
>
<Box marginBottom={1}>
<Text bold color={theme.text.accent}>
About Qwen Code
</Text>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
CLI Version
export const AboutBox: React.FC<AboutBoxProps> = (props) => {
const fields = getSystemInfoFields(props);
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
marginY={1}
width="100%"
>
<Box marginBottom={1}>
<Text bold color={theme.text.accent}>
About Qwen Code
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{cliVersion}</Text>
</Box>
{fields.map((field: SystemInfoField) => (
<Box key={field.key} flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
{field.label}
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>
{getFieldValue(field, props)}
</Text>
</Box>
</Box>
))}
</Box>
{GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
Git Commit
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{GIT_COMMIT_INFO}</Text>
</Box>
</Box>
)}
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
Model
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{modelVersion}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
Sandbox
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{sandboxEnv}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
OS
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{osVersion}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
Auth Method
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>
{selectedAuthType.startsWith('oauth') ? 'OAuth' : selectedAuthType}
</Text>
</Box>
</Box>
{gcpProject && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
GCP Project
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{gcpProject}</Text>
</Box>
</Box>
)}
{ideClient && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
IDE Client
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{ideClient}</Text>
</Box>
</Box>
)}
</Box>
);
);
};

View File

@@ -71,15 +71,24 @@ describe('<HistoryItemDisplay />', () => {
it('renders AboutBox for "about" type', () => {
const item: HistoryItem = {
...baseItem,
id: 1,
type: MessageType.ABOUT,
cliVersion: '1.0.0',
osVersion: 'test-os',
sandboxEnv: 'test-env',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
gcpProject: 'test-project',
ideClient: 'test-ide',
systemInfo: {
cliVersion: '1.0.0',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'test-env',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
gitCommit: undefined,
},
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,

View File

@@ -95,15 +95,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
<ErrorMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'about' && (
<AboutBox
cliVersion={itemForDisplay.cliVersion}
osVersion={itemForDisplay.osVersion}
sandboxEnv={itemForDisplay.sandboxEnv}
modelVersion={itemForDisplay.modelVersion}
selectedAuthType={itemForDisplay.selectedAuthType}
gcpProject={itemForDisplay.gcpProject}
ideClient={itemForDisplay.ideClient}
/>
<AboutBox {...itemForDisplay.systemInfo} />
)}
{itemForDisplay.type === 'help' && commands && (
<Help commands={commands} />

View File

@@ -138,13 +138,7 @@ export const useSlashCommandProcessor = (
if (message.type === MessageType.ABOUT) {
historyItemContent = {
type: 'about',
cliVersion: message.cliVersion,
osVersion: message.osVersion,
sandboxEnv: message.sandboxEnv,
modelVersion: message.modelVersion,
selectedAuthType: message.selectedAuthType,
gcpProject: message.gcpProject,
ideClient: message.ideClient,
systemInfo: message.systemInfo,
};
} else if (message.type === MessageType.HELP) {
historyItemContent = {

View File

@@ -120,13 +120,22 @@ export type HistoryItemWarning = HistoryItemBase & {
export type HistoryItemAbout = HistoryItemBase & {
type: 'about';
cliVersion: string;
osVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
gcpProject: string;
ideClient: string;
systemInfo: {
cliVersion: string;
osPlatform: string;
osArch: string;
osRelease: string;
nodeVersion: string;
npmVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
ideClient: string;
sessionId: string;
memoryUsage: string;
baseUrl?: string;
gitCommit?: string;
};
};
export type HistoryItemHelp = HistoryItemBase & {
@@ -288,13 +297,22 @@ export type Message =
| {
type: MessageType.ABOUT;
timestamp: Date;
cliVersion: string;
osVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
gcpProject: string;
ideClient: string;
systemInfo: {
cliVersion: string;
osPlatform: string;
osArch: string;
osRelease: string;
nodeVersion: string;
npmVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
ideClient: string;
sessionId: string;
memoryUsage: string;
baseUrl?: string;
gitCommit?: string;
};
content?: string; // Optional content, not really used for ABOUT
}
| {

View File

@@ -0,0 +1,331 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import {
getSystemInfo,
getExtendedSystemInfo,
getNpmVersion,
getSandboxEnv,
getIdeClientName,
} from './systemInfo.js';
import type { CommandContext } from '../ui/commands/types.js';
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
import * as child_process from 'node:child_process';
import os from 'node:os';
import { IdeClient } from '@qwen-code/qwen-code-core';
import * as versionUtils from './version.js';
import type { ExecSyncOptions } from 'node:child_process';
vi.mock('node:child_process');
vi.mock('node:os', () => ({
default: {
release: vi.fn(),
},
}));
vi.mock('./version.js', () => ({
getCliVersion: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
IdeClient: {
getInstance: vi.fn(),
},
};
});
describe('systemInfo', () => {
let mockContext: CommandContext;
const originalPlatform = process.platform;
const originalArch = process.arch;
const originalVersion = process.version;
const originalEnv = { ...process.env };
beforeEach(() => {
mockContext = createMockCommandContext({
services: {
config: {
getModel: vi.fn().mockReturnValue('test-model'),
getIdeMode: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getContentGeneratorConfig: vi.fn().mockReturnValue({
baseUrl: 'https://api.openai.com',
}),
},
settings: {
merged: {
security: {
auth: {
selectedType: 'test-auth',
},
},
},
},
},
} as unknown as CommandContext);
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
vi.mocked(os.release).mockReturnValue('22.0.0');
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
Object.defineProperty(process, 'platform', {
value: 'test-os',
});
Object.defineProperty(process, 'arch', {
value: 'x64',
});
Object.defineProperty(process, 'version', {
value: 'v20.0.0',
});
});
afterEach(() => {
vi.unstubAllEnvs();
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
Object.defineProperty(process, 'arch', {
value: originalArch,
});
Object.defineProperty(process, 'version', {
value: originalVersion,
});
process.env = originalEnv;
vi.clearAllMocks();
vi.resetAllMocks();
});
describe('getNpmVersion', () => {
it('should return npm version when available', async () => {
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
const version = await getNpmVersion();
expect(version).toBe('10.0.0');
});
it('should return unknown when npm command fails', async () => {
vi.mocked(child_process.execSync).mockImplementation(() => {
throw new Error('npm not found');
});
const version = await getNpmVersion();
expect(version).toBe('unknown');
});
});
describe('getSandboxEnv', () => {
it('should return "no sandbox" when SANDBOX is not set', () => {
delete process.env['SANDBOX'];
expect(getSandboxEnv()).toBe('no sandbox');
});
it('should return sandbox-exec info when SANDBOX is sandbox-exec', () => {
process.env['SANDBOX'] = 'sandbox-exec';
process.env['SEATBELT_PROFILE'] = 'test-profile';
expect(getSandboxEnv()).toBe('sandbox-exec (test-profile)');
});
it('should return sandbox name without prefix when stripPrefix is true', () => {
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
expect(getSandboxEnv(true)).toBe('test-sandbox');
});
it('should return sandbox name with prefix when stripPrefix is false', () => {
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
expect(getSandboxEnv(false)).toBe('qwen-code-test-sandbox');
});
it('should handle qwen- prefix removal', () => {
process.env['SANDBOX'] = 'qwen-custom-sandbox';
expect(getSandboxEnv(true)).toBe('custom-sandbox');
});
});
describe('getIdeClientName', () => {
it('should return IDE client name when IDE mode is enabled', async () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
} as unknown as IdeClient);
const ideClient = await getIdeClientName(mockContext);
expect(ideClient).toBe('test-ide');
});
it('should return empty string when IDE mode is disabled', async () => {
vi.mocked(mockContext.services.config!.getIdeMode).mockReturnValue(false);
const ideClient = await getIdeClientName(mockContext);
expect(ideClient).toBe('');
});
it('should return empty string when IDE client detection fails', async () => {
vi.mocked(IdeClient.getInstance).mockRejectedValue(
new Error('IDE client error'),
);
const ideClient = await getIdeClientName(mockContext);
expect(ideClient).toBe('');
});
});
describe('getSystemInfo', () => {
it('should collect all system information', async () => {
// Ensure SANDBOX is not set for this test
delete process.env['SANDBOX'];
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
} as unknown as IdeClient);
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
const systemInfo = await getSystemInfo(mockContext);
expect(systemInfo).toEqual({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
});
});
it('should handle missing config gracefully', async () => {
mockContext.services.config = null;
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
} as unknown as IdeClient);
const systemInfo = await getSystemInfo(mockContext);
expect(systemInfo.modelVersion).toBe('Unknown');
expect(systemInfo.sessionId).toBe('unknown');
});
});
describe('getExtendedSystemInfo', () => {
it('should include memory usage and base URL', async () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
} as unknown as IdeClient);
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
const { AuthType } = await import('@qwen-code/qwen-code-core');
// Update the mock context to use OpenAI auth
mockContext.services.settings.merged.security!.auth!.selectedType =
AuthType.USE_OPENAI;
const extendedInfo = await getExtendedSystemInfo(mockContext);
expect(extendedInfo.memoryUsage).toBeDefined();
expect(extendedInfo.memoryUsage).toMatch(/\d+\.\d+ (KB|MB|GB)/);
expect(extendedInfo.baseUrl).toBe('https://api.openai.com');
});
it('should use sandbox env without prefix for bug reports', async () => {
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
} as unknown as IdeClient);
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
const extendedInfo = await getExtendedSystemInfo(mockContext);
expect(extendedInfo.sandboxEnv).toBe('test-sandbox');
});
it('should not include base URL for non-OpenAI auth', async () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
} as unknown as IdeClient);
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
const extendedInfo = await getExtendedSystemInfo(mockContext);
expect(extendedInfo.baseUrl).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,173 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
import os from 'node:os';
import { execSync } from 'node:child_process';
import type { CommandContext } from '../ui/commands/types.js';
import { getCliVersion } from './version.js';
import { IdeClient, AuthType } from '@qwen-code/qwen-code-core';
import { formatMemoryUsage } from '../ui/utils/formatters.js';
import { GIT_COMMIT_INFO } from '../generated/git-commit.js';
/**
* System information interface containing all system-related details
* that can be collected for debugging and reporting purposes.
*/
export interface SystemInfo {
cliVersion: string;
osPlatform: string;
osArch: string;
osRelease: string;
nodeVersion: string;
npmVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
ideClient: string;
sessionId: string;
}
/**
* Additional system information for bug reports
*/
export interface ExtendedSystemInfo extends SystemInfo {
memoryUsage: string;
baseUrl?: string;
gitCommit?: string;
}
/**
* Gets the NPM version, handling cases where npm might not be available.
* Returns 'unknown' if npm command fails or is not found.
*/
export async function getNpmVersion(): Promise<string> {
try {
return execSync('npm --version', { encoding: 'utf-8' }).trim();
} catch {
return 'unknown';
}
}
/**
* Gets the IDE client name if IDE mode is enabled.
* Returns empty string if IDE mode is disabled or IDE client is not detected.
*/
export async function getIdeClientName(
context: CommandContext,
): Promise<string> {
if (!context.services.config?.getIdeMode()) {
return '';
}
try {
const ideClient = await IdeClient.getInstance();
return ideClient?.getDetectedIdeDisplayName() ?? '';
} catch {
return '';
}
}
/**
* Gets the sandbox environment information.
* Handles different sandbox types including sandbox-exec and custom sandbox environments.
* For bug reports, removes 'qwen-' or 'qwen-code-' prefixes from sandbox names.
*
* @param stripPrefix - Whether to strip 'qwen-' prefix (used for bug reports)
*/
export function getSandboxEnv(stripPrefix = false): string {
const sandbox = process.env['SANDBOX'];
if (!sandbox || sandbox === 'sandbox-exec') {
if (sandbox === 'sandbox-exec') {
const profile = process.env['SEATBELT_PROFILE'] || 'unknown';
return `sandbox-exec (${profile})`;
}
return 'no sandbox';
}
// For bug reports, remove qwen- prefix
if (stripPrefix) {
return sandbox.replace(/^qwen-(?:code-)?/, '');
}
return sandbox;
}
/**
* Collects comprehensive system information for debugging and reporting.
* This function gathers all system-related details including OS, versions,
* sandbox environment, authentication, and session information.
*
* @param context - Command context containing config and settings
* @returns Promise resolving to SystemInfo object with all collected information
*/
export async function getSystemInfo(
context: CommandContext,
): Promise<SystemInfo> {
const osPlatform = process.platform;
const osArch = process.arch;
const osRelease = os.release();
const nodeVersion = process.version;
const npmVersion = await getNpmVersion();
const sandboxEnv = getSandboxEnv();
const modelVersion = context.services.config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion();
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
const ideClient = await getIdeClientName(context);
const sessionId = context.services.config?.getSessionId() || 'unknown';
return {
cliVersion,
osPlatform,
osArch,
osRelease,
nodeVersion,
npmVersion,
sandboxEnv,
modelVersion,
selectedAuthType,
ideClient,
sessionId,
};
}
/**
* Collects extended system information for bug reports.
* Includes all standard system info plus memory usage and optional base URL.
*
* @param context - Command context containing config and settings
* @returns Promise resolving to ExtendedSystemInfo object
*/
export async function getExtendedSystemInfo(
context: CommandContext,
): Promise<ExtendedSystemInfo> {
const baseInfo = await getSystemInfo(context);
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
// For bug reports, use sandbox name without prefix
const sandboxEnv = getSandboxEnv(true);
// Get base URL if using OpenAI auth
const baseUrl =
baseInfo.selectedAuthType === AuthType.USE_OPENAI
? context.services.config?.getContentGeneratorConfig()?.baseUrl
: undefined;
// Get git commit info
const gitCommit =
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? GIT_COMMIT_INFO
: undefined;
return {
...baseInfo,
sandboxEnv,
memoryUsage,
baseUrl,
gitCommit,
};
}

View File

@@ -0,0 +1,117 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { ExtendedSystemInfo } from './systemInfo.js';
/**
* Field configuration for system information display
*/
export interface SystemInfoField {
label: string;
key: keyof ExtendedSystemInfo;
}
/**
* Unified field configuration for system information display.
* This ensures consistent labeling between /about and /bug commands.
*/
export function getSystemInfoFields(
info: ExtendedSystemInfo,
): SystemInfoField[] {
const allFields: SystemInfoField[] = [
{
label: 'CLI Version',
key: 'cliVersion',
},
{
label: 'Git Commit',
key: 'gitCommit',
},
{
label: 'Model',
key: 'modelVersion',
},
{
label: 'Sandbox',
key: 'sandboxEnv',
},
{
label: 'OS Platform',
key: 'osPlatform',
},
{
label: 'OS Arch',
key: 'osArch',
},
{
label: 'OS Release',
key: 'osRelease',
},
{
label: 'Node.js Version',
key: 'nodeVersion',
},
{
label: 'NPM Version',
key: 'npmVersion',
},
{
label: 'Session ID',
key: 'sessionId',
},
{
label: 'Auth Method',
key: 'selectedAuthType',
},
{
label: 'Base URL',
key: 'baseUrl',
},
{
label: 'Memory Usage',
key: 'memoryUsage',
},
{
label: 'IDE Client',
key: 'ideClient',
},
];
// Filter out optional fields that are not present
return allFields.filter((field) => {
const value = info[field.key];
// Optional fields: only show if they exist and are non-empty
if (
field.key === 'baseUrl' ||
field.key === 'gitCommit' ||
field.key === 'ideClient'
) {
return Boolean(value);
}
return true;
});
}
/**
* Get the value for a field from system info
*/
export function getFieldValue(
field: SystemInfoField,
info: ExtendedSystemInfo,
): string {
const value = info[field.key];
if (value === undefined || value === null) {
return '';
}
// Special formatting for selectedAuthType
if (field.key === 'selectedAuthType') {
return String(value).startsWith('oauth') ? 'OAuth' : String(value);
}
return String(value);
}

View File

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

View File

@@ -21,6 +21,9 @@ vi.mock('../../telemetry/loggers.js', () => ({
}));
vi.mock('../../utils/openaiLogger.js', () => ({
OpenAILogger: vi.fn().mockImplementation(() => ({
logInteraction: vi.fn(),
})),
openaiLogger: {
logInteraction: vi.fn(),
},

View File

@@ -58,6 +58,7 @@ export type ContentGeneratorConfig = {
vertexai?: boolean;
authType?: AuthType | undefined;
enableOpenAILogging?: boolean;
openAILoggingDir?: string;
// Timeout configuration in milliseconds
timeout?: number;
// Maximum retries for failed requests

View File

@@ -32,6 +32,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
telemetryService: new DefaultTelemetryService(
cliConfig,
contentGeneratorConfig.enableOpenAILogging,
contentGeneratorConfig.openAILoggingDir,
),
errorHandler: new EnhancedErrorHandler(
(error: unknown, request: GenerateContentParameters) =>

View File

@@ -7,7 +7,7 @@
import type { Config } from '../../config/config.js';
import { logApiError, logApiResponse } from '../../telemetry/loggers.js';
import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.js';
import { openaiLogger } from '../../utils/openaiLogger.js';
import { OpenAILogger } from '../../utils/openaiLogger.js';
import type { GenerateContentResponse } from '@google/genai';
import type OpenAI from 'openai';
@@ -43,10 +43,17 @@ export interface TelemetryService {
}
export class DefaultTelemetryService implements TelemetryService {
private logger: OpenAILogger;
constructor(
private config: Config,
private enableOpenAILogging: boolean = false,
) {}
openAILoggingDir?: string,
) {
// Always create a new logger instance to ensure correct working directory
// If no custom directory is provided, undefined will use the default path
this.logger = new OpenAILogger(openAILoggingDir);
}
async logSuccess(
context: RequestContext,
@@ -68,7 +75,7 @@ export class DefaultTelemetryService implements TelemetryService {
// Log interaction if enabled
if (this.enableOpenAILogging && openaiRequest && openaiResponse) {
await openaiLogger.logInteraction(openaiRequest, openaiResponse);
await this.logger.logInteraction(openaiRequest, openaiResponse);
}
}
@@ -97,7 +104,7 @@ export class DefaultTelemetryService implements TelemetryService {
// Log error interaction if enabled
if (this.enableOpenAILogging && openaiRequest) {
await openaiLogger.logInteraction(
await this.logger.logInteraction(
openaiRequest,
undefined,
error as Error,
@@ -137,7 +144,7 @@ export class DefaultTelemetryService implements TelemetryService {
openaiChunks.length > 0
) {
const combinedResponse = this.combineOpenAIChunksForLogging(openaiChunks);
await openaiLogger.logInteraction(openaiRequest, combinedResponse);
await this.logger.logInteraction(openaiRequest, combinedResponse);
}
}

View File

@@ -64,6 +64,12 @@ describe('normalize', () => {
expect(normalize('qwen-vl-max-latest')).toBe('qwen-vl-max-latest');
});
it('should preserve date suffixes for Kimi K2 models', () => {
expect(normalize('kimi-k2-0905-preview')).toBe('kimi-k2-0905');
expect(normalize('kimi-k2-0711-preview')).toBe('kimi-k2-0711');
expect(normalize('kimi-k2-turbo-preview')).toBe('kimi-k2-turbo');
});
it('should remove date like suffixes', () => {
expect(normalize('deepseek-r1-0528')).toBe('deepseek-r1');
});
@@ -213,7 +219,7 @@ describe('tokenLimit', () => {
});
});
describe('Other models', () => {
describe('DeepSeek', () => {
it('should return the correct limit for deepseek-r1', () => {
expect(tokenLimit('deepseek-r1')).toBe(131072);
});
@@ -226,9 +232,27 @@ describe('tokenLimit', () => {
it('should return the correct limit for deepseek-v3.2', () => {
expect(tokenLimit('deepseek-v3.2-exp')).toBe(131072);
});
it('should return the correct limit for kimi-k2-instruct', () => {
expect(tokenLimit('kimi-k2-instruct')).toBe(131072);
});
describe('Moonshot Kimi', () => {
it('should return the correct limit for kimi-k2-0905-preview', () => {
expect(tokenLimit('kimi-k2-0905-preview')).toBe(262144); // 256K
expect(tokenLimit('kimi-k2-0905')).toBe(262144);
});
it('should return the correct limit for kimi-k2-turbo-preview', () => {
expect(tokenLimit('kimi-k2-turbo-preview')).toBe(262144); // 256K
expect(tokenLimit('kimi-k2-turbo')).toBe(262144);
});
it('should return the correct limit for kimi-k2-0711-preview', () => {
expect(tokenLimit('kimi-k2-0711-preview')).toBe(131072); // 128K
expect(tokenLimit('kimi-k2-0711')).toBe(131072);
});
it('should return the correct limit for kimi-k2-instruct', () => {
expect(tokenLimit('kimi-k2-instruct')).toBe(131072); // 128K
});
});
describe('Other models', () => {
it('should return the correct limit for gpt-oss', () => {
expect(tokenLimit('gpt-oss')).toBe(131072);
});

View File

@@ -47,8 +47,13 @@ export function normalize(model: string): string {
// remove trailing build / date / revision suffixes:
// - dates (e.g., -20250219), -v1, version numbers, 'latest', 'preview' etc.
s = s.replace(/-preview/g, '');
// Special handling for Qwen model names that include "-latest" as part of the model name
if (!s.match(/^qwen-(?:plus|flash|vl-max)-latest$/)) {
// Special handling for model names that include date/version as part of the model identifier
// - Qwen models: qwen-plus-latest, qwen-flash-latest, qwen-vl-max-latest
// - Kimi models: kimi-k2-0905, kimi-k2-0711, etc. (keep date for version distinction)
if (
!s.match(/^qwen-(?:plus|flash|vl-max)-latest$/) &&
!s.match(/^kimi-k2-\d{4}$/)
) {
// Regex breakdown:
// -(?:...)$ - Non-capturing group for suffixes at the end of the string
// The following patterns are matched within the group:
@@ -165,9 +170,16 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [
[/^deepseek-v3(?:\.\d+)?(?:-.*)?$/, LIMITS['128k']],
// -------------------
// GPT-OSS / Kimi / Llama & Mistral examples
// Moonshot / Kimi
// -------------------
[/^kimi-k2-0905$/, LIMITS['256k']], // Kimi-k2-0905-preview: 256K context
[/^kimi-k2-turbo.*$/, LIMITS['256k']], // Kimi-k2-turbo-preview: 256K context
[/^kimi-k2-0711$/, LIMITS['128k']], // Kimi-k2-0711-preview: 128K context
[/^kimi-k2-instruct.*$/, LIMITS['128k']], // Kimi-k2-instruct: 128K context
// -------------------
// GPT-OSS / Llama & Mistral examples
// -------------------
[/^kimi-k2-instruct.*$/, LIMITS['128k']],
[/^gpt-oss.*$/, LIMITS['128k']],
[/^llama-4-scout.*$/, LIMITS['10m']],
[/^mistral-large-2.*$/, LIMITS['128k']],

View File

@@ -113,7 +113,7 @@ describe('IdeClient', () => {
'utf8',
);
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://localhost:8080/mcp'),
new URL('http://127.0.0.1:8080/mcp'),
expect.any(Object),
);
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
@@ -181,7 +181,7 @@ describe('IdeClient', () => {
await ideClient.connect();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://localhost:9090/mcp'),
new URL('http://127.0.0.1:9090/mcp'),
expect.any(Object),
);
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
@@ -230,7 +230,7 @@ describe('IdeClient', () => {
await ideClient.connect();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://localhost:8080/mcp'),
new URL('http://127.0.0.1:8080/mcp'),
expect.any(Object),
);
expect(ideClient.getConnectionStatus().status).toBe(
@@ -665,7 +665,7 @@ describe('IdeClient', () => {
await ideClient.connect();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://localhost:8080/mcp'),
new URL('http://127.0.0.1:8080/mcp'),
expect.objectContaining({
requestInit: {
headers: {

View File

@@ -667,10 +667,10 @@ export class IdeClient {
}
private createProxyAwareFetch() {
// ignore proxy for 'localhost' by deafult to allow connecting to the ide mcp server
// ignore proxy for '127.0.0.1' by deafult to allow connecting to the ide mcp server
const existingNoProxy = process.env['NO_PROXY'] || '';
const agent = new EnvHttpProxyAgent({
noProxy: [existingNoProxy, 'localhost'].filter(Boolean).join(','),
noProxy: [existingNoProxy, '127.0.0.1'].filter(Boolean).join(','),
});
const undiciPromise = import('undici');
return async (url: string | URL, init?: RequestInit): Promise<Response> => {
@@ -851,5 +851,5 @@ export class IdeClient {
function getIdeServerHost() {
const isInContainer =
fs.existsSync('/.dockerenv') || fs.existsSync('/run/.containerenv');
return isInContainer ? 'host.docker.internal' : 'localhost';
return isInContainer ? 'host.docker.internal' : '127.0.0.1';
}

View File

@@ -112,14 +112,19 @@ describe('ide-installer', () => {
platform: 'linux',
});
await installer.install();
// Note: The implementation uses process.platform, not the mocked platform
const isActuallyWindows = process.platform === 'win32';
const expectedCommand = isActuallyWindows ? '"code"' : 'code';
expect(child_process.spawnSync).toHaveBeenCalledWith(
'code',
expectedCommand,
[
'--install-extension',
'qwenlm.qwen-code-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe' },
{ stdio: 'pipe', shell: isActuallyWindows },
);
});

View File

@@ -117,15 +117,16 @@ class VsCodeInstaller implements IdeInstaller {
};
}
const isWindows = process.platform === 'win32';
try {
const result = child_process.spawnSync(
commandPath,
isWindows ? `"${commandPath}"` : commandPath,
[
'--install-extension',
'qwenlm.qwen-code-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe' },
{ stdio: 'pipe', shell: isWindows },
);
if (result.status !== 0) {

View File

@@ -0,0 +1,381 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as path from 'node:path';
import * as os from 'os';
import { promises as fs } from 'node:fs';
import { OpenAILogger } from './openaiLogger.js';
describe('OpenAILogger', () => {
let originalCwd: string;
let testTempDir: string;
const createdDirs: string[] = [];
beforeEach(() => {
originalCwd = process.cwd();
testTempDir = path.join(os.tmpdir(), `openai-logger-test-${Date.now()}`);
createdDirs.length = 0; // Clear array
});
afterEach(async () => {
// Clean up all created directories
const cleanupPromises = [
testTempDir,
...createdDirs,
path.resolve(process.cwd(), 'relative-logs'),
path.resolve(process.cwd(), 'custom-logs'),
path.resolve(process.cwd(), 'test-relative-logs'),
path.join(os.homedir(), 'custom-logs'),
path.join(os.homedir(), 'test-openai-logs'),
].map(async (dir) => {
try {
await fs.rm(dir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
await Promise.all(cleanupPromises);
process.chdir(originalCwd);
});
describe('constructor', () => {
it('should use default directory when no custom directory is provided', () => {
const logger = new OpenAILogger();
// We can't directly access private logDir, but we can verify behavior
expect(logger).toBeInstanceOf(OpenAILogger);
});
it('should accept absolute path as custom directory', () => {
const customDir = '/absolute/path/to/logs';
const logger = new OpenAILogger(customDir);
expect(logger).toBeInstanceOf(OpenAILogger);
});
it('should resolve relative path to absolute path', async () => {
const relativeDir = 'custom-logs';
const logger = new OpenAILogger(relativeDir);
const expectedDir = path.resolve(process.cwd(), relativeDir);
createdDirs.push(expectedDir);
expect(logger).toBeInstanceOf(OpenAILogger);
});
it('should expand ~ to home directory', () => {
const customDir = '~/custom-logs';
const logger = new OpenAILogger(customDir);
expect(logger).toBeInstanceOf(OpenAILogger);
});
it('should expand ~/ to home directory', () => {
const customDir = '~/custom-logs';
const logger = new OpenAILogger(customDir);
expect(logger).toBeInstanceOf(OpenAILogger);
});
it('should handle just ~ as home directory', () => {
const customDir = '~';
const logger = new OpenAILogger(customDir);
expect(logger).toBeInstanceOf(OpenAILogger);
});
});
describe('initialize', () => {
it('should create directory if it does not exist', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
const dirExists = await fs
.access(testTempDir)
.then(() => true)
.catch(() => false);
expect(dirExists).toBe(true);
});
it('should create nested directories recursively', async () => {
const nestedDir = path.join(testTempDir, 'nested', 'deep', 'path');
const logger = new OpenAILogger(nestedDir);
await logger.initialize();
const dirExists = await fs
.access(nestedDir)
.then(() => true)
.catch(() => false);
expect(dirExists).toBe(true);
});
it('should not throw if directory already exists', async () => {
await fs.mkdir(testTempDir, { recursive: true });
const logger = new OpenAILogger(testTempDir);
await expect(logger.initialize()).resolves.not.toThrow();
});
});
describe('logInteraction', () => {
it('should create log file with correct format', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const response = { id: 'test-id', choices: [] };
const logPath = await logger.logInteraction(request, response);
expect(logPath).toContain(testTempDir);
expect(logPath).toMatch(
/openai-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z-[a-f0-9]{8}\.json/,
);
const fileExists = await fs
.access(logPath)
.then(() => true)
.catch(() => false);
expect(fileExists).toBe(true);
});
it('should write correct log data structure', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const response = { id: 'test-id', choices: [] };
const logPath = await logger.logInteraction(request, response);
const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8'));
expect(logContent).toHaveProperty('timestamp');
expect(logContent).toHaveProperty('request', request);
expect(logContent).toHaveProperty('response', response);
expect(logContent).toHaveProperty('error', null);
expect(logContent).toHaveProperty('system');
expect(logContent.system).toHaveProperty('hostname');
expect(logContent.system).toHaveProperty('platform');
expect(logContent.system).toHaveProperty('release');
expect(logContent.system).toHaveProperty('nodeVersion');
});
it('should log error when provided', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const error = new Error('Test error');
const logPath = await logger.logInteraction(request, undefined, error);
const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8'));
expect(logContent).toHaveProperty('error');
expect(logContent.error).toHaveProperty('message', 'Test error');
expect(logContent.error).toHaveProperty('stack');
expect(logContent.response).toBeNull();
});
it('should use custom directory when provided', async () => {
const customDir = path.join(testTempDir, 'custom-logs');
const logger = new OpenAILogger(customDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const response = { id: 'test-id', choices: [] };
const logPath = await logger.logInteraction(request, response);
expect(logPath).toContain(customDir);
expect(logPath.startsWith(customDir)).toBe(true);
});
it('should resolve relative path correctly', async () => {
const relativeDir = 'relative-logs';
const logger = new OpenAILogger(relativeDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const response = { id: 'test-id', choices: [] };
const logPath = await logger.logInteraction(request, response);
const expectedDir = path.resolve(process.cwd(), relativeDir);
createdDirs.push(expectedDir);
expect(logPath).toContain(expectedDir);
});
it('should expand ~ correctly', async () => {
const customDir = '~/test-openai-logs';
const logger = new OpenAILogger(customDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const response = { id: 'test-id', choices: [] };
const logPath = await logger.logInteraction(request, response);
const expectedDir = path.join(os.homedir(), 'test-openai-logs');
createdDirs.push(expectedDir);
expect(logPath).toContain(expectedDir);
});
});
describe('getLogFiles', () => {
it('should return empty array when directory does not exist', async () => {
const logger = new OpenAILogger(testTempDir);
const files = await logger.getLogFiles();
expect(files).toEqual([]);
});
it('should return log files after initialization', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const response = { id: 'test-id', choices: [] };
await logger.logInteraction(request, response);
const files = await logger.getLogFiles();
expect(files.length).toBeGreaterThan(0);
expect(files[0]).toMatch(/openai-.*\.json$/);
});
it('should return only log files matching pattern', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
// Create a log file
await logger.logInteraction({ test: 'request' }, { test: 'response' });
// Create a non-log file
await fs.writeFile(path.join(testTempDir, 'other-file.txt'), 'content');
const files = await logger.getLogFiles();
expect(files.length).toBe(1);
expect(files[0]).toMatch(/openai-.*\.json$/);
});
it('should respect limit parameter', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
// Create multiple log files
for (let i = 0; i < 5; i++) {
await logger.logInteraction(
{ test: `request-${i}` },
{ test: `response-${i}` },
);
// Small delay to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
}
const allFiles = await logger.getLogFiles();
expect(allFiles.length).toBe(5);
const limitedFiles = await logger.getLogFiles(3);
expect(limitedFiles.length).toBe(3);
});
it('should return files sorted by most recent first', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
const files: string[] = [];
for (let i = 0; i < 3; i++) {
const logPath = await logger.logInteraction(
{ test: `request-${i}` },
{ test: `response-${i}` },
);
files.push(logPath);
await new Promise((resolve) => setTimeout(resolve, 10));
}
const retrievedFiles = await logger.getLogFiles();
expect(retrievedFiles[0]).toBe(files[2]); // Most recent first
expect(retrievedFiles[1]).toBe(files[1]);
expect(retrievedFiles[2]).toBe(files[0]);
});
});
describe('readLogFile', () => {
it('should read and parse log file correctly', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const response = { id: 'test-id', choices: [] };
const logPath = await logger.logInteraction(request, response);
const logData = await logger.readLogFile(logPath);
expect(logData).toHaveProperty('timestamp');
expect(logData).toHaveProperty('request', request);
expect(logData).toHaveProperty('response', response);
});
it('should throw error when file does not exist', async () => {
const logger = new OpenAILogger(testTempDir);
const nonExistentPath = path.join(testTempDir, 'non-existent.json');
await expect(logger.readLogFile(nonExistentPath)).rejects.toThrow();
});
});
describe('path resolution', () => {
it('should normalize absolute paths', () => {
const absolutePath = '/tmp/test/logs';
const logger = new OpenAILogger(absolutePath);
expect(logger).toBeInstanceOf(OpenAILogger);
});
it('should resolve relative paths based on current working directory', async () => {
const relativePath = 'test-relative-logs';
const logger = new OpenAILogger(relativePath);
await logger.initialize();
const request = { test: 'request' };
const response = { test: 'response' };
const logPath = await logger.logInteraction(request, response);
const expectedBaseDir = path.resolve(process.cwd(), relativePath);
createdDirs.push(expectedBaseDir);
expect(logPath).toContain(expectedBaseDir);
});
it('should handle paths with special characters', async () => {
const specialPath = path.join(testTempDir, 'logs-with-special-chars');
const logger = new OpenAILogger(specialPath);
await logger.initialize();
const request = { test: 'request' };
const response = { test: 'response' };
const logPath = await logger.logInteraction(request, response);
expect(logPath).toContain(specialPath);
});
});
});

View File

@@ -18,10 +18,23 @@ export class OpenAILogger {
/**
* Creates a new OpenAI logger
* @param customLogDir Optional custom log directory path
* @param customLogDir Optional custom log directory path (supports relative paths, absolute paths, and ~ expansion)
*/
constructor(customLogDir?: string) {
this.logDir = customLogDir || path.join(process.cwd(), 'logs', 'openai');
if (customLogDir) {
// Resolve relative paths to absolute paths
// Handle ~ expansion
let resolvedPath = customLogDir;
if (customLogDir === '~' || customLogDir.startsWith('~/')) {
resolvedPath = path.join(os.homedir(), customLogDir.slice(1));
} else if (!path.isAbsolute(customLogDir)) {
// If it's a relative path, resolve it relative to current working directory
resolvedPath = path.resolve(process.cwd(), customLogDir);
}
this.logDir = path.normalize(resolvedPath);
} else {
this.logDir = path.join(process.cwd(), 'logs', 'openai');
}
}
/**

View File

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

View File

@@ -85,7 +85,7 @@ const distPackageJson = {
bin: {
qwen: 'cli.js',
},
files: ['cli.js', 'vendor', 'README.md', 'LICENSE'],
files: ['cli.js', 'vendor', '*.sb', 'README.md', 'LICENSE'],
config: rootPackageJson.config,
dependencies: runtimeDependencies,
optionalDependencies: {

View File

@@ -69,7 +69,14 @@ if (process.env.DEBUG) {
// than the relaunched process making it harder to debug.
env.GEMINI_CLI_NO_RELAUNCH = 'true';
}
const child = spawn('node', nodeArgs, { stdio: 'inherit', env });
// Use process.cwd() to inherit the working directory from launch.json cwd setting
// This allows debugging from a specific directory (e.g., .todo)
const workingDir = process.env.QWEN_WORKING_DIR || process.cwd();
const child = spawn('node', nodeArgs, {
stdio: 'inherit',
env,
cwd: workingDir,
});
child.on('close', (code) => {
process.exit(code);