Compare commits

...

22 Commits

Author SHA1 Message Date
github-actions[bot]
bbbc4c6342 chore(release): v0.8.0-preview.0 2026-01-21 12:25:07 +00:00
tanzhenxin
21b26a400a Merge pull request #1563 from QwenLM/fix/issue-1549-skip-enoent-import
fix: skip non-existent file imports instead of warning (ENOENT)
2026-01-21 20:11:00 +08:00
tanzhenxin
ae9ba8be18 Merge pull request #1513 from QwenLM/feat/cli-welcome-screen
feat: Redesign CLI welcome screen and settings dialog
2026-01-21 19:43:46 +08:00
tanzhenxin
ed12c50693 Merge pull request #1552 from QwenLM/mingholy/feat/acp-bug-command
Add /bug command to non-interactive mode
2026-01-21 19:38:46 +08:00
tanzhenxin
1f5206cd54 Merge pull request #1526 from QwenLM/chore/no-tiktoken
chore: remove tiktoken dependency and use API-reported token counts
2026-01-21 19:37:42 +08:00
Mingholy
19bbd22109 Merge pull request #1556 from QwenLM/chore/bump-version-0.8.0
chore(release): bump version to 0.8.0
2026-01-21 18:21:37 +08:00
tanzhenxin
3ece0e3c3c Merge pull request #1476 from afarber/1461-yolo-color-change
feat(cli): use dim colors for YOLO/auto-accept mode borders
2026-01-21 18:06:52 +08:00
DennisYu07
fb3a95e874 Merge pull request #1234 from afarber/1115-fix-dep0190-deprecation
fix: replace spawn shell option with explicit shell args to avoid Node.js DEP0190 warning
2026-01-21 01:54:37 -08:00
LaZzyMan
1562780393 fix: skip non-existent file imports instead of warning (ENOENT) 2026-01-21 14:13:20 +08:00
tanzhenxin
7d9917b2c9 chore(release): bump version to 0.8.0 2026-01-20 17:39:43 +08:00
mingholy.lmh
7dd56d0861 feat: add bug command for acp support 2026-01-20 16:27:23 +08:00
Mingholy
6eb16c0bcf Merge pull request #1548 from QwenLM/mingholy/fix/qwen-oauth-model-info
Fix: Update Qwen OAuth model information
2026-01-20 16:16:30 +08:00
tanzhenxin
7fa1dcb0e6 Merge pull request #1550 from QwenLM/refactor/acp-error-codes
fix(acp): propagate ENOENT errors correctly and centralize error codes
2026-01-20 16:03:16 +08:00
tanzhenxin
3c68a9a5f6 test(acp): update filesystem tests for error code-based ENOENT handling 2026-01-20 15:40:09 +08:00
tanzhenxin
bdfeec24fb refactor(acp): centralize error codes and add RESOURCE_NOT_FOUND handling for file operations 2026-01-20 15:19:18 +08:00
mingholy.lmh
03f12bfa3f fix: update qwen-oauth models info 2026-01-20 15:11:11 +08:00
tanzhenxin
c14ddab6fe refactor(compression): use API usageMetadata for token counting instead of local tokenizer 2026-01-18 12:45:24 +08:00
tanzhenxin
35c865968f chore: remove tiktoken dependency and replace with character-based token estimation 2026-01-17 22:39:18 +08:00
Alexander Farber
fa8b5a7762 Replace the not functional CodeQL comments 2026-01-17 14:29:43 +01:00
Alexander Farber
9adad2f369 Fix 1 test and silence CodeQL warning (not introduced by this PR) 2026-01-17 14:29:42 +01:00
Alexander Farber
a8ccd7b6fb Replace spawn shell option with explicit shell args to avoid DEP0190 warning 2026-01-17 14:29:42 +01:00
Alexander Farber
5d20848577 Use dim colors for YOLO/auto-accept mode borders 2026-01-17 14:23:42 +01:00
64 changed files with 910 additions and 708 deletions

View File

@@ -202,7 +202,7 @@ This is the most critical stage where files are moved and transformed into their
- Copies README.md and LICENSE to dist/
- Copies locales folder for internationalization
- Creates a clean package.json for distribution with only necessary dependencies
- Includes runtime dependencies like tiktoken
- Keeps distribution dependencies minimal (no bundled runtime deps)
- Maintains optional dependencies for node-pty
2. The JavaScript Bundle is Created:

View File

@@ -33,7 +33,6 @@ const external = [
'@lydell/node-pty-linux-x64',
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
'tiktoken',
];
esbuild

20
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.8.0-preview.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.8.0-preview.0",
"workspaces": [
"packages/*"
],
@@ -15682,12 +15682,6 @@
"tslib": "^2"
}
},
"node_modules/tiktoken": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.22.tgz",
"integrity": "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -17310,7 +17304,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.8.0-preview.0",
"dependencies": {
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
@@ -17947,7 +17941,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.7.1",
"version": "0.8.0-preview.0",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
@@ -17990,7 +17984,6 @@
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"tiktoken": "^1.0.21",
"undici": "^6.22.0",
"uuid": "^9.0.1",
"ws": "^8.18.0"
@@ -18592,7 +18585,6 @@
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",
"tiktoken": "^1.0.21",
"zod": "^3.25.0"
},
"devDependencies": {
@@ -21408,7 +21400,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.7.1",
"version": "0.8.0-preview.0",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -21420,7 +21412,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.7.1",
"version": "0.8.0-preview.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.8.0-preview.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,19 +33,20 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.8.0-preview.0"
},
"dependencies": {
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
"@qwen-code/qwen-code-core": "file:../core",
"@modelcontextprotocol/sdk": "^1.25.1",
"@qwen-code/qwen-code-core": "file:../core",
"@types/update-notifier": "^6.0.8",
"ansi-regex": "^6.2.2",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"extract-zip": "^2.0.1",
"fzf": "^0.5.2",
"glob": "^10.5.0",
"highlight.js": "^11.11.1",
@@ -65,7 +66,6 @@
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"undici": "^6.22.0",
"extract-zip": "^2.0.1",
"update-notifier": "^7.3.1",
"wrap-ansi": "9.0.2",
"yargs": "^17.7.2",
@@ -74,6 +74,7 @@
"devDependencies": {
"@babel/runtime": "^7.27.6",
"@google/gemini-cli-test-utils": "file:../test-utils",
"@qwen-code/qwen-code-test-utils": "file:../test-utils",
"@testing-library/react": "^16.3.0",
"@types/archiver": "^6.0.3",
"@types/command-exists": "^1.2.3",
@@ -92,8 +93,7 @@
"pretty-format": "^30.0.2",
"react-dom": "^19.1.0",
"typescript": "^5.3.3",
"vitest": "^3.1.1",
"@qwen-code/qwen-code-test-utils": "file:../test-utils"
"vitest": "^3.1.1"
},
"engines": {
"node": ">=20"

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
'init',
'summary',
'compress',
'bug',
] as const;
/**

View File

@@ -38,6 +38,12 @@ export const Colors: ColorsTheme = {
get AccentRed() {
return themeManager.getActiveTheme().colors.AccentRed;
},
get AccentYellowDim() {
return themeManager.getActiveTheme().colors.AccentYellowDim;
},
get AccentRedDim() {
return themeManager.getActiveTheme().colors.AccentRedDim;
},
get DiffAdded() {
return themeManager.getActiveTheme().colors.DiffAdded;
},

View File

@@ -749,10 +749,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
statusColor = theme.ui.symbol;
statusText = t('Shell mode');
} else if (showYoloStyling) {
statusColor = theme.status.error;
statusColor = theme.status.errorDim;
statusText = t('YOLO mode');
} else if (showAutoAcceptStyling) {
statusColor = theme.status.warning;
statusColor = theme.status.warningDim;
statusText = t('Accepting edits');
}

View File

@@ -18,6 +18,8 @@ const ansiLightColors: ColorsTheme = {
AccentGreen: 'green',
AccentYellow: 'orange',
AccentRed: 'red',
AccentYellowDim: 'orange',
AccentRedDim: 'red',
DiffAdded: '#E5F2E5',
DiffRemoved: '#FFE5E5',
Comment: 'gray',

View File

@@ -18,6 +18,8 @@ const ansiColors: ColorsTheme = {
AccentGreen: 'green',
AccentYellow: 'yellow',
AccentRed: 'red',
AccentYellowDim: 'yellow',
AccentRedDim: 'red',
DiffAdded: '#003300',
DiffRemoved: '#4D0000',
Comment: 'gray',

View File

@@ -17,6 +17,8 @@ const atomOneDarkColors: ColorsTheme = {
AccentGreen: '#98c379',
AccentYellow: '#e6c07b',
AccentRed: '#e06c75',
AccentYellowDim: '#8B7530',
AccentRedDim: '#8B3A4A',
DiffAdded: '#39544E',
DiffRemoved: '#562B2F',
Comment: '#5c6370',

View File

@@ -17,6 +17,8 @@ const ayuLightColors: ColorsTheme = {
AccentGreen: '#86b300',
AccentYellow: '#f2ae49',
AccentRed: '#f07171',
AccentYellowDim: '#8B7000',
AccentRedDim: '#993333',
DiffAdded: '#C6EAD8',
DiffRemoved: '#FFCCCC',
Comment: '#ABADB1',

View File

@@ -17,6 +17,8 @@ const ayuDarkColors: ColorsTheme = {
AccentGreen: '#AAD94C',
AccentYellow: '#FFB454',
AccentRed: '#F26D78',
AccentYellowDim: '#8B7530',
AccentRedDim: '#8B3A4A',
DiffAdded: '#293022',
DiffRemoved: '#3D1215',
Comment: '#646A71',

View File

@@ -17,6 +17,8 @@ const draculaColors: ColorsTheme = {
AccentGreen: '#50fa7b',
AccentYellow: '#fff783',
AccentRed: '#ff5555',
AccentYellowDim: '#8B7530',
AccentRedDim: '#8B3A4A',
DiffAdded: '#11431d',
DiffRemoved: '#6e1818',
Comment: '#6272a4',

View File

@@ -17,6 +17,8 @@ const githubDarkColors: ColorsTheme = {
AccentGreen: '#85E89D',
AccentYellow: '#FFAB70',
AccentRed: '#F97583',
AccentYellowDim: '#8B7530',
AccentRedDim: '#8B3A4A',
DiffAdded: '#3C4636',
DiffRemoved: '#502125',
Comment: '#6A737D',

View File

@@ -17,6 +17,8 @@ const githubLightColors: ColorsTheme = {
AccentGreen: '#008080',
AccentYellow: '#990073',
AccentRed: '#d14',
AccentYellowDim: '#8B7000',
AccentRedDim: '#993333',
DiffAdded: '#C6EAD8',
DiffRemoved: '#FFCCCC',
Comment: '#998',

View File

@@ -17,6 +17,8 @@ const googleCodeColors: ColorsTheme = {
AccentGreen: '#080',
AccentYellow: '#660',
AccentRed: '#800',
AccentYellowDim: '#8B7000',
AccentRedDim: '#993333',
DiffAdded: '#C6EAD8',
DiffRemoved: '#FEDEDE',
Comment: '#5f6368',

View File

@@ -19,6 +19,8 @@ const noColorColorsTheme: ColorsTheme = {
AccentGreen: '',
AccentYellow: '',
AccentRed: '',
AccentYellowDim: '',
AccentRedDim: '',
DiffAdded: '',
DiffRemoved: '',
Comment: '',
@@ -52,6 +54,8 @@ const noColorSemanticColors: SemanticColors = {
error: '',
success: '',
warning: '',
errorDim: '',
warningDim: '',
},
};

View File

@@ -18,6 +18,8 @@ const qwenDarkColors: ColorsTheme = {
AccentGreen: '#AAD94C',
AccentYellow: '#FFD700',
AccentRed: '#F26D78',
AccentYellowDim: '#8B7530',
AccentRedDim: '#8B3A4A',
DiffAdded: '#AAD94C',
DiffRemoved: '#F26D78',
Comment: '#646A71',

View File

@@ -18,6 +18,8 @@ const qwenLightColors: ColorsTheme = {
AccentGreen: '#86b300',
AccentYellow: '#f2ae49',
AccentRed: '#f07171',
AccentYellowDim: '#8B7000',
AccentRedDim: '#993333',
DiffAdded: '#86b300',
DiffRemoved: '#f07171',
Comment: '#ABADB1',

View File

@@ -33,6 +33,9 @@ export interface SemanticColors {
error: string;
success: string;
warning: string;
// Dim variants for less intense UI elements
errorDim: string;
warningDim: string;
};
}
@@ -63,6 +66,8 @@ export const lightSemanticColors: SemanticColors = {
error: lightTheme.AccentRed,
success: lightTheme.AccentGreen,
warning: lightTheme.AccentYellow,
errorDim: lightTheme.AccentRedDim,
warningDim: lightTheme.AccentYellowDim,
},
};
@@ -93,6 +98,8 @@ export const darkSemanticColors: SemanticColors = {
error: darkTheme.AccentRed,
success: darkTheme.AccentGreen,
warning: darkTheme.AccentYellow,
errorDim: darkTheme.AccentRedDim,
warningDim: darkTheme.AccentYellowDim,
},
};
@@ -123,5 +130,7 @@ export const ansiSemanticColors: SemanticColors = {
error: ansiTheme.AccentRed,
success: ansiTheme.AccentGreen,
warning: ansiTheme.AccentYellow,
errorDim: ansiTheme.AccentRedDim,
warningDim: ansiTheme.AccentYellowDim,
},
};

View File

@@ -22,6 +22,8 @@ const shadesOfPurpleColors: ColorsTheme = {
AccentGreen: '#A5FF90', // Strings and many others
AccentYellow: '#fad000', // Title, main yellow
AccentRed: '#ff628c', // Error/deletion accent
AccentYellowDim: '#8B7530',
AccentRedDim: '#8B3A4A',
DiffAdded: '#383E45',
DiffRemoved: '#572244',
Comment: '#B362FF', // Comment color (same as AccentPurple)

View File

@@ -21,6 +21,9 @@ export interface ColorsTheme {
AccentGreen: string;
AccentYellow: string;
AccentRed: string;
// Dim variants for less intense UI elements
AccentYellowDim: string;
AccentRedDim: string;
DiffAdded: string;
DiffRemoved: string;
Comment: string;
@@ -58,6 +61,8 @@ export interface CustomTheme {
error?: string;
success?: string;
warning?: string;
errorDim?: string;
warningDim?: string;
};
// Legacy properties (all optional)
@@ -70,6 +75,8 @@ export interface CustomTheme {
AccentGreen?: string;
AccentYellow?: string;
AccentRed?: string;
AccentYellowDim?: string;
AccentRedDim?: string;
DiffAdded?: string;
DiffRemoved?: string;
Comment?: string;
@@ -88,6 +95,8 @@ export const lightTheme: ColorsTheme = {
AccentGreen: '#3CA84B',
AccentYellow: '#D5A40A',
AccentRed: '#DD4C4C',
AccentYellowDim: '#8B7000',
AccentRedDim: '#993333',
DiffAdded: '#C6EAD8',
DiffRemoved: '#FFCCCC',
Comment: '#008000',
@@ -106,6 +115,8 @@ export const darkTheme: ColorsTheme = {
AccentGreen: '#A6E3A1',
AccentYellow: '#F9E2AF',
AccentRed: '#F38BA8',
AccentYellowDim: '#8B7530',
AccentRedDim: '#8B3A4A',
DiffAdded: '#28350B',
DiffRemoved: '#430000',
Comment: '#6C7086',
@@ -124,6 +135,8 @@ export const ansiTheme: ColorsTheme = {
AccentGreen: 'green',
AccentYellow: 'yellow',
AccentRed: 'red',
AccentYellowDim: 'yellow',
AccentRedDim: 'red',
DiffAdded: 'green',
DiffRemoved: 'red',
Comment: 'gray',
@@ -182,6 +195,8 @@ export class Theme {
error: this.colors.AccentRed,
success: this.colors.AccentGreen,
warning: this.colors.AccentYellow,
errorDim: this.colors.AccentRedDim,
warningDim: this.colors.AccentYellowDim,
},
};
this._colorMap = Object.freeze(this._buildColorMap(rawMappings)); // Build and freeze the map
@@ -261,6 +276,10 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
AccentGreen: customTheme.status?.success ?? customTheme.AccentGreen ?? '',
AccentYellow: customTheme.status?.warning ?? customTheme.AccentYellow ?? '',
AccentRed: customTheme.status?.error ?? customTheme.AccentRed ?? '',
AccentYellowDim:
customTheme.status?.warningDim ?? customTheme.AccentYellowDim ?? '',
AccentRedDim:
customTheme.status?.errorDim ?? customTheme.AccentRedDim ?? '',
DiffAdded:
customTheme.background?.diff?.added ?? customTheme.DiffAdded ?? '',
DiffRemoved:
@@ -435,6 +454,8 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
error: customTheme.status?.error ?? colors.AccentRed,
success: customTheme.status?.success ?? colors.AccentGreen,
warning: customTheme.status?.warning ?? colors.AccentYellow,
errorDim: customTheme.status?.errorDim ?? colors.AccentRedDim,
warningDim: customTheme.status?.warningDim ?? colors.AccentYellowDim,
},
};

View File

@@ -17,6 +17,8 @@ const xcodeColors: ColorsTheme = {
AccentGreen: '#007400',
AccentYellow: '#836C28',
AccentRed: '#c41a16',
AccentYellowDim: '#8B7000',
AccentRedDim: '#993333',
DiffAdded: '#C6EAD8',
DiffRemoved: '#FEDEDE',
Comment: '#007400',

View File

@@ -241,9 +241,12 @@ describe('handleAutoUpdate', () => {
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(mockSpawn).toHaveBeenCalledWith(
'npm i -g @qwen-code/qwen-code@nightly',
expect.stringMatching(/^(bash|cmd\.exe)$/),
expect.arrayContaining([
expect.stringMatching(/^(-c|\/c)$/),
'npm i -g @qwen-code/qwen-code@nightly',
]),
{
shell: true,
stdio: 'pipe',
},
);

View File

@@ -12,6 +12,7 @@ import type { HistoryItem } from '../ui/types.js';
import { MessageType } from '../ui/types.js';
import { spawnWrapper } from './spawnWrapper.js';
import type { spawn } from 'node:child_process';
import os from 'node:os';
export function handleAutoUpdate(
info: UpdateObject | null,
@@ -53,7 +54,10 @@ export function handleAutoUpdate(
'@latest',
isNightly ? '@nightly' : `@${info.update.latest}`,
);
const updateProcess = spawnFn(updateCommand, { stdio: 'pipe', shell: true });
const isWindows = os.platform() === 'win32';
const shell = isWindows ? 'cmd.exe' : 'bash';
const shellArgs = isWindows ? ['/c', updateCommand] : ['-c', updateCommand];
const updateProcess = spawnFn(shell, shellArgs, { stdio: 'pipe' });
let errorOutput = '';
updateProcess.stderr.on('data', (data) => {
errorOutput += data.toString();

View File

@@ -291,9 +291,10 @@ export async function start_sandbox(
sandboxEnv['NO_PROXY'] = noProxy;
sandboxEnv['no_proxy'] = noProxy;
}
proxyProcess = spawn(proxyCommand, {
// Note: CodeQL flags this as js/shell-command-injection-from-environment.
// This is intentional - CLI tool executes user-provided proxy commands.
proxyProcess = spawn('bash', ['-c', proxyCommand], {
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
detached: true,
});
// install handlers to stop proxy on exit/signal
@@ -781,9 +782,15 @@ export async function start_sandbox(
if (proxyCommand) {
// run proxyCommand in its own container
const proxyContainerCommand = `${config.command} run --rm --init ${userFlag} --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`;
proxyProcess = spawn(proxyContainerCommand, {
const isWindows = os.platform() === 'win32';
const proxyShell = isWindows ? 'cmd.exe' : 'bash';
const proxyShellArgs = isWindows
? ['/c', proxyContainerCommand]
: ['-c', proxyContainerCommand];
// Note: CodeQL flags this as js/shell-command-injection-from-environment.
// This is intentional - CLI tool executes user-provided proxy commands in container.
proxyProcess = spawn(proxyShell, proxyShellArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
detached: true,
});
// install handlers to stop proxy on exit/signal

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.7.1",
"version": "0.8.0-preview.0",
"description": "Qwen Code Core",
"repository": {
"type": "git",
@@ -63,7 +63,6 @@
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"tiktoken": "^1.0.21",
"undici": "^6.22.0",
"uuid": "^9.0.1",
"ws": "^8.18.0"

View File

@@ -19,9 +19,7 @@ const mockTokenizer = {
};
vi.mock('../../utils/request-tokenizer/index.js', () => ({
getDefaultTokenizer: vi.fn(() => mockTokenizer),
DefaultRequestTokenizer: vi.fn(() => mockTokenizer),
disposeDefaultTokenizer: vi.fn(),
RequestTokenEstimator: vi.fn(() => mockTokenizer),
}));
type AnthropicCreateArgs = [unknown, { signal?: AbortSignal }?];
@@ -352,9 +350,7 @@ describe('AnthropicContentGenerator', () => {
};
const result = await generator.countTokens(request);
expect(mockTokenizer.calculateTokens).toHaveBeenCalledWith(request, {
textEncoding: 'cl100k_base',
});
expect(mockTokenizer.calculateTokens).toHaveBeenCalledWith(request);
expect(result.totalTokens).toBe(50);
});

View File

@@ -25,7 +25,7 @@ type MessageCreateParamsNonStreaming =
Anthropic.MessageCreateParamsNonStreaming;
type MessageCreateParamsStreaming = Anthropic.MessageCreateParamsStreaming;
type RawMessageStreamEvent = Anthropic.RawMessageStreamEvent;
import { getDefaultTokenizer } from '../../utils/request-tokenizer/index.js';
import { RequestTokenEstimator } from '../../utils/request-tokenizer/index.js';
import { safeJsonParse } from '../../utils/safeJsonParse.js';
import { AnthropicContentConverter } from './converter.js';
@@ -105,10 +105,8 @@ export class AnthropicContentGenerator implements ContentGenerator {
request: CountTokensParameters,
): Promise<CountTokensResponse> {
try {
const tokenizer = getDefaultTokenizer();
const result = await tokenizer.calculateTokens(request, {
textEncoding: 'cl100k_base',
});
const estimator = new RequestTokenEstimator();
const result = await estimator.calculateTokens(request);
return {
totalTokens: result.totalTokens,

View File

@@ -153,6 +153,26 @@ vi.mock('../telemetry/loggers.js', () => ({
logNextSpeakerCheck: vi.fn(),
}));
// Mock RequestTokenizer to use simple character-based estimation
vi.mock('../utils/request-tokenizer/requestTokenizer.js', () => ({
RequestTokenizer: class {
async calculateTokens(request: { contents: unknown }) {
// Simple estimation: count characters in JSON and divide by 4
const totalChars = JSON.stringify(request.contents).length;
return {
totalTokens: Math.floor(totalChars / 4),
breakdown: {
textTokens: Math.floor(totalChars / 4),
imageTokens: 0,
audioTokens: 0,
otherTokens: 0,
},
processingTime: 0,
};
}
},
}));
/**
* Array.fromAsync ponyfill, which will be available in es 2024.
*
@@ -417,6 +437,12 @@ describe('Gemini Client (client.ts)', () => {
] as Content[],
originalTokenCount = 1000,
summaryText = 'This is a summary.',
// Token counts returned in usageMetadata to simulate what the API would return
// Default values ensure successful compression:
// newTokenCount = originalTokenCount - (compressionInputTokenCount - 1000) + compressionOutputTokenCount
// = 1000 - (1600 - 1000) + 50 = 1000 - 600 + 50 = 450 (< 1000, success)
compressionInputTokenCount = 1600,
compressionOutputTokenCount = 50,
} = {}) {
const mockOriginalChat: Partial<GeminiChat> = {
getHistory: vi.fn((_curated?: boolean) => chatHistory),
@@ -438,6 +464,12 @@ describe('Gemini Client (client.ts)', () => {
},
},
],
usageMetadata: {
promptTokenCount: compressionInputTokenCount,
candidatesTokenCount: compressionOutputTokenCount,
totalTokenCount:
compressionInputTokenCount + compressionOutputTokenCount,
},
} as unknown as GenerateContentResponse);
// Calculate what the new history will be
@@ -477,11 +509,13 @@ describe('Gemini Client (client.ts)', () => {
.fn()
.mockResolvedValue(mockNewChat as GeminiChat);
const totalChars = newCompressedHistory.reduce(
(total, content) => total + JSON.stringify(content).length,
// New token count formula: originalTokenCount - (compressionInputTokenCount - 1000) + compressionOutputTokenCount
const estimatedNewTokenCount = Math.max(
0,
originalTokenCount -
(compressionInputTokenCount - 1000) +
compressionOutputTokenCount,
);
const estimatedNewTokenCount = Math.floor(totalChars / 4);
return {
client,
@@ -493,49 +527,58 @@ describe('Gemini Client (client.ts)', () => {
describe('when compression inflates the token count', () => {
it('allows compression to be forced/manual after a failure', async () => {
// Call 1 (Fails): Setup with a long summary to inflate tokens
// Call 1 (Fails): Setup with token counts that will inflate
// newTokenCount = originalTokenCount - (compressionInputTokenCount - 1000) + compressionOutputTokenCount
// = 100 - (1010 - 1000) + 200 = 100 - 10 + 200 = 290 > 100 (inflation)
const longSummary = 'long summary '.repeat(100);
const { client, estimatedNewTokenCount: inflatedTokenCount } = setup({
originalTokenCount: 100,
summaryText: longSummary,
compressionInputTokenCount: 1010,
compressionOutputTokenCount: 200,
});
expect(inflatedTokenCount).toBeGreaterThan(100); // Ensure setup is correct
await client.tryCompressChat('prompt-id-4', false); // Fails
// Call 2 (Forced): Re-setup with a short summary
// Call 2 (Forced): Re-setup with token counts that will compress
// newTokenCount = 100 - (1100 - 1000) + 50 = 100 - 100 + 50 = 50 <= 100 (compression)
const shortSummary = 'short';
const { estimatedNewTokenCount: compressedTokenCount } = setup({
originalTokenCount: 100,
summaryText: shortSummary,
compressionInputTokenCount: 1100,
compressionOutputTokenCount: 50,
});
expect(compressedTokenCount).toBeLessThanOrEqual(100); // Ensure setup is correct
const result = await client.tryCompressChat('prompt-id-4', true); // Forced
expect(result).toEqual({
compressionStatus: CompressionStatus.COMPRESSED,
newTokenCount: compressedTokenCount,
originalTokenCount: 100,
});
expect(result.compressionStatus).toBe(CompressionStatus.COMPRESSED);
expect(result.originalTokenCount).toBe(100);
// newTokenCount might be clamped to originalTokenCount due to tolerance logic
expect(result.newTokenCount).toBeLessThanOrEqual(100);
});
it('yields the result even if the compression inflated the tokens', async () => {
// newTokenCount = 100 - (1010 - 1000) + 200 = 100 - 10 + 200 = 290 > 100 (inflation)
const longSummary = 'long summary '.repeat(100);
const { client, estimatedNewTokenCount } = setup({
originalTokenCount: 100,
summaryText: longSummary,
compressionInputTokenCount: 1010,
compressionOutputTokenCount: 200,
});
expect(estimatedNewTokenCount).toBeGreaterThan(100); // Ensure setup is correct
const result = await client.tryCompressChat('prompt-id-4', false);
expect(result).toEqual({
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
newTokenCount: estimatedNewTokenCount,
originalTokenCount: 100,
});
expect(result.compressionStatus).toBe(
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
);
expect(result.originalTokenCount).toBe(100);
// The newTokenCount should be higher than original since compression failed due to inflation
expect(result.newTokenCount).toBeGreaterThan(100);
// IMPORTANT: The change in client.ts means setLastPromptTokenCount is NOT called on failure
expect(
uiTelemetryService.setLastPromptTokenCount,
@@ -543,10 +586,13 @@ describe('Gemini Client (client.ts)', () => {
});
it('does not manipulate the source chat', async () => {
// newTokenCount = 100 - (1010 - 1000) + 200 = 100 - 10 + 200 = 290 > 100 (inflation)
const longSummary = 'long summary '.repeat(100);
const { client, mockOriginalChat, estimatedNewTokenCount } = setup({
originalTokenCount: 100,
summaryText: longSummary,
compressionInputTokenCount: 1010,
compressionOutputTokenCount: 200,
});
expect(estimatedNewTokenCount).toBeGreaterThan(100); // Ensure setup is correct
@@ -557,10 +603,13 @@ describe('Gemini Client (client.ts)', () => {
});
it('will not attempt to compress context after a failure', async () => {
// newTokenCount = 100 - (1010 - 1000) + 200 = 100 - 10 + 200 = 290 > 100 (inflation)
const longSummary = 'long summary '.repeat(100);
const { client, estimatedNewTokenCount } = setup({
originalTokenCount: 100,
summaryText: longSummary,
compressionInputTokenCount: 1010,
compressionOutputTokenCount: 200,
});
expect(estimatedNewTokenCount).toBeGreaterThan(100); // Ensure setup is correct
@@ -631,6 +680,7 @@ describe('Gemini Client (client.ts)', () => {
);
// Mock the summary response from the chat
// newTokenCount = 501 - (1400 - 1000) + 50 = 501 - 400 + 50 = 151 <= 501 (success)
const summaryText = 'This is a summary.';
mockGenerateContentFn.mockResolvedValue({
candidates: [
@@ -641,6 +691,11 @@ describe('Gemini Client (client.ts)', () => {
},
},
],
usageMetadata: {
promptTokenCount: 1400,
candidatesTokenCount: 50,
totalTokenCount: 1450,
},
} as unknown as GenerateContentResponse);
// Mock startChat to complete the compression flow
@@ -719,13 +774,8 @@ describe('Gemini Client (client.ts)', () => {
.fn()
.mockResolvedValue(mockNewChat as GeminiChat);
const totalChars = newCompressedHistory.reduce(
(total, content) => total + JSON.stringify(content).length,
0,
);
const newTokenCount = Math.floor(totalChars / 4);
// Mock the summary response from the chat
// newTokenCount = 501 - (1400 - 1000) + 50 = 501 - 400 + 50 = 151 <= 501 (success)
mockGenerateContentFn.mockResolvedValue({
candidates: [
{
@@ -735,6 +785,11 @@ describe('Gemini Client (client.ts)', () => {
},
},
],
usageMetadata: {
promptTokenCount: 1400,
candidatesTokenCount: 50,
totalTokenCount: 1450,
},
} as unknown as GenerateContentResponse);
const initialChat = client.getChat();
@@ -744,12 +799,11 @@ describe('Gemini Client (client.ts)', () => {
expect(tokenLimit).toHaveBeenCalled();
expect(mockGenerateContentFn).toHaveBeenCalled();
// Assert that summarization happened and returned the correct stats
expect(result).toEqual({
compressionStatus: CompressionStatus.COMPRESSED,
originalTokenCount,
newTokenCount,
});
// Assert that summarization happened
expect(result.compressionStatus).toBe(CompressionStatus.COMPRESSED);
expect(result.originalTokenCount).toBe(originalTokenCount);
// newTokenCount might be clamped to originalTokenCount due to tolerance logic
expect(result.newTokenCount).toBeLessThanOrEqual(originalTokenCount);
// Assert that the chat was reset
expect(newChat).not.toBe(initialChat);
@@ -809,13 +863,8 @@ describe('Gemini Client (client.ts)', () => {
.fn()
.mockResolvedValue(mockNewChat as GeminiChat);
const totalChars = newCompressedHistory.reduce(
(total, content) => total + JSON.stringify(content).length,
0,
);
const newTokenCount = Math.floor(totalChars / 4);
// Mock the summary response from the chat
// newTokenCount = 700 - (1500 - 1000) + 50 = 700 - 500 + 50 = 250 <= 700 (success)
mockGenerateContentFn.mockResolvedValue({
candidates: [
{
@@ -825,6 +874,11 @@ describe('Gemini Client (client.ts)', () => {
},
},
],
usageMetadata: {
promptTokenCount: 1500,
candidatesTokenCount: 50,
totalTokenCount: 1550,
},
} as unknown as GenerateContentResponse);
const initialChat = client.getChat();
@@ -834,12 +888,11 @@ describe('Gemini Client (client.ts)', () => {
expect(tokenLimit).toHaveBeenCalled();
expect(mockGenerateContentFn).toHaveBeenCalled();
// Assert that summarization happened and returned the correct stats
expect(result).toEqual({
compressionStatus: CompressionStatus.COMPRESSED,
originalTokenCount,
newTokenCount,
});
// Assert that summarization happened
expect(result.compressionStatus).toBe(CompressionStatus.COMPRESSED);
expect(result.originalTokenCount).toBe(originalTokenCount);
// newTokenCount might be clamped to originalTokenCount due to tolerance logic
expect(result.newTokenCount).toBeLessThanOrEqual(originalTokenCount);
// Assert that the chat was reset
expect(newChat).not.toBe(initialChat);
@@ -887,13 +940,8 @@ describe('Gemini Client (client.ts)', () => {
.fn()
.mockResolvedValue(mockNewChat as GeminiChat);
const totalChars = newCompressedHistory.reduce(
(total, content) => total + JSON.stringify(content).length,
0,
);
const newTokenCount = Math.floor(totalChars / 4);
// Mock the summary response from the chat
// newTokenCount = 100 - (1060 - 1000) + 20 = 100 - 60 + 20 = 60 <= 100 (success)
mockGenerateContentFn.mockResolvedValue({
candidates: [
{
@@ -903,6 +951,11 @@ describe('Gemini Client (client.ts)', () => {
},
},
],
usageMetadata: {
promptTokenCount: 1060,
candidatesTokenCount: 20,
totalTokenCount: 1080,
},
} as unknown as GenerateContentResponse);
const initialChat = client.getChat();
@@ -911,11 +964,10 @@ describe('Gemini Client (client.ts)', () => {
expect(mockGenerateContentFn).toHaveBeenCalled();
expect(result).toEqual({
compressionStatus: CompressionStatus.COMPRESSED,
originalTokenCount,
newTokenCount,
});
expect(result.compressionStatus).toBe(CompressionStatus.COMPRESSED);
expect(result.originalTokenCount).toBe(originalTokenCount);
// newTokenCount might be clamped to originalTokenCount due to tolerance logic
expect(result.newTokenCount).toBeLessThanOrEqual(originalTokenCount);
// Assert that the chat was reset
expect(newChat).not.toBe(initialChat);

View File

@@ -441,47 +441,19 @@ export class GeminiClient {
yield { type: GeminiEventType.ChatCompressed, value: compressed };
}
// Check session token limit after compression using accurate token counting
// Check session token limit after compression.
// `lastPromptTokenCount` is treated as authoritative for the (possibly compressed) history;
const sessionTokenLimit = this.config.getSessionTokenLimit();
if (sessionTokenLimit > 0) {
// Get all the content that would be sent in an API call
const currentHistory = this.getChat().getHistory(true);
const userMemory = this.config.getUserMemory();
const systemPrompt = getCoreSystemPrompt(
userMemory,
this.config.getModel(),
);
const initialHistory = await getInitialChatHistory(this.config);
// Create a mock request content to count total tokens
const mockRequestContent = [
{
role: 'system' as const,
parts: [{ text: systemPrompt }],
},
...initialHistory,
...currentHistory,
];
// Use the improved countTokens method for accurate counting
const { totalTokens: totalRequestTokens } = await this.config
.getContentGenerator()
.countTokens({
model: this.config.getModel(),
contents: mockRequestContent,
});
if (
totalRequestTokens !== undefined &&
totalRequestTokens > sessionTokenLimit
) {
const lastPromptTokenCount = uiTelemetryService.getLastPromptTokenCount();
if (lastPromptTokenCount > sessionTokenLimit) {
yield {
type: GeminiEventType.SessionTokenLimitExceeded,
value: {
currentTokens: totalRequestTokens,
currentTokens: lastPromptTokenCount,
limit: sessionTokenLimit,
message:
`Session token limit exceeded: ${totalRequestTokens} tokens > ${sessionTokenLimit} limit. ` +
`Session token limit exceeded: ${lastPromptTokenCount} tokens > ${sessionTokenLimit} limit. ` +
'Please start a new session or increase the sessionTokenLimit in your settings.json.',
},
};

View File

@@ -708,7 +708,7 @@ describe('GeminiChat', () => {
// Verify that token counting is called when usageMetadata is present
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(
42,
57,
);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(
1,

View File

@@ -529,10 +529,10 @@ export class GeminiChat {
// Collect token usage for consolidated recording
if (chunk.usageMetadata) {
usageMetadata = chunk.usageMetadata;
if (chunk.usageMetadata.promptTokenCount !== undefined) {
uiTelemetryService.setLastPromptTokenCount(
chunk.usageMetadata.promptTokenCount,
);
const lastPromptTokenCount =
usageMetadata.totalTokenCount ?? usageMetadata.promptTokenCount;
if (lastPromptTokenCount) {
uiTelemetryService.setLastPromptTokenCount(lastPromptTokenCount);
}
}

View File

@@ -22,17 +22,7 @@ const mockTokenizer = {
};
vi.mock('../../../utils/request-tokenizer/index.js', () => ({
getDefaultTokenizer: vi.fn(() => mockTokenizer),
DefaultRequestTokenizer: vi.fn(() => mockTokenizer),
disposeDefaultTokenizer: vi.fn(),
}));
// Mock tiktoken as well for completeness
vi.mock('tiktoken', () => ({
get_encoding: vi.fn(() => ({
encode: vi.fn(() => new Array(50)), // Mock 50 tokens
free: vi.fn(),
})),
RequestTokenEstimator: vi.fn(() => mockTokenizer),
}));
// Now import the modules that depend on the mocked modules
@@ -134,7 +124,7 @@ describe('OpenAIContentGenerator (Refactored)', () => {
});
describe('countTokens', () => {
it('should count tokens using tiktoken', async () => {
it('should count tokens using character-based estimation', async () => {
const request: CountTokensParameters = {
contents: [{ role: 'user', parts: [{ text: 'Hello world' }] }],
model: 'gpt-4',
@@ -142,26 +132,27 @@ describe('OpenAIContentGenerator (Refactored)', () => {
const result = await generator.countTokens(request);
expect(result.totalTokens).toBe(50); // Mocked value
// 'Hello world' = 11 ASCII chars
// 11 / 4 = 2.75 -> ceil = 3 tokens
expect(result.totalTokens).toBe(3);
});
it('should fall back to character approximation if tiktoken fails', async () => {
// Mock tiktoken to throw error
vi.doMock('tiktoken', () => ({
get_encoding: vi.fn().mockImplementation(() => {
throw new Error('Tiktoken failed');
}),
}));
it('should handle multimodal content', async () => {
const request: CountTokensParameters = {
contents: [{ role: 'user', parts: [{ text: 'Hello world' }] }],
contents: [
{
role: 'user',
parts: [{ text: 'Hello' }, { text: ' world' }],
},
],
model: 'gpt-4',
};
const result = await generator.countTokens(request);
// Should use character approximation (content length / 4)
expect(result.totalTokens).toBeGreaterThan(0);
// Parts are combined for estimation:
// 'Hello world' = 11 ASCII chars -> 11/4 = 2.75 -> ceil = 3 tokens
expect(result.totalTokens).toBe(3);
});
});

View File

@@ -12,7 +12,7 @@ import type {
import type { PipelineConfig } from './pipeline.js';
import { ContentGenerationPipeline } from './pipeline.js';
import { EnhancedErrorHandler } from './errorHandler.js';
import { getDefaultTokenizer } from '../../utils/request-tokenizer/index.js';
import { RequestTokenEstimator } from '../../utils/request-tokenizer/index.js';
import type { ContentGeneratorConfig } from '../contentGenerator.js';
import { isAbortError } from '../../utils/errors.js';
@@ -80,11 +80,9 @@ export class OpenAIContentGenerator implements ContentGenerator {
request: CountTokensParameters,
): Promise<CountTokensResponse> {
try {
// Use the new high-performance request tokenizer
const tokenizer = getDefaultTokenizer();
const result = await tokenizer.calculateTokens(request, {
textEncoding: 'cl100k_base', // Use GPT-4 encoding for consistency
});
// Use the request token estimator (character-based).
const estimator = new RequestTokenEstimator();
const result = await estimator.calculateTokens(request);
return {
totalTokens: result.totalTokens,

View File

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

View File

@@ -15,13 +15,11 @@ import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
import { tokenLimit } from '../core/tokenLimits.js';
import type { GeminiChat } from '../core/geminiChat.js';
import type { Config } from '../config/config.js';
import { getInitialChatHistory } from '../utils/environmentContext.js';
import type { ContentGenerator } from '../core/contentGenerator.js';
vi.mock('../telemetry/uiTelemetry.js');
vi.mock('../core/tokenLimits.js');
vi.mock('../telemetry/loggers.js');
vi.mock('../utils/environmentContext.js');
describe('findCompressSplitPoint', () => {
it('should throw an error for non-positive numbers', () => {
@@ -122,9 +120,6 @@ describe('ChatCompressionService', () => {
vi.mocked(tokenLimit).mockReturnValue(1000);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(500);
vi.mocked(getInitialChatHistory).mockImplementation(
async (_config, extraHistory) => extraHistory || [],
);
});
afterEach(() => {
@@ -241,6 +236,7 @@ describe('ChatCompressionService', () => {
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(800);
vi.mocked(tokenLimit).mockReturnValue(1000);
// newTokenCount = 800 - (1600 - 1000) + 50 = 800 - 600 + 50 = 250 <= 800 (success)
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
@@ -249,6 +245,11 @@ describe('ChatCompressionService', () => {
},
},
],
usageMetadata: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
totalTokenCount: 1650,
},
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
@@ -264,6 +265,7 @@ describe('ChatCompressionService', () => {
);
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
expect(result.info.newTokenCount).toBe(250); // 800 - (1600 - 1000) + 50
expect(result.newHistory).not.toBeNull();
expect(result.newHistory![0].parts![0].text).toBe('Summary');
expect(mockGenerateContent).toHaveBeenCalled();
@@ -280,6 +282,7 @@ describe('ChatCompressionService', () => {
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(100);
vi.mocked(tokenLimit).mockReturnValue(1000);
// newTokenCount = 100 - (1100 - 1000) + 50 = 100 - 100 + 50 = 50 <= 100 (success)
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
@@ -288,6 +291,11 @@ describe('ChatCompressionService', () => {
},
},
],
usageMetadata: {
promptTokenCount: 1100,
candidatesTokenCount: 50,
totalTokenCount: 1150,
},
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
@@ -315,15 +323,19 @@ describe('ChatCompressionService', () => {
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(10);
vi.mocked(tokenLimit).mockReturnValue(1000);
const longSummary = 'a'.repeat(1000); // Long summary to inflate token count
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: longSummary }],
parts: [{ text: 'Summary' }],
},
},
],
usageMetadata: {
promptTokenCount: 1,
candidatesTokenCount: 20,
totalTokenCount: 21,
},
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
@@ -344,6 +356,48 @@ describe('ChatCompressionService', () => {
expect(result.newHistory).toBeNull();
});
it('should return FAILED if usage metadata is missing', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
{ role: 'user', parts: [{ text: 'msg3' }] },
{ role: 'model', parts: [{ text: 'msg4' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(800);
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: 'Summary' }],
},
},
],
// No usageMetadata -> keep original token count
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
} as unknown as ContentGenerator);
const result = await service.compress(
mockChat,
mockPromptId,
false,
mockModel,
mockConfig,
false,
);
expect(result.info.compressionStatus).toBe(
CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR,
);
expect(result.info.originalTokenCount).toBe(800);
expect(result.info.newTokenCount).toBe(800);
expect(result.newHistory).toBeNull();
});
it('should return FAILED if summary is empty string', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },

View File

@@ -14,7 +14,6 @@ import { getCompressionPrompt } from '../core/prompts.js';
import { getResponseText } from '../utils/partUtils.js';
import { logChatCompression } from '../telemetry/loggers.js';
import { makeChatCompressionEvent } from '../telemetry/types.js';
import { getInitialChatHistory } from '../utils/environmentContext.js';
/**
* Threshold for compression token count as a fraction of the model's token limit.
@@ -163,9 +162,25 @@ export class ChatCompressionService {
);
const summary = getResponseText(summaryResponse) ?? '';
const isSummaryEmpty = !summary || summary.trim().length === 0;
const compressionUsageMetadata = summaryResponse.usageMetadata;
const compressionInputTokenCount =
compressionUsageMetadata?.promptTokenCount;
let compressionOutputTokenCount =
compressionUsageMetadata?.candidatesTokenCount;
if (
compressionOutputTokenCount === undefined &&
typeof compressionUsageMetadata?.totalTokenCount === 'number' &&
typeof compressionInputTokenCount === 'number'
) {
compressionOutputTokenCount = Math.max(
0,
compressionUsageMetadata.totalTokenCount - compressionInputTokenCount,
);
}
let newTokenCount = originalTokenCount;
let extraHistory: Content[] = [];
let canCalculateNewTokenCount = false;
if (!isSummaryEmpty) {
extraHistory = [
@@ -180,16 +195,26 @@ export class ChatCompressionService {
...historyToKeep,
];
// Use a shared utility to construct the initial history for an accurate token count.
const fullNewHistory = await getInitialChatHistory(config, extraHistory);
// Estimate token count 1 token ≈ 4 characters
newTokenCount = Math.floor(
fullNewHistory.reduce(
(total, content) => total + JSON.stringify(content).length,
// Best-effort token math using *only* model-reported token counts.
//
// Note: compressionInputTokenCount includes the compression prompt and
// the extra "reason in your scratchpad" instruction(approx. 1000 tokens), and
// compressionOutputTokenCount may include non-persisted tokens (thoughts).
// We accept these inaccuracies to avoid local token estimation.
if (
typeof compressionInputTokenCount === 'number' &&
compressionInputTokenCount > 0 &&
typeof compressionOutputTokenCount === 'number' &&
compressionOutputTokenCount > 0
) {
canCalculateNewTokenCount = true;
newTokenCount = Math.max(
0,
) / 4,
);
originalTokenCount -
(compressionInputTokenCount - 1000) +
compressionOutputTokenCount,
);
}
}
logChatCompression(
@@ -197,6 +222,8 @@ export class ChatCompressionService {
makeChatCompressionEvent({
tokens_before: originalTokenCount,
tokens_after: newTokenCount,
compression_input_token_count: compressionInputTokenCount,
compression_output_token_count: compressionOutputTokenCount,
}),
);
@@ -209,6 +236,16 @@ export class ChatCompressionService {
compressionStatus: CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
},
};
} else if (!canCalculateNewTokenCount) {
return {
newHistory: null,
info: {
originalTokenCount,
newTokenCount: originalTokenCount,
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR,
},
};
} else if (newTokenCount > originalTokenCount) {
return {
newHistory: null,

View File

@@ -580,9 +580,11 @@ describe('ShellExecutionService child_process fallback', () => {
});
expect(mockCpSpawn).toHaveBeenCalledWith(
'ls -l',
[],
expect.objectContaining({ shell: 'bash' }),
'bash',
['-c', 'ls -l'],
expect.objectContaining({
detached: true,
}),
);
expect(result.exitCode).toBe(0);
expect(result.signal).toBeNull();
@@ -825,10 +827,9 @@ describe('ShellExecutionService child_process fallback', () => {
);
expect(mockCpSpawn).toHaveBeenCalledWith(
'dir "foo bar"',
[],
'cmd.exe',
['/c', 'dir "foo bar"'],
expect.objectContaining({
shell: true,
detached: false,
windowsHide: true,
}),
@@ -840,10 +841,9 @@ describe('ShellExecutionService child_process fallback', () => {
await simulateExecution('ls "foo bar"', (cp) => cp.emit('exit', 0, null));
expect(mockCpSpawn).toHaveBeenCalledWith(
'ls "foo bar"',
[],
'bash',
['-c', 'ls "foo bar"'],
expect.objectContaining({
shell: 'bash',
detached: true,
}),
);

View File

@@ -223,12 +223,17 @@ export class ShellExecutionService {
): ShellExecutionHandle {
try {
const isWindows = os.platform() === 'win32';
const shell = isWindows ? 'cmd.exe' : 'bash';
const shellArgs = isWindows
? ['/c', commandToExecute]
: ['-c', commandToExecute];
const child = cpSpawn(commandToExecute, [], {
// Note: CodeQL flags this as js/shell-command-injection-from-environment.
// This is intentional - CLI tool executes user-provided shell commands.
const child = cpSpawn(shell, shellArgs, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: true,
shell: isWindows ? true : 'bash',
windowsVerbatimArguments: isWindows,
detached: !isWindows,
windowsHide: isWindows,
env: {

View File

@@ -439,17 +439,27 @@ export interface ChatCompressionEvent extends BaseTelemetryEvent {
'event.timestamp': string;
tokens_before: number;
tokens_after: number;
compression_input_token_count?: number;
compression_output_token_count?: number;
}
export function makeChatCompressionEvent({
tokens_before,
tokens_after,
compression_input_token_count,
compression_output_token_count,
}: Omit<ChatCompressionEvent, CommonFields>): ChatCompressionEvent {
return {
'event.name': 'chat_compression',
'event.timestamp': new Date().toISOString(),
tokens_before,
tokens_after,
...(compression_input_token_count !== undefined
? { compression_input_token_count }
: {}),
...(compression_output_token_count !== undefined
? { compression_output_token_count }
: {}),
};
}

View File

@@ -92,11 +92,15 @@ const findCodeBlocks = (
describe('memoryImportProcessor', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks(); // Use resetAllMocks to clear mock implementations
// Mock console methods
console.warn = vi.fn();
console.error = vi.fn();
console.debug = vi.fn();
// Default mock for lstat (used by findProjectRoot)
mockedFs.lstat.mockRejectedValue(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
);
});
afterEach(() => {
@@ -204,20 +208,43 @@ describe('memoryImportProcessor', () => {
);
});
it('should handle file not found errors', async () => {
it('should silently preserve content when file not found (ENOENT)', async () => {
const content = 'Content @./nonexistent.md more content';
const basePath = testPath('test', 'path');
mockedFs.access.mockRejectedValue(new Error('File not found'));
// Mock ENOENT error (file not found)
mockedFs.access.mockRejectedValue(
Object.assign(new Error('ENOENT: no such file or directory'), {
code: 'ENOENT',
}),
);
const result = await processImports(content, basePath, true);
// Content should be preserved as-is when file doesn't exist
expect(result.content).toBe(content);
// No error should be logged for ENOENT
expect(console.error).not.toHaveBeenCalled();
});
it('should log error for non-ENOENT file access errors', async () => {
const content = 'Content @./permission-denied.md more content';
const basePath = testPath('test', 'path');
// Mock a permission denied error (not ENOENT)
mockedFs.access.mockRejectedValue(
Object.assign(new Error('Permission denied'), { code: 'EACCES' }),
);
const result = await processImports(content, basePath, true);
// Should show error comment for non-ENOENT errors
expect(result.content).toContain(
'<!-- Import failed: ./nonexistent.md - File not found -->',
'<!-- Import failed: ./permission-denied.md - Permission denied -->',
);
expect(console.error).toHaveBeenCalledWith(
'[ERROR] [ImportProcessor]',
'Failed to import ./nonexistent.md: File not found',
'Failed to import ./permission-denied.md: Permission denied',
);
});
@@ -448,6 +475,50 @@ describe('memoryImportProcessor', () => {
expect(result.importTree.imports).toBeUndefined();
});
it('should still import valid paths while ignoring non-existent paths', async () => {
const content = '使用 @./valid.md 文件和 @中文路径 注解';
const basePath = testPath('test', 'path');
const importedContent = 'Valid imported content';
// Mock: valid.md exists, 中文路径 doesn't exist
mockedFs.access
.mockResolvedValueOnce(undefined) // ./valid.md exists
.mockRejectedValueOnce(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
); // 中文路径 doesn't exist
mockedFs.readFile.mockResolvedValue(importedContent);
const result = await processImports(content, basePath, true);
// Should import valid.md
expect(result.content).toContain(importedContent);
expect(result.content).toContain('<!-- Imported from: ./valid.md -->');
// The non-existent path should remain as-is
expect(result.content).toContain('@中文路径');
});
it('should import Chinese file names if they exist', async () => {
const content = '导入 @./中文文档.md 文件';
const projectRoot = testPath('test', 'project');
const basePath = testPath(projectRoot, 'src');
const importedContent = '这是中文文档的内容';
mockedFs.access.mockResolvedValue(undefined);
mockedFs.readFile.mockResolvedValue(importedContent);
const result = await processImports(
content,
basePath,
true,
undefined,
projectRoot,
);
// Should successfully import the Chinese-named file
expect(result.content).toContain(importedContent);
expect(result.content).toContain('<!-- Imported from: ./中文文档.md -->');
});
it('should allow imports from parent and subdirectories within project root', async () => {
const content =
'Parent import: @../parent.md Subdir import: @./components/sub.md';

View File

@@ -150,6 +150,18 @@ function isLetter(char: string): boolean {
); // a-z
}
/**
* Checks if an error is a "file not found" error (ENOENT)
*/
function isFileNotFoundError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
(err as { code: unknown }).code === 'ENOENT'
);
}
function findCodeRegions(content: string): Array<[number, number]> {
const regions: Array<[number, number]> = [];
const tokens = marked.lexer(content);
@@ -292,7 +304,9 @@ export async function processImports(
depth + 1,
);
} catch (error) {
if (debugMode) {
// If file doesn't exist, silently skip this import (it's not a real import)
// Only log warnings for other types of errors
if (!isFileNotFoundError(error) && debugMode) {
logger.warn(
`Failed to import ${fullPath}: ${hasMessage(error) ? error.message : 'Unknown error'}`,
);
@@ -371,6 +385,12 @@ export async function processImports(
result += `<!-- Imported from: ${importPath} -->\n${imported.content}\n<!-- End of import from: ${importPath} -->`;
imports.push(imported.importTree);
} catch (err: unknown) {
// If file doesn't exist, preserve the original @path text (it's not a real import)
if (isFileNotFoundError(err)) {
result += `@${importPath}`;
continue;
}
// For other errors, log and add error comment
let message = 'Unknown error';
if (hasMessage(err)) {
message = err.message;

View File

@@ -4,37 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
export { DefaultRequestTokenizer } from './requestTokenizer.js';
import { DefaultRequestTokenizer } from './requestTokenizer.js';
export { RequestTokenizer as RequestTokenEstimator } from './requestTokenizer.js';
export { TextTokenizer } from './textTokenizer.js';
export { ImageTokenizer } from './imageTokenizer.js';
export type {
RequestTokenizer,
TokenizerConfig,
TokenCalculationResult,
ImageMetadata,
} from './types.js';
// Singleton instance for convenient usage
let defaultTokenizer: DefaultRequestTokenizer | null = null;
/**
* Get the default request tokenizer instance
*/
export function getDefaultTokenizer(): DefaultRequestTokenizer {
if (!defaultTokenizer) {
defaultTokenizer = new DefaultRequestTokenizer();
}
return defaultTokenizer;
}
/**
* Dispose of the default tokenizer instance
*/
export async function disposeDefaultTokenizer(): Promise<void> {
if (defaultTokenizer) {
await defaultTokenizer.dispose();
defaultTokenizer = null;
}
}
export type { TokenCalculationResult, ImageMetadata } from './types.js';

View File

@@ -4,19 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { DefaultRequestTokenizer } from './requestTokenizer.js';
import { describe, it, expect, beforeEach } from 'vitest';
import { RequestTokenizer } from './requestTokenizer.js';
import type { CountTokensParameters } from '@google/genai';
describe('DefaultRequestTokenizer', () => {
let tokenizer: DefaultRequestTokenizer;
describe('RequestTokenEstimator', () => {
let tokenizer: RequestTokenizer;
beforeEach(() => {
tokenizer = new DefaultRequestTokenizer();
});
afterEach(async () => {
await tokenizer.dispose();
tokenizer = new RequestTokenizer();
});
describe('text token calculation', () => {
@@ -221,25 +217,7 @@ describe('DefaultRequestTokenizer', () => {
});
});
describe('configuration', () => {
it('should use custom text encoding', async () => {
const request: CountTokensParameters = {
model: 'test-model',
contents: [
{
role: 'user',
parts: [{ text: 'Test text for encoding' }],
},
],
};
const result = await tokenizer.calculateTokens(request, {
textEncoding: 'cl100k_base',
});
expect(result.totalTokens).toBeGreaterThan(0);
});
describe('images', () => {
it('should process multiple images serially', async () => {
const pngBase64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU77yQAAAABJRU5ErkJggg==';

View File

@@ -10,18 +10,14 @@ import type {
Part,
PartUnion,
} from '@google/genai';
import type {
RequestTokenizer,
TokenizerConfig,
TokenCalculationResult,
} from './types.js';
import type { TokenCalculationResult } from './types.js';
import { TextTokenizer } from './textTokenizer.js';
import { ImageTokenizer } from './imageTokenizer.js';
/**
* Simple request tokenizer that handles text and image content serially
* Simple request token estimator that handles text and image content serially
*/
export class DefaultRequestTokenizer implements RequestTokenizer {
export class RequestTokenizer {
private textTokenizer: TextTokenizer;
private imageTokenizer: ImageTokenizer;
@@ -35,15 +31,9 @@ export class DefaultRequestTokenizer implements RequestTokenizer {
*/
async calculateTokens(
request: CountTokensParameters,
config: TokenizerConfig = {},
): Promise<TokenCalculationResult> {
const startTime = performance.now();
// Apply configuration
if (config.textEncoding) {
this.textTokenizer = new TextTokenizer(config.textEncoding);
}
try {
// Process request content and group by type
const { textContents, imageContents, audioContents, otherContents } =
@@ -112,9 +102,8 @@ export class DefaultRequestTokenizer implements RequestTokenizer {
if (textContents.length === 0) return 0;
try {
const tokenCounts =
await this.textTokenizer.calculateTokensBatch(textContents);
return tokenCounts.reduce((sum, count) => sum + count, 0);
// Avoid per-part rounding inflation by estimating once on the combined text.
return await this.textTokenizer.calculateTokens(textContents.join(''));
} catch (error) {
console.warn('Error calculating text tokens:', error);
// Fallback: character-based estimation
@@ -177,10 +166,8 @@ export class DefaultRequestTokenizer implements RequestTokenizer {
if (otherContents.length === 0) return 0;
try {
// Treat other content as text for token calculation
const tokenCounts =
await this.textTokenizer.calculateTokensBatch(otherContents);
return tokenCounts.reduce((sum, count) => sum + count, 0);
// Treat other content as text, and avoid per-item rounding inflation.
return await this.textTokenizer.calculateTokens(otherContents.join(''));
} catch (error) {
console.warn('Error calculating other content tokens:', error);
// Fallback: character-based estimation
@@ -264,7 +251,18 @@ export class DefaultRequestTokenizer implements RequestTokenizer {
otherContents,
);
}
return;
}
// Some request shapes (e.g. CountTokensParameters) allow passing parts directly
// instead of wrapping them in a { parts: [...] } Content object.
this.processPart(
content as Part | string,
textContents,
imageContents,
audioContents,
otherContents,
);
}
/**
@@ -326,16 +324,4 @@ export class DefaultRequestTokenizer implements RequestTokenizer {
console.warn('Failed to serialize unknown part type:', error);
}
}
/**
* Dispose of resources
*/
async dispose(): Promise<void> {
try {
// Dispose of tokenizers
this.textTokenizer.dispose();
} catch (error) {
console.warn('Error disposing request tokenizer:', error);
}
}
}

View File

@@ -4,36 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { TextTokenizer } from './textTokenizer.js';
// Mock tiktoken at the top level with hoisted functions
const mockEncode = vi.hoisted(() => vi.fn());
const mockFree = vi.hoisted(() => vi.fn());
const mockGetEncoding = vi.hoisted(() => vi.fn());
vi.mock('tiktoken', () => ({
get_encoding: mockGetEncoding,
}));
describe('TextTokenizer', () => {
let tokenizer: TextTokenizer;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.resetAllMocks();
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Default mock implementation
mockGetEncoding.mockReturnValue({
encode: mockEncode,
free: mockFree,
});
});
afterEach(() => {
vi.restoreAllMocks();
tokenizer?.dispose();
tokenizer = new TextTokenizer();
});
describe('constructor', () => {
@@ -42,17 +20,14 @@ describe('TextTokenizer', () => {
expect(tokenizer).toBeInstanceOf(TextTokenizer);
});
it('should create tokenizer with custom encoding', () => {
tokenizer = new TextTokenizer('gpt2');
it('should create tokenizer with custom encoding (for backward compatibility)', () => {
tokenizer = new TextTokenizer();
expect(tokenizer).toBeInstanceOf(TextTokenizer);
// Note: encoding name is accepted but not used
});
});
describe('calculateTokens', () => {
beforeEach(() => {
tokenizer = new TextTokenizer();
});
it('should return 0 for empty text', async () => {
const result = await tokenizer.calculateTokens('');
expect(result).toBe(0);
@@ -69,99 +44,77 @@ describe('TextTokenizer', () => {
expect(result2).toBe(0);
});
it('should calculate tokens using tiktoken when available', async () => {
const testText = 'Hello, world!';
const mockTokens = [1, 2, 3, 4, 5]; // 5 tokens
mockEncode.mockReturnValue(mockTokens);
it('should calculate tokens using character-based estimation for ASCII text', async () => {
const testText = 'Hello, world!'; // 13 ASCII chars
const result = await tokenizer.calculateTokens(testText);
// 13 / 4 = 3.25 -> ceil = 4
expect(result).toBe(4);
});
expect(mockGetEncoding).toHaveBeenCalledWith('cl100k_base');
expect(mockEncode).toHaveBeenCalledWith(testText);
it('should calculate tokens for code (ASCII)', async () => {
const code = 'function test() { return 42; }'; // 30 ASCII chars
const result = await tokenizer.calculateTokens(code);
// 30 / 4 = 7.5 -> ceil = 8
expect(result).toBe(8);
});
it('should calculate tokens for non-ASCII text (CJK)', async () => {
const unicodeText = '你好世界'; // 4 non-ASCII chars
const result = await tokenizer.calculateTokens(unicodeText);
// 4 * 1.1 = 4.4 -> ceil = 5
expect(result).toBe(5);
});
it('should use fallback calculation when tiktoken fails to load', async () => {
mockGetEncoding.mockImplementation(() => {
throw new Error('Failed to load tiktoken');
});
const testText = 'Hello, world!'; // 13 characters
const result = await tokenizer.calculateTokens(testText);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to load tiktoken with encoding cl100k_base:',
expect.any(Error),
);
// Fallback: Math.ceil(13 / 4) = 4
it('should calculate tokens for mixed ASCII and non-ASCII text', async () => {
const mixedText = 'Hello 世界'; // 6 ASCII + 2 non-ASCII
const result = await tokenizer.calculateTokens(mixedText);
// (6 / 4) + (2 * 1.1) = 1.5 + 2.2 = 3.7 -> ceil = 4
expect(result).toBe(4);
});
it('should use fallback calculation when encoding fails', async () => {
mockEncode.mockImplementation(() => {
throw new Error('Encoding failed');
});
const testText = 'Hello, world!'; // 13 characters
const result = await tokenizer.calculateTokens(testText);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Error encoding text with tiktoken:',
expect.any(Error),
);
// Fallback: Math.ceil(13 / 4) = 4
expect(result).toBe(4);
it('should calculate tokens for emoji', async () => {
const emojiText = '🌍'; // 2 UTF-16 code units (non-ASCII)
const result = await tokenizer.calculateTokens(emojiText);
// 2 * 1.1 = 2.2 -> ceil = 3
expect(result).toBe(3);
});
it('should handle very long text', async () => {
const longText = 'a'.repeat(10000);
const mockTokens = new Array(2500); // 2500 tokens
mockEncode.mockReturnValue(mockTokens);
const longText = 'a'.repeat(10000); // 10000 ASCII chars
const result = await tokenizer.calculateTokens(longText);
// 10000 / 4 = 2500 -> ceil = 2500
expect(result).toBe(2500);
});
it('should handle unicode characters', async () => {
const unicodeText = '你好世界 🌍';
const mockTokens = [1, 2, 3, 4, 5, 6];
mockEncode.mockReturnValue(mockTokens);
const result = await tokenizer.calculateTokens(unicodeText);
expect(result).toBe(6);
it('should handle text with only whitespace', async () => {
const whitespaceText = ' \n\t '; // 7 ASCII chars
const result = await tokenizer.calculateTokens(whitespaceText);
// 7 / 4 = 1.75 -> ceil = 2
expect(result).toBe(2);
});
it('should use custom encoding when specified', async () => {
tokenizer = new TextTokenizer('gpt2');
const testText = 'Hello, world!';
const mockTokens = [1, 2, 3];
mockEncode.mockReturnValue(mockTokens);
it('should handle special characters and symbols', async () => {
const specialText = '!@#$%^&*()_+-=[]{}|;:,.<>?'; // 26 ASCII chars
const result = await tokenizer.calculateTokens(specialText);
// 26 / 4 = 6.5 -> ceil = 7
expect(result).toBe(7);
});
const result = await tokenizer.calculateTokens(testText);
expect(mockGetEncoding).toHaveBeenCalledWith('gpt2');
expect(result).toBe(3);
it('should handle very short text', async () => {
const result = await tokenizer.calculateTokens('a');
// 1 / 4 = 0.25 -> ceil = 1
expect(result).toBe(1);
});
});
describe('calculateTokensBatch', () => {
beforeEach(() => {
tokenizer = new TextTokenizer();
});
it('should process multiple texts and return token counts', async () => {
const texts = ['Hello', 'world', 'test'];
mockEncode
.mockReturnValueOnce([1, 2]) // 2 tokens for 'Hello'
.mockReturnValueOnce([3, 4, 5]) // 3 tokens for 'world'
.mockReturnValueOnce([6]); // 1 token for 'test'
const result = await tokenizer.calculateTokensBatch(texts);
expect(result).toEqual([2, 3, 1]);
expect(mockEncode).toHaveBeenCalledTimes(3);
// 'Hello' = 5 / 4 = 1.25 -> ceil = 2
// 'world' = 5 / 4 = 1.25 -> ceil = 2
// 'test' = 4 / 4 = 1 -> ceil = 1
expect(result).toEqual([2, 2, 1]);
});
it('should handle empty array', async () => {
@@ -171,177 +124,156 @@ describe('TextTokenizer', () => {
it('should handle array with empty strings', async () => {
const texts = ['', 'hello', ''];
mockEncode.mockReturnValue([1, 2, 3]); // Only called for 'hello'
const result = await tokenizer.calculateTokensBatch(texts);
expect(result).toEqual([0, 3, 0]);
expect(mockEncode).toHaveBeenCalledTimes(1);
expect(mockEncode).toHaveBeenCalledWith('hello');
// '' = 0
// 'hello' = 5 / 4 = 1.25 -> ceil = 2
// '' = 0
expect(result).toEqual([0, 2, 0]);
});
it('should use fallback calculation when tiktoken fails to load', async () => {
mockGetEncoding.mockImplementation(() => {
throw new Error('Failed to load tiktoken');
});
const texts = ['Hello', 'world']; // 5 and 5 characters
it('should handle mixed ASCII and non-ASCII texts', async () => {
const texts = ['Hello', '世界', 'Hello 世界'];
const result = await tokenizer.calculateTokensBatch(texts);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to load tiktoken with encoding cl100k_base:',
expect.any(Error),
);
// Fallback: Math.ceil(5/4) = 2 for both
expect(result).toEqual([2, 2]);
});
it('should use fallback calculation when encoding fails during batch processing', async () => {
mockEncode.mockImplementation(() => {
throw new Error('Encoding failed');
});
const texts = ['Hello', 'world']; // 5 and 5 characters
const result = await tokenizer.calculateTokensBatch(texts);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Error encoding texts with tiktoken:',
expect.any(Error),
);
// Fallback: Math.ceil(5/4) = 2 for both
expect(result).toEqual([2, 2]);
// 'Hello' = 5 / 4 = 1.25 -> ceil = 2
// '世界' = 2 * 1.1 = 2.2 -> ceil = 3
// 'Hello 世界' = (6/4) + (2*1.1) = 1.5 + 2.2 = 3.7 -> ceil = 4
expect(result).toEqual([2, 3, 4]);
});
it('should handle null and undefined values in batch', async () => {
const texts = [null, 'hello', undefined, 'world'] as unknown as string[];
mockEncode
.mockReturnValueOnce([1, 2, 3]) // 3 tokens for 'hello'
.mockReturnValueOnce([4, 5]); // 2 tokens for 'world'
const result = await tokenizer.calculateTokensBatch(texts);
// null = 0
// 'hello' = 5 / 4 = 1.25 -> ceil = 2
// undefined = 0
// 'world' = 5 / 4 = 1.25 -> ceil = 2
expect(result).toEqual([0, 2, 0, 2]);
});
expect(result).toEqual([0, 3, 0, 2]);
it('should process large batches efficiently', async () => {
const texts = Array.from({ length: 1000 }, (_, i) => `text${i}`);
const result = await tokenizer.calculateTokensBatch(texts);
expect(result).toHaveLength(1000);
// Verify results are reasonable
result.forEach((count) => {
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThan(10); // 'textNNN' should be less than 10 tokens
});
});
});
describe('dispose', () => {
beforeEach(() => {
tokenizer = new TextTokenizer();
describe('backward compatibility', () => {
it('should accept encoding parameter in constructor', () => {
const tokenizer1 = new TextTokenizer();
const tokenizer2 = new TextTokenizer();
const tokenizer3 = new TextTokenizer();
expect(tokenizer1).toBeInstanceOf(TextTokenizer);
expect(tokenizer2).toBeInstanceOf(TextTokenizer);
expect(tokenizer3).toBeInstanceOf(TextTokenizer);
});
it('should free tiktoken encoding when disposing', async () => {
// Initialize the encoding by calling calculateTokens
await tokenizer.calculateTokens('test');
it('should produce same results regardless of encoding parameter', async () => {
const text = 'Hello, world!';
const tokenizer1 = new TextTokenizer();
const tokenizer2 = new TextTokenizer();
const tokenizer3 = new TextTokenizer();
tokenizer.dispose();
const result1 = await tokenizer1.calculateTokens(text);
const result2 = await tokenizer2.calculateTokens(text);
const result3 = await tokenizer3.calculateTokens(text);
expect(mockFree).toHaveBeenCalled();
// All should use character-based estimation, ignoring encoding parameter
expect(result1).toBe(result2);
expect(result2).toBe(result3);
expect(result1).toBe(4); // 13 / 4 = 3.25 -> ceil = 4
});
it('should handle disposal when encoding is not initialized', () => {
expect(() => tokenizer.dispose()).not.toThrow();
expect(mockFree).not.toHaveBeenCalled();
it('should maintain async interface for calculateTokens', async () => {
const result = tokenizer.calculateTokens('test');
expect(result).toBeInstanceOf(Promise);
await expect(result).resolves.toBe(1);
});
it('should handle disposal when encoding is null', async () => {
// Force encoding to be null by making tiktoken fail
mockGetEncoding.mockImplementation(() => {
throw new Error('Failed to load');
});
await tokenizer.calculateTokens('test');
expect(() => tokenizer.dispose()).not.toThrow();
expect(mockFree).not.toHaveBeenCalled();
});
it('should handle errors during disposal gracefully', async () => {
await tokenizer.calculateTokens('test');
mockFree.mockImplementation(() => {
throw new Error('Free failed');
});
tokenizer.dispose();
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Error freeing tiktoken encoding:',
expect.any(Error),
);
});
it('should allow multiple calls to dispose', async () => {
await tokenizer.calculateTokens('test');
tokenizer.dispose();
tokenizer.dispose(); // Second call should not throw
expect(mockFree).toHaveBeenCalledTimes(1);
});
});
describe('lazy initialization', () => {
beforeEach(() => {
tokenizer = new TextTokenizer();
});
it('should not initialize tiktoken until first use', () => {
expect(mockGetEncoding).not.toHaveBeenCalled();
});
it('should initialize tiktoken on first calculateTokens call', async () => {
await tokenizer.calculateTokens('test');
expect(mockGetEncoding).toHaveBeenCalledTimes(1);
});
it('should not reinitialize tiktoken on subsequent calls', async () => {
await tokenizer.calculateTokens('test1');
await tokenizer.calculateTokens('test2');
expect(mockGetEncoding).toHaveBeenCalledTimes(1);
});
it('should initialize tiktoken on first calculateTokensBatch call', async () => {
await tokenizer.calculateTokensBatch(['test']);
expect(mockGetEncoding).toHaveBeenCalledTimes(1);
it('should maintain async interface for calculateTokensBatch', async () => {
const result = tokenizer.calculateTokensBatch(['test']);
expect(result).toBeInstanceOf(Promise);
await expect(result).resolves.toEqual([1]);
});
});
describe('edge cases', () => {
beforeEach(() => {
tokenizer = new TextTokenizer();
});
it('should handle very short text', async () => {
const result = await tokenizer.calculateTokens('a');
if (mockGetEncoding.mock.calls.length > 0) {
// If tiktoken was called, use its result
expect(mockEncode).toHaveBeenCalledWith('a');
} else {
// If tiktoken failed, should use fallback: Math.ceil(1/4) = 1
expect(result).toBe(1);
}
});
it('should handle text with only whitespace', async () => {
const whitespaceText = ' \n\t ';
const mockTokens = [1];
mockEncode.mockReturnValue(mockTokens);
const result = await tokenizer.calculateTokens(whitespaceText);
it('should handle text with only newlines', async () => {
const text = '\n\n\n'; // 3 ASCII chars
const result = await tokenizer.calculateTokens(text);
// 3 / 4 = 0.75 -> ceil = 1
expect(result).toBe(1);
});
it('should handle special characters and symbols', async () => {
const specialText = '!@#$%^&*()_+-=[]{}|;:,.<>?';
const mockTokens = new Array(10);
mockEncode.mockReturnValue(mockTokens);
it('should handle text with tabs', async () => {
const text = '\t\t\t\t'; // 4 ASCII chars
const result = await tokenizer.calculateTokens(text);
// 4 / 4 = 1 -> ceil = 1
expect(result).toBe(1);
});
const result = await tokenizer.calculateTokens(specialText);
it('should handle surrogate pairs correctly', async () => {
// Character outside BMP (Basic Multilingual Plane)
const text = '𝕳𝖊𝖑𝖑𝖔'; // Mathematical bold letters (2 UTF-16 units each)
const result = await tokenizer.calculateTokens(text);
// Each character is 2 UTF-16 units, all non-ASCII
// Total: 10 non-ASCII units
// 10 * 1.1 = 11 -> ceil = 11
expect(result).toBe(11);
});
expect(result).toBe(10);
it('should handle combining characters', async () => {
// e + combining acute accent
const text = 'e\u0301'; // 2 chars: 'e' (ASCII) + combining acute (non-ASCII)
const result = await tokenizer.calculateTokens(text);
// ASCII: 1 / 4 = 0.25
// Non-ASCII: 1 * 1.1 = 1.1
// Total: 0.25 + 1.1 = 1.35 -> ceil = 2
expect(result).toBe(2);
});
it('should handle accented characters', async () => {
const text = 'café'; // 'caf' = 3 ASCII, 'é' = 1 non-ASCII
const result = await tokenizer.calculateTokens(text);
// ASCII: 3 / 4 = 0.75
// Non-ASCII: 1 * 1.1 = 1.1
// Total: 0.75 + 1.1 = 1.85 -> ceil = 2
expect(result).toBe(2);
});
it('should handle various unicode scripts', async () => {
const cyrillic = 'Привет'; // 6 non-ASCII chars
const arabic = 'مرحبا'; // 5 non-ASCII chars
const japanese = 'こんにちは'; // 5 non-ASCII chars
const result1 = await tokenizer.calculateTokens(cyrillic);
const result2 = await tokenizer.calculateTokens(arabic);
const result3 = await tokenizer.calculateTokens(japanese);
// All should use 1.1 tokens per char
expect(result1).toBe(7); // 6 * 1.1 = 6.6 -> ceil = 7
expect(result2).toBe(6); // 5 * 1.1 = 5.5 -> ceil = 6
expect(result3).toBe(6); // 5 * 1.1 = 5.5 -> ceil = 6
});
});
describe('large inputs', () => {
it('should handle very long text', async () => {
const longText = 'a'.repeat(200000); // 200k characters
const result = await tokenizer.calculateTokens(longText);
expect(result).toBe(50000); // 200000 / 4
});
it('should handle large batches', async () => {
const texts = Array.from({ length: 5000 }, () => 'Hello, world!');
const result = await tokenizer.calculateTokensBatch(texts);
expect(result).toHaveLength(5000);
expect(result[0]).toBe(4);
});
});
});

View File

@@ -4,94 +4,55 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { TiktokenEncoding, Tiktoken } from 'tiktoken';
import { get_encoding } from 'tiktoken';
/**
* Text tokenizer for calculating text tokens using tiktoken
* Text tokenizer for calculating text tokens using character-based estimation.
*
* Uses a lightweight character-based approach that is "good enough" for
* guardrail features like sessionTokenLimit.
*
* Algorithm:
* - ASCII characters: 0.25 tokens per char (4 chars = 1 token)
* - Non-ASCII characters: 1.1 tokens per char (conservative for CJK, emoji, etc.)
*/
export class TextTokenizer {
private encoding: Tiktoken | null = null;
private encodingName: string;
constructor(encodingName: string = 'cl100k_base') {
this.encodingName = encodingName;
}
/**
* Initialize the tokenizer (lazy loading)
*/
private async ensureEncoding(): Promise<void> {
if (this.encoding) return;
try {
// Use type assertion since we know the encoding name is valid
this.encoding = get_encoding(this.encodingName as TiktokenEncoding);
} catch (error) {
console.warn(
`Failed to load tiktoken with encoding ${this.encodingName}:`,
error,
);
this.encoding = null;
}
}
/**
* Calculate tokens for text content
*
* @param text - The text to estimate tokens for
* @returns The estimated token count
*/
async calculateTokens(text: string): Promise<number> {
if (!text) return 0;
await this.ensureEncoding();
if (this.encoding) {
try {
return this.encoding.encode(text).length;
} catch (error) {
console.warn('Error encoding text with tiktoken:', error);
}
}
// Fallback: rough approximation using character count
// This is a conservative estimate: 1 token ≈ 4 characters for most languages
return Math.ceil(text.length / 4);
return this.calculateTokensSync(text);
}
/**
* Calculate tokens for multiple text strings in parallel
* Calculate tokens for multiple text strings
*
* @param texts - Array of text strings to estimate tokens for
* @returns Array of token counts corresponding to each input text
*/
async calculateTokensBatch(texts: string[]): Promise<number[]> {
await this.ensureEncoding();
if (this.encoding) {
try {
return texts.map((text) => {
if (!text) return 0;
// this.encoding may be null, add a null check to satisfy lint
return this.encoding ? this.encoding.encode(text).length : 0;
});
} catch (error) {
console.warn('Error encoding texts with tiktoken:', error);
// In case of error, return fallback estimation for all texts
return texts.map((text) => Math.ceil((text || '').length / 4));
}
}
// Fallback for batch processing
return texts.map((text) => Math.ceil((text || '').length / 4));
return texts.map((text) => this.calculateTokensSync(text));
}
/**
* Dispose of resources
*/
dispose(): void {
if (this.encoding) {
try {
this.encoding.free();
} catch (error) {
console.warn('Error freeing tiktoken encoding:', error);
}
this.encoding = null;
private calculateTokensSync(text: string): number {
if (!text || text.length === 0) {
return 0;
}
let asciiChars = 0;
let nonAsciiChars = 0;
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
if (charCode < 128) {
asciiChars++;
} else {
nonAsciiChars++;
}
}
const tokens = asciiChars / 4 + nonAsciiChars * 1.1;
return Math.ceil(tokens);
}
}

View File

@@ -4,8 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { CountTokensParameters } from '@google/genai';
/**
* Token calculation result for different content types
*/
@@ -23,14 +21,6 @@ export interface TokenCalculationResult {
processingTime: number;
}
/**
* Configuration for token calculation
*/
export interface TokenizerConfig {
/** Custom text tokenizer encoding (defaults to cl100k_base) */
textEncoding?: string;
}
/**
* Image metadata extracted from base64 data
*/
@@ -44,21 +34,3 @@ export interface ImageMetadata {
/** Size of the base64 data in bytes */
dataSize: number;
}
/**
* Request tokenizer interface
*/
export interface RequestTokenizer {
/**
* Calculate tokens for a request
*/
calculateTokens(
request: CountTokensParameters,
config?: TokenizerConfig,
): Promise<TokenCalculationResult>;
/**
* Dispose of resources (worker threads, etc.)
*/
dispose(): Promise<void>;
}

View File

@@ -46,8 +46,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",
"zod": "^3.25.0",
"tiktoken": "^1.0.21"
"zod": "^3.25.0"
},
"devDependencies": {
"@types/node": "^20.14.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,17 +98,6 @@ console.log('Creating package.json for distribution...');
const rootPackageJson = JSON.parse(
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'),
);
const corePackageJson = JSON.parse(
fs.readFileSync(
path.join(rootDir, 'packages', 'core', 'package.json'),
'utf-8',
),
);
const runtimeDependencies = {};
if (corePackageJson.dependencies?.tiktoken) {
runtimeDependencies.tiktoken = corePackageJson.dependencies.tiktoken;
}
// Create a clean package.json for the published package
const distPackageJson = {
@@ -124,7 +113,7 @@ const distPackageJson = {
},
files: ['cli.js', 'vendor', '*.sb', 'README.md', 'LICENSE', 'locales'],
config: rootPackageJson.config,
dependencies: runtimeDependencies,
dependencies: {},
optionalDependencies: {
'@lydell/node-pty': '1.1.0',
'@lydell/node-pty-darwin-arm64': '1.1.0',