mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-07 17:39:17 +00:00
Compare commits
8 Commits
fix-AbortE
...
v0.1.3-pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f913d33c8 | ||
|
|
7e827833bf | ||
|
|
45f1000dea | ||
|
|
04f0996327 | ||
|
|
d8cc0a1f04 | ||
|
|
ff8a8ac693 | ||
|
|
908ac5e1b0 | ||
|
|
ea4a7a2368 |
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3-preview.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3-preview.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
@@ -16024,7 +16024,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3-preview.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "1.16.0",
|
"@google/genai": "1.16.0",
|
||||||
"@iarna/toml": "^2.2.5",
|
"@iarna/toml": "^2.2.5",
|
||||||
@@ -16139,7 +16139,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@qwen-code/qwen-code-core",
|
"name": "@qwen-code/qwen-code-core",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3-preview.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "1.16.0",
|
"@google/genai": "1.16.0",
|
||||||
@@ -16278,7 +16278,7 @@
|
|||||||
},
|
},
|
||||||
"packages/test-utils": {
|
"packages/test-utils": {
|
||||||
"name": "@qwen-code/qwen-code-test-utils",
|
"name": "@qwen-code/qwen-code-test-utils",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3-preview.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -16290,7 +16290,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode-ide-companion": {
|
"packages/vscode-ide-companion": {
|
||||||
"name": "qwen-code-vscode-ide-companion",
|
"name": "qwen-code-vscode-ide-companion",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3-preview.0",
|
||||||
"license": "LICENSE",
|
"license": "LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3-preview.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.2"
|
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.3-preview.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "cross-env node scripts/start.js",
|
"start": "cross-env node scripts/start.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3-preview.0",
|
||||||
"description": "Qwen Code",
|
"description": "Qwen Code",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"config": {
|
"config": {
|
||||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.2"
|
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.3-preview.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "1.16.0",
|
"@google/genai": "1.16.0",
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ import {
|
|||||||
loadEnvironment,
|
loadEnvironment,
|
||||||
migrateDeprecatedSettings,
|
migrateDeprecatedSettings,
|
||||||
SettingScope,
|
SettingScope,
|
||||||
|
SETTINGS_VERSION,
|
||||||
|
SETTINGS_VERSION_KEY,
|
||||||
} from './settings.js';
|
} from './settings.js';
|
||||||
import { FatalConfigError, QWEN_DIR } from '@qwen-code/qwen-code-core';
|
import { FatalConfigError, QWEN_DIR } from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
@@ -94,6 +96,7 @@ vi.mock('fs', async (importOriginal) => {
|
|||||||
existsSync: vi.fn(),
|
existsSync: vi.fn(),
|
||||||
readFileSync: vi.fn(),
|
readFileSync: vi.fn(),
|
||||||
writeFileSync: vi.fn(),
|
writeFileSync: vi.fn(),
|
||||||
|
renameSync: vi.fn(),
|
||||||
mkdirSync: vi.fn(),
|
mkdirSync: vi.fn(),
|
||||||
realpathSync: (p: string) => p,
|
realpathSync: (p: string) => p,
|
||||||
};
|
};
|
||||||
@@ -171,11 +174,15 @@ describe('Settings Loading and Merging', () => {
|
|||||||
getSystemSettingsPath(),
|
getSystemSettingsPath(),
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
expect(settings.system.settings).toEqual(systemSettingsContent);
|
expect(settings.system.settings).toEqual({
|
||||||
|
...systemSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
});
|
||||||
expect(settings.user.settings).toEqual({});
|
expect(settings.user.settings).toEqual({});
|
||||||
expect(settings.workspace.settings).toEqual({});
|
expect(settings.workspace.settings).toEqual({});
|
||||||
expect(settings.merged).toEqual({
|
expect(settings.merged).toEqual({
|
||||||
...systemSettingsContent,
|
...systemSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,10 +214,14 @@ describe('Settings Loading and Merging', () => {
|
|||||||
expectedUserSettingsPath,
|
expectedUserSettingsPath,
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
expect(settings.user.settings).toEqual(userSettingsContent);
|
expect(settings.user.settings).toEqual({
|
||||||
|
...userSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
});
|
||||||
expect(settings.workspace.settings).toEqual({});
|
expect(settings.workspace.settings).toEqual({});
|
||||||
expect(settings.merged).toEqual({
|
expect(settings.merged).toEqual({
|
||||||
...userSettingsContent,
|
...userSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,9 +252,13 @@ describe('Settings Loading and Merging', () => {
|
|||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
expect(settings.user.settings).toEqual({});
|
expect(settings.user.settings).toEqual({});
|
||||||
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
expect(settings.workspace.settings).toEqual({
|
||||||
|
...workspaceSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
});
|
||||||
expect(settings.merged).toEqual({
|
expect(settings.merged).toEqual({
|
||||||
...workspaceSettingsContent,
|
...workspaceSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -304,10 +319,20 @@ describe('Settings Loading and Merging', () => {
|
|||||||
|
|
||||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
expect(settings.system.settings).toEqual(systemSettingsContent);
|
expect(settings.system.settings).toEqual({
|
||||||
expect(settings.user.settings).toEqual(userSettingsContent);
|
...systemSettingsContent,
|
||||||
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
});
|
||||||
|
expect(settings.user.settings).toEqual({
|
||||||
|
...userSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
});
|
||||||
|
expect(settings.workspace.settings).toEqual({
|
||||||
|
...workspaceSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
});
|
||||||
expect(settings.merged).toEqual({
|
expect(settings.merged).toEqual({
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
ui: {
|
ui: {
|
||||||
theme: 'system-theme',
|
theme: 'system-theme',
|
||||||
},
|
},
|
||||||
@@ -361,6 +386,7 @@ describe('Settings Loading and Merging', () => {
|
|||||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
expect(settings.merged).toEqual({
|
expect(settings.merged).toEqual({
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
ui: {
|
ui: {
|
||||||
theme: 'legacy-dark',
|
theme: 'legacy-dark',
|
||||||
},
|
},
|
||||||
@@ -413,6 +439,132 @@ describe('Settings Loading and Merging', () => {
|
|||||||
expect((settings.merged as TestSettings)['allowedTools']).toBeUndefined();
|
expect((settings.merged as TestSettings)['allowedTools']).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should add version field to migrated settings file', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
const legacySettingsContent = {
|
||||||
|
theme: 'dark',
|
||||||
|
model: 'qwen-coder',
|
||||||
|
};
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(legacySettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
|
// Verify that fs.writeFileSync was called with migrated settings including version
|
||||||
|
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||||
|
const writeCall = (fs.writeFileSync as Mock).mock.calls[0];
|
||||||
|
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||||
|
expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not re-migrate settings that have version field', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
const migratedSettingsContent = {
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
ui: {
|
||||||
|
theme: 'dark',
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
name: 'qwen-coder',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(migratedSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
|
// Verify that fs.renameSync and fs.writeFileSync were NOT called
|
||||||
|
// (because no migration was needed)
|
||||||
|
expect(fs.renameSync).not.toHaveBeenCalled();
|
||||||
|
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add version field to V2 settings without version and write to disk', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
// V2 format but no version field
|
||||||
|
const v2SettingsWithoutVersion = {
|
||||||
|
ui: {
|
||||||
|
theme: 'dark',
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
name: 'qwen-coder',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(v2SettingsWithoutVersion);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
|
// Verify that fs.writeFileSync was called (to add version)
|
||||||
|
// but NOT fs.renameSync (no backup needed, just adding version)
|
||||||
|
expect(fs.renameSync).not.toHaveBeenCalled();
|
||||||
|
expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const writeCall = (fs.writeFileSync as Mock).mock.calls[0];
|
||||||
|
const writtenPath = writeCall[0];
|
||||||
|
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||||
|
|
||||||
|
expect(writtenPath).toBe(USER_SETTINGS_PATH);
|
||||||
|
expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION);
|
||||||
|
expect(writtenContent.ui?.theme).toBe('dark');
|
||||||
|
expect(writtenContent.model?.name).toBe('qwen-coder');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly handle partially migrated settings without version field', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
// Edge case: model already in V2 format (object), but autoAccept in V1 format
|
||||||
|
const partiallyMigratedContent = {
|
||||||
|
model: {
|
||||||
|
name: 'qwen-coder',
|
||||||
|
},
|
||||||
|
autoAccept: false, // V1 key
|
||||||
|
};
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(partiallyMigratedContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
|
// Verify that the migrated settings preserve the model object correctly
|
||||||
|
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||||
|
const writeCall = (fs.writeFileSync as Mock).mock.calls[0];
|
||||||
|
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||||
|
|
||||||
|
// Model should remain as an object, not double-nested
|
||||||
|
expect(writtenContent.model).toEqual({ name: 'qwen-coder' });
|
||||||
|
// autoAccept should be migrated to tools.autoAccept
|
||||||
|
expect(writtenContent.tools?.autoAccept).toBe(false);
|
||||||
|
// Version field should be added
|
||||||
|
expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION);
|
||||||
|
});
|
||||||
|
|
||||||
it('should correctly merge and migrate legacy array properties from multiple scopes', () => {
|
it('should correctly merge and migrate legacy array properties from multiple scopes', () => {
|
||||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||||
const legacyUserSettings = {
|
const legacyUserSettings = {
|
||||||
@@ -515,11 +667,24 @@ describe('Settings Loading and Merging', () => {
|
|||||||
|
|
||||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
expect(settings.systemDefaults.settings).toEqual(systemDefaultsContent);
|
expect(settings.systemDefaults.settings).toEqual({
|
||||||
expect(settings.system.settings).toEqual(systemSettingsContent);
|
...systemDefaultsContent,
|
||||||
expect(settings.user.settings).toEqual(userSettingsContent);
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
});
|
||||||
|
expect(settings.system.settings).toEqual({
|
||||||
|
...systemSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
});
|
||||||
|
expect(settings.user.settings).toEqual({
|
||||||
|
...userSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
});
|
||||||
|
expect(settings.workspace.settings).toEqual({
|
||||||
|
...workspaceSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
});
|
||||||
expect(settings.merged).toEqual({
|
expect(settings.merged).toEqual({
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
context: {
|
context: {
|
||||||
fileName: 'WORKSPACE_CONTEXT.md',
|
fileName: 'WORKSPACE_CONTEXT.md',
|
||||||
includeDirectories: [
|
includeDirectories: [
|
||||||
@@ -866,8 +1031,14 @@ describe('Settings Loading and Merging', () => {
|
|||||||
|
|
||||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
expect(settings.user.settings).toEqual(userSettingsContent);
|
expect(settings.user.settings).toEqual({
|
||||||
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
...userSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
});
|
||||||
|
expect(settings.workspace.settings).toEqual({
|
||||||
|
...workspaceSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
});
|
||||||
expect(settings.merged.mcpServers).toEqual({
|
expect(settings.merged.mcpServers).toEqual({
|
||||||
'user-server': {
|
'user-server': {
|
||||||
command: 'user-command',
|
command: 'user-command',
|
||||||
@@ -1696,9 +1867,13 @@ describe('Settings Loading and Merging', () => {
|
|||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
expect(settings.system.path).toBe(MOCK_ENV_SYSTEM_SETTINGS_PATH);
|
expect(settings.system.path).toBe(MOCK_ENV_SYSTEM_SETTINGS_PATH);
|
||||||
expect(settings.system.settings).toEqual(systemSettingsContent);
|
expect(settings.system.settings).toEqual({
|
||||||
|
...systemSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
});
|
||||||
expect(settings.merged).toEqual({
|
expect(settings.merged).toEqual({
|
||||||
...systemSettingsContent,
|
...systemSettingsContent,
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2248,6 +2423,44 @@ describe('Settings Loading and Merging', () => {
|
|||||||
customWittyPhrases: ['test phrase'],
|
customWittyPhrases: ['test phrase'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should remove version field when migrating to V1', () => {
|
||||||
|
const v2Settings = {
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
ui: {
|
||||||
|
theme: 'dark',
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
name: 'qwen-coder',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||||
|
|
||||||
|
// Version field should not be present in V1 settings
|
||||||
|
expect(v1Settings[SETTINGS_VERSION_KEY]).toBeUndefined();
|
||||||
|
// Other fields should be properly migrated
|
||||||
|
expect(v1Settings).toEqual({
|
||||||
|
theme: 'dark',
|
||||||
|
model: 'qwen-coder',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle version field in unrecognized properties', () => {
|
||||||
|
const v2Settings = {
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
general: {
|
||||||
|
vimMode: true,
|
||||||
|
},
|
||||||
|
someUnrecognizedKey: 'value',
|
||||||
|
};
|
||||||
|
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||||
|
|
||||||
|
// Version field should be filtered out
|
||||||
|
expect(v1Settings[SETTINGS_VERSION_KEY]).toBeUndefined();
|
||||||
|
// Unrecognized keys should be preserved
|
||||||
|
expect(v1Settings['someUnrecognizedKey']).toBe('value');
|
||||||
|
expect(v1Settings['vimMode']).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loadEnvironment', () => {
|
describe('loadEnvironment', () => {
|
||||||
@@ -2368,6 +2581,73 @@ describe('Settings Loading and Merging', () => {
|
|||||||
};
|
};
|
||||||
expect(needsMigration(settings)).toBe(false);
|
expect(needsMigration(settings)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('with version field', () => {
|
||||||
|
it('should return false when version field indicates current or newer version', () => {
|
||||||
|
const settingsWithVersion = {
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
theme: 'dark', // Even though this is a V1 key, version field takes precedence
|
||||||
|
};
|
||||||
|
expect(needsMigration(settingsWithVersion)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when version field indicates a newer version', () => {
|
||||||
|
const settingsWithNewerVersion = {
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION + 1,
|
||||||
|
theme: 'dark',
|
||||||
|
};
|
||||||
|
expect(needsMigration(settingsWithNewerVersion)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when version field indicates an older version', () => {
|
||||||
|
const settingsWithOldVersion = {
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION - 1,
|
||||||
|
theme: 'dark',
|
||||||
|
};
|
||||||
|
expect(needsMigration(settingsWithOldVersion)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use fallback logic when version field is not a number', () => {
|
||||||
|
const settingsWithInvalidVersion = {
|
||||||
|
[SETTINGS_VERSION_KEY]: 'not-a-number',
|
||||||
|
theme: 'dark',
|
||||||
|
};
|
||||||
|
expect(needsMigration(settingsWithInvalidVersion)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use fallback logic when version field is missing', () => {
|
||||||
|
const settingsWithoutVersion = {
|
||||||
|
theme: 'dark',
|
||||||
|
};
|
||||||
|
expect(needsMigration(settingsWithoutVersion)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge case: partially migrated settings', () => {
|
||||||
|
it('should return true for partially migrated settings without version field', () => {
|
||||||
|
// This simulates the dangerous edge case: model already in V2 format,
|
||||||
|
// but other fields in V1 format
|
||||||
|
const partiallyMigrated = {
|
||||||
|
model: {
|
||||||
|
name: 'qwen-coder',
|
||||||
|
},
|
||||||
|
autoAccept: false, // V1 key
|
||||||
|
};
|
||||||
|
expect(needsMigration(partiallyMigrated)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for partially migrated settings WITH version field', () => {
|
||||||
|
// With version field, we trust that it's been properly migrated
|
||||||
|
const partiallyMigratedWithVersion = {
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
model: {
|
||||||
|
name: 'qwen-coder',
|
||||||
|
},
|
||||||
|
autoAccept: false, // This would look like V1 but version says it's V2
|
||||||
|
};
|
||||||
|
expect(needsMigration(partiallyMigratedWithVersion)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('migrateDeprecatedSettings', () => {
|
describe('migrateDeprecatedSettings', () => {
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
|||||||
|
|
||||||
const MIGRATE_V2_OVERWRITE = true;
|
const MIGRATE_V2_OVERWRITE = true;
|
||||||
|
|
||||||
|
// Settings version to track migration state
|
||||||
|
export const SETTINGS_VERSION = 2;
|
||||||
|
export const SETTINGS_VERSION_KEY = '$version';
|
||||||
|
|
||||||
const MIGRATION_MAP: Record<string, string> = {
|
const MIGRATION_MAP: Record<string, string> = {
|
||||||
accessibility: 'ui.accessibility',
|
accessibility: 'ui.accessibility',
|
||||||
allowedTools: 'tools.allowed',
|
allowedTools: 'tools.allowed',
|
||||||
@@ -216,8 +220,16 @@ function setNestedProperty(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function needsMigration(settings: Record<string, unknown>): boolean {
|
export function needsMigration(settings: Record<string, unknown>): boolean {
|
||||||
// A file needs migration if it contains any top-level key that is moved to a
|
// Check version field first - if present and matches current version, no migration needed
|
||||||
// nested location in V2.
|
if (SETTINGS_VERSION_KEY in settings) {
|
||||||
|
const version = settings[SETTINGS_VERSION_KEY];
|
||||||
|
if (typeof version === 'number' && version >= SETTINGS_VERSION) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to legacy detection: A file needs migration if it contains any
|
||||||
|
// top-level key that is moved to a nested location in V2.
|
||||||
const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => {
|
const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => {
|
||||||
if (v1Key === v2Path || !(v1Key in settings)) {
|
if (v1Key === v2Path || !(v1Key in settings)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -250,6 +262,21 @@ function migrateSettingsToV2(
|
|||||||
|
|
||||||
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
|
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
|
||||||
if (flatKeys.has(oldKey)) {
|
if (flatKeys.has(oldKey)) {
|
||||||
|
// Safety check: If this key is a V2 container (like 'model') and it's
|
||||||
|
// already an object, it's likely already in V2 format. Skip migration
|
||||||
|
// to prevent double-nesting (e.g., model.name.name).
|
||||||
|
if (
|
||||||
|
KNOWN_V2_CONTAINERS.has(oldKey) &&
|
||||||
|
typeof flatSettings[oldKey] === 'object' &&
|
||||||
|
flatSettings[oldKey] !== null &&
|
||||||
|
!Array.isArray(flatSettings[oldKey])
|
||||||
|
) {
|
||||||
|
// This is already a V2 container, carry it over as-is
|
||||||
|
v2Settings[oldKey] = flatSettings[oldKey];
|
||||||
|
flatKeys.delete(oldKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
|
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
|
||||||
flatKeys.delete(oldKey);
|
flatKeys.delete(oldKey);
|
||||||
}
|
}
|
||||||
@@ -287,6 +314,9 @@ function migrateSettingsToV2(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set version field to indicate this is a V2 settings file
|
||||||
|
v2Settings[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||||
|
|
||||||
return v2Settings;
|
return v2Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +366,11 @@ export function migrateSettingsToV1(
|
|||||||
|
|
||||||
// Carry over any unrecognized keys
|
// Carry over any unrecognized keys
|
||||||
for (const remainingKey of v2Keys) {
|
for (const remainingKey of v2Keys) {
|
||||||
|
// Skip the version field - it's only for V2 format
|
||||||
|
if (remainingKey === SETTINGS_VERSION_KEY) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const value = v2Settings[remainingKey];
|
const value = v2Settings[remainingKey];
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
continue;
|
continue;
|
||||||
@@ -621,6 +656,22 @@ export function loadSettings(
|
|||||||
}
|
}
|
||||||
settingsObject = migratedSettings;
|
settingsObject = migratedSettings;
|
||||||
}
|
}
|
||||||
|
} else if (!(SETTINGS_VERSION_KEY in settingsObject)) {
|
||||||
|
// No migration needed, but version field is missing - add it for future optimizations
|
||||||
|
settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||||
|
if (MIGRATE_V2_OVERWRITE) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify(settingsObject, null, 2),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Error adding version to settings file: ${getErrorMessage(e)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { settings: settingsObject as Settings, rawJson: content };
|
return { settings: settingsObject as Settings, rawJson: content };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -916,17 +916,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
(result: IdeIntegrationNudgeResult) => {
|
(result: IdeIntegrationNudgeResult) => {
|
||||||
if (result.userSelection === 'yes') {
|
if (result.userSelection === 'yes') {
|
||||||
handleSlashCommand('/ide install');
|
handleSlashCommand('/ide install');
|
||||||
settings.setValue(
|
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||||
SettingScope.User,
|
|
||||||
'hasSeenIdeIntegrationNudge',
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
} else if (result.userSelection === 'dismiss') {
|
} else if (result.userSelection === 'dismiss') {
|
||||||
settings.setValue(
|
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||||
SettingScope.User,
|
|
||||||
'hasSeenIdeIntegrationNudge',
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
setIdePromptAnswered(true);
|
setIdePromptAnswered(true);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code-core",
|
"name": "@qwen-code/qwen-code-core",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3-preview.0",
|
||||||
"description": "Qwen Code Core",
|
"description": "Qwen Code Core",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ import {
|
|||||||
|
|
||||||
import type { Content, GenerateContentResponse, Part } from '@google/genai';
|
import type { Content, GenerateContentResponse, Part } from '@google/genai';
|
||||||
import {
|
import {
|
||||||
findCompressSplitPoint,
|
|
||||||
isThinkingDefault,
|
isThinkingDefault,
|
||||||
isThinkingSupported,
|
isThinkingSupported,
|
||||||
GeminiClient,
|
GeminiClient,
|
||||||
} from './client.js';
|
} from './client.js';
|
||||||
|
import { findCompressSplitPoint } from '../services/chatCompressionService.js';
|
||||||
import {
|
import {
|
||||||
AuthType,
|
AuthType,
|
||||||
type ContentGenerator,
|
type ContentGenerator,
|
||||||
@@ -42,7 +42,6 @@ import { setSimulate429 } from '../utils/testUtils.js';
|
|||||||
import { tokenLimit } from './tokenLimits.js';
|
import { tokenLimit } from './tokenLimits.js';
|
||||||
import { ideContextStore } from '../ide/ideContext.js';
|
import { ideContextStore } from '../ide/ideContext.js';
|
||||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||||
import { QwenLogger } from '../telemetry/index.js';
|
|
||||||
|
|
||||||
// Mock fs module to prevent actual file system operations during tests
|
// Mock fs module to prevent actual file system operations during tests
|
||||||
const mockFileSystem = new Map<string, string>();
|
const mockFileSystem = new Map<string, string>();
|
||||||
@@ -101,6 +100,22 @@ vi.mock('../utils/errorReporting', () => ({ reportError: vi.fn() }));
|
|||||||
vi.mock('../utils/nextSpeakerChecker', () => ({
|
vi.mock('../utils/nextSpeakerChecker', () => ({
|
||||||
checkNextSpeaker: vi.fn().mockResolvedValue(null),
|
checkNextSpeaker: vi.fn().mockResolvedValue(null),
|
||||||
}));
|
}));
|
||||||
|
vi.mock('../utils/environmentContext', () => ({
|
||||||
|
getEnvironmentContext: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ text: 'Mocked env context' }]),
|
||||||
|
getInitialChatHistory: vi.fn(async (_config, extraHistory) => [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'Mocked env context' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'Got it. Thanks for the context!' }],
|
||||||
|
},
|
||||||
|
...(extraHistory ?? []),
|
||||||
|
]),
|
||||||
|
}));
|
||||||
vi.mock('../utils/generateContentResponseUtilities', () => ({
|
vi.mock('../utils/generateContentResponseUtilities', () => ({
|
||||||
getResponseText: (result: GenerateContentResponse) =>
|
getResponseText: (result: GenerateContentResponse) =>
|
||||||
result.candidates?.[0]?.content?.parts?.map((part) => part.text).join('') ||
|
result.candidates?.[0]?.content?.parts?.map((part) => part.text).join('') ||
|
||||||
@@ -136,6 +151,10 @@ vi.mock('../ide/ideContext.js');
|
|||||||
vi.mock('../telemetry/uiTelemetry.js', () => ({
|
vi.mock('../telemetry/uiTelemetry.js', () => ({
|
||||||
uiTelemetryService: mockUiTelemetryService,
|
uiTelemetryService: mockUiTelemetryService,
|
||||||
}));
|
}));
|
||||||
|
vi.mock('../telemetry/loggers.js', () => ({
|
||||||
|
logChatCompression: vi.fn(),
|
||||||
|
logNextSpeakerCheck: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Array.fromAsync ponyfill, which will be available in es 2024.
|
* Array.fromAsync ponyfill, which will be available in es 2024.
|
||||||
@@ -619,7 +638,8 @@ describe('Gemini Client (client.ts)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('logs a telemetry event when compressing', async () => {
|
it('logs a telemetry event when compressing', async () => {
|
||||||
vi.spyOn(QwenLogger.prototype, 'logChatCompressionEvent');
|
const { logChatCompression } = await import('../telemetry/loggers.js');
|
||||||
|
vi.mocked(logChatCompression).mockClear();
|
||||||
|
|
||||||
const MOCKED_TOKEN_LIMIT = 1000;
|
const MOCKED_TOKEN_LIMIT = 1000;
|
||||||
const MOCKED_CONTEXT_PERCENTAGE_THRESHOLD = 0.5;
|
const MOCKED_CONTEXT_PERCENTAGE_THRESHOLD = 0.5;
|
||||||
@@ -627,19 +647,37 @@ describe('Gemini Client (client.ts)', () => {
|
|||||||
vi.spyOn(client['config'], 'getChatCompression').mockReturnValue({
|
vi.spyOn(client['config'], 'getChatCompression').mockReturnValue({
|
||||||
contextPercentageThreshold: MOCKED_CONTEXT_PERCENTAGE_THRESHOLD,
|
contextPercentageThreshold: MOCKED_CONTEXT_PERCENTAGE_THRESHOLD,
|
||||||
});
|
});
|
||||||
const history = [{ role: 'user', parts: [{ text: '...history...' }] }];
|
// Need multiple history items so there's something to compress
|
||||||
|
const history = [
|
||||||
|
{ role: 'user', parts: [{ text: '...history 1...' }] },
|
||||||
|
{ role: 'model', parts: [{ text: '...history 2...' }] },
|
||||||
|
{ role: 'user', parts: [{ text: '...history 3...' }] },
|
||||||
|
{ role: 'model', parts: [{ text: '...history 4...' }] },
|
||||||
|
];
|
||||||
mockGetHistory.mockReturnValue(history);
|
mockGetHistory.mockReturnValue(history);
|
||||||
|
|
||||||
|
// Token count needs to be ABOVE the threshold to trigger compression
|
||||||
const originalTokenCount =
|
const originalTokenCount =
|
||||||
MOCKED_TOKEN_LIMIT * MOCKED_CONTEXT_PERCENTAGE_THRESHOLD;
|
MOCKED_TOKEN_LIMIT * MOCKED_CONTEXT_PERCENTAGE_THRESHOLD + 1;
|
||||||
|
|
||||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||||
originalTokenCount,
|
originalTokenCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
// We need to control the estimated new token count.
|
// Mock the summary response from the chat
|
||||||
// We mock startChat to return a chat with a known history.
|
|
||||||
const summaryText = 'This is a summary.';
|
const summaryText = 'This is a summary.';
|
||||||
|
mockGenerateContentFn.mockResolvedValue({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: summaryText }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as GenerateContentResponse);
|
||||||
|
|
||||||
|
// Mock startChat to complete the compression flow
|
||||||
const splitPoint = findCompressSplitPoint(history, 0.7);
|
const splitPoint = findCompressSplitPoint(history, 0.7);
|
||||||
const historyToKeep = history.slice(splitPoint);
|
const historyToKeep = history.slice(splitPoint);
|
||||||
const newCompressedHistory: Content[] = [
|
const newCompressedHistory: Content[] = [
|
||||||
@@ -659,52 +697,36 @@ describe('Gemini Client (client.ts)', () => {
|
|||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue(mockNewChat as GeminiChat);
|
.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
|
|
||||||
mockGenerateContentFn.mockResolvedValue({
|
|
||||||
candidates: [
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
role: 'model',
|
|
||||||
parts: [{ text: summaryText }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as unknown as GenerateContentResponse);
|
|
||||||
|
|
||||||
await client.tryCompressChat('prompt-id-3', false);
|
await client.tryCompressChat('prompt-id-3', false);
|
||||||
|
|
||||||
expect(QwenLogger.prototype.logChatCompressionEvent).toHaveBeenCalledWith(
|
expect(logChatCompression).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
tokens_before: originalTokenCount,
|
tokens_before: originalTokenCount,
|
||||||
tokens_after: newTokenCount,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(
|
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalled();
|
||||||
newTokenCount,
|
|
||||||
);
|
|
||||||
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should trigger summarization if token count is at threshold with contextPercentageThreshold setting', async () => {
|
it('should trigger summarization if token count is above threshold with contextPercentageThreshold setting', async () => {
|
||||||
const MOCKED_TOKEN_LIMIT = 1000;
|
const MOCKED_TOKEN_LIMIT = 1000;
|
||||||
const MOCKED_CONTEXT_PERCENTAGE_THRESHOLD = 0.5;
|
const MOCKED_CONTEXT_PERCENTAGE_THRESHOLD = 0.5;
|
||||||
vi.mocked(tokenLimit).mockReturnValue(MOCKED_TOKEN_LIMIT);
|
vi.mocked(tokenLimit).mockReturnValue(MOCKED_TOKEN_LIMIT);
|
||||||
vi.spyOn(client['config'], 'getChatCompression').mockReturnValue({
|
vi.spyOn(client['config'], 'getChatCompression').mockReturnValue({
|
||||||
contextPercentageThreshold: MOCKED_CONTEXT_PERCENTAGE_THRESHOLD,
|
contextPercentageThreshold: MOCKED_CONTEXT_PERCENTAGE_THRESHOLD,
|
||||||
});
|
});
|
||||||
const history = [{ role: 'user', parts: [{ text: '...history...' }] }];
|
// Need multiple history items so there's something to compress
|
||||||
|
const history = [
|
||||||
|
{ role: 'user', parts: [{ text: '...history 1...' }] },
|
||||||
|
{ role: 'model', parts: [{ text: '...history 2...' }] },
|
||||||
|
{ role: 'user', parts: [{ text: '...history 3...' }] },
|
||||||
|
{ role: 'model', parts: [{ text: '...history 4...' }] },
|
||||||
|
];
|
||||||
mockGetHistory.mockReturnValue(history);
|
mockGetHistory.mockReturnValue(history);
|
||||||
|
|
||||||
|
// Token count needs to be ABOVE the threshold to trigger compression
|
||||||
const originalTokenCount =
|
const originalTokenCount =
|
||||||
MOCKED_TOKEN_LIMIT * MOCKED_CONTEXT_PERCENTAGE_THRESHOLD;
|
MOCKED_TOKEN_LIMIT * MOCKED_CONTEXT_PERCENTAGE_THRESHOLD + 1;
|
||||||
|
|
||||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||||
originalTokenCount,
|
originalTokenCount,
|
||||||
@@ -864,7 +886,13 @@ describe('Gemini Client (client.ts)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should always trigger summarization when force is true, regardless of token count', async () => {
|
it('should always trigger summarization when force is true, regardless of token count', async () => {
|
||||||
const history = [{ role: 'user', parts: [{ text: '...history...' }] }];
|
// Need multiple history items so there's something to compress
|
||||||
|
const history = [
|
||||||
|
{ role: 'user', parts: [{ text: '...history 1...' }] },
|
||||||
|
{ role: 'model', parts: [{ text: '...history 2...' }] },
|
||||||
|
{ role: 'user', parts: [{ text: '...history 3...' }] },
|
||||||
|
{ role: 'model', parts: [{ text: '...history 4...' }] },
|
||||||
|
];
|
||||||
mockGetHistory.mockReturnValue(history);
|
mockGetHistory.mockReturnValue(history);
|
||||||
|
|
||||||
const originalTokenCount = 100; // Well below threshold, but > estimated new count
|
const originalTokenCount = 100; // Well below threshold, but > estimated new count
|
||||||
|
|||||||
@@ -25,13 +25,11 @@ import {
|
|||||||
import type { ContentGenerator } from './contentGenerator.js';
|
import type { ContentGenerator } from './contentGenerator.js';
|
||||||
import { GeminiChat } from './geminiChat.js';
|
import { GeminiChat } from './geminiChat.js';
|
||||||
import {
|
import {
|
||||||
getCompressionPrompt,
|
|
||||||
getCoreSystemPrompt,
|
getCoreSystemPrompt,
|
||||||
getCustomSystemPrompt,
|
getCustomSystemPrompt,
|
||||||
getPlanModeSystemReminder,
|
getPlanModeSystemReminder,
|
||||||
getSubagentSystemReminder,
|
getSubagentSystemReminder,
|
||||||
} from './prompts.js';
|
} from './prompts.js';
|
||||||
import { tokenLimit } from './tokenLimits.js';
|
|
||||||
import {
|
import {
|
||||||
CompressionStatus,
|
CompressionStatus,
|
||||||
GeminiEventType,
|
GeminiEventType,
|
||||||
@@ -42,6 +40,11 @@ import {
|
|||||||
|
|
||||||
// Services
|
// Services
|
||||||
import { type ChatRecordingService } from '../services/chatRecordingService.js';
|
import { type ChatRecordingService } from '../services/chatRecordingService.js';
|
||||||
|
import {
|
||||||
|
ChatCompressionService,
|
||||||
|
COMPRESSION_PRESERVE_THRESHOLD,
|
||||||
|
COMPRESSION_TOKEN_THRESHOLD,
|
||||||
|
} from '../services/chatCompressionService.js';
|
||||||
import { LoopDetectionService } from '../services/loopDetectionService.js';
|
import { LoopDetectionService } from '../services/loopDetectionService.js';
|
||||||
|
|
||||||
// Tools
|
// Tools
|
||||||
@@ -50,21 +53,18 @@ import { TaskTool } from '../tools/task.js';
|
|||||||
// Telemetry
|
// Telemetry
|
||||||
import {
|
import {
|
||||||
NextSpeakerCheckEvent,
|
NextSpeakerCheckEvent,
|
||||||
logChatCompression,
|
|
||||||
logNextSpeakerCheck,
|
logNextSpeakerCheck,
|
||||||
makeChatCompressionEvent,
|
|
||||||
uiTelemetryService,
|
|
||||||
} from '../telemetry/index.js';
|
} from '../telemetry/index.js';
|
||||||
|
|
||||||
// Utilities
|
// Utilities
|
||||||
import {
|
import {
|
||||||
getDirectoryContextString,
|
getDirectoryContextString,
|
||||||
getEnvironmentContext,
|
getInitialChatHistory,
|
||||||
} from '../utils/environmentContext.js';
|
} from '../utils/environmentContext.js';
|
||||||
import { reportError } from '../utils/errorReporting.js';
|
import { reportError } from '../utils/errorReporting.js';
|
||||||
import { getErrorMessage } from '../utils/errors.js';
|
import { getErrorMessage } from '../utils/errors.js';
|
||||||
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
|
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
|
||||||
import { flatMapTextParts, getResponseText } from '../utils/partUtils.js';
|
import { flatMapTextParts } from '../utils/partUtils.js';
|
||||||
import { retryWithBackoff } from '../utils/retry.js';
|
import { retryWithBackoff } from '../utils/retry.js';
|
||||||
|
|
||||||
// IDE integration
|
// IDE integration
|
||||||
@@ -85,68 +85,8 @@ export function isThinkingDefault(model: string) {
|
|||||||
return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO;
|
return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the index of the oldest item to keep when compressing. May return
|
|
||||||
* contents.length which indicates that everything should be compressed.
|
|
||||||
*
|
|
||||||
* Exported for testing purposes.
|
|
||||||
*/
|
|
||||||
export function findCompressSplitPoint(
|
|
||||||
contents: Content[],
|
|
||||||
fraction: number,
|
|
||||||
): number {
|
|
||||||
if (fraction <= 0 || fraction >= 1) {
|
|
||||||
throw new Error('Fraction must be between 0 and 1');
|
|
||||||
}
|
|
||||||
|
|
||||||
const charCounts = contents.map((content) => JSON.stringify(content).length);
|
|
||||||
const totalCharCount = charCounts.reduce((a, b) => a + b, 0);
|
|
||||||
const targetCharCount = totalCharCount * fraction;
|
|
||||||
|
|
||||||
let lastSplitPoint = 0; // 0 is always valid (compress nothing)
|
|
||||||
let cumulativeCharCount = 0;
|
|
||||||
for (let i = 0; i < contents.length; i++) {
|
|
||||||
const content = contents[i];
|
|
||||||
if (
|
|
||||||
content.role === 'user' &&
|
|
||||||
!content.parts?.some((part) => !!part.functionResponse)
|
|
||||||
) {
|
|
||||||
if (cumulativeCharCount >= targetCharCount) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
lastSplitPoint = i;
|
|
||||||
}
|
|
||||||
cumulativeCharCount += charCounts[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// We found no split points after targetCharCount.
|
|
||||||
// Check if it's safe to compress everything.
|
|
||||||
const lastContent = contents[contents.length - 1];
|
|
||||||
if (
|
|
||||||
lastContent?.role === 'model' &&
|
|
||||||
!lastContent?.parts?.some((part) => part.functionCall)
|
|
||||||
) {
|
|
||||||
return contents.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Can't compress everything so just compress at last splitpoint.
|
|
||||||
return lastSplitPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_TURNS = 100;
|
const MAX_TURNS = 100;
|
||||||
|
|
||||||
/**
|
|
||||||
* Threshold for compression token count as a fraction of the model's token limit.
|
|
||||||
* If the chat history exceeds this threshold, it will be compressed.
|
|
||||||
*/
|
|
||||||
const COMPRESSION_TOKEN_THRESHOLD = 0.7;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The fraction of the latest chat history to keep. A value of 0.3
|
|
||||||
* means that only the last 30% of the chat history will be kept after compression.
|
|
||||||
*/
|
|
||||||
const COMPRESSION_PRESERVE_THRESHOLD = 0.3;
|
|
||||||
|
|
||||||
export class GeminiClient {
|
export class GeminiClient {
|
||||||
private chat?: GeminiChat;
|
private chat?: GeminiChat;
|
||||||
private readonly generateContentConfig: GenerateContentConfig = {
|
private readonly generateContentConfig: GenerateContentConfig = {
|
||||||
@@ -243,23 +183,13 @@ export class GeminiClient {
|
|||||||
async startChat(extraHistory?: Content[]): Promise<GeminiChat> {
|
async startChat(extraHistory?: Content[]): Promise<GeminiChat> {
|
||||||
this.forceFullIdeContext = true;
|
this.forceFullIdeContext = true;
|
||||||
this.hasFailedCompressionAttempt = false;
|
this.hasFailedCompressionAttempt = false;
|
||||||
const envParts = await getEnvironmentContext(this.config);
|
|
||||||
|
|
||||||
const toolRegistry = this.config.getToolRegistry();
|
const toolRegistry = this.config.getToolRegistry();
|
||||||
const toolDeclarations = toolRegistry.getFunctionDeclarations();
|
const toolDeclarations = toolRegistry.getFunctionDeclarations();
|
||||||
const tools: Tool[] = [{ functionDeclarations: toolDeclarations }];
|
const tools: Tool[] = [{ functionDeclarations: toolDeclarations }];
|
||||||
|
|
||||||
const history: Content[] = [
|
const history = await getInitialChatHistory(this.config, extraHistory);
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
parts: envParts,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'model',
|
|
||||||
parts: [{ text: 'Got it. Thanks for the context!' }],
|
|
||||||
},
|
|
||||||
...(extraHistory ?? []),
|
|
||||||
];
|
|
||||||
try {
|
try {
|
||||||
const userMemory = this.config.getUserMemory();
|
const userMemory = this.config.getUserMemory();
|
||||||
const model = this.config.getModel();
|
const model = this.config.getModel();
|
||||||
@@ -503,14 +433,15 @@ export class GeminiClient {
|
|||||||
userMemory,
|
userMemory,
|
||||||
this.config.getModel(),
|
this.config.getModel(),
|
||||||
);
|
);
|
||||||
const environment = await getEnvironmentContext(this.config);
|
const initialHistory = await getInitialChatHistory(this.config);
|
||||||
|
|
||||||
// Create a mock request content to count total tokens
|
// Create a mock request content to count total tokens
|
||||||
const mockRequestContent = [
|
const mockRequestContent = [
|
||||||
{
|
{
|
||||||
role: 'system' as const,
|
role: 'system' as const,
|
||||||
parts: [{ text: systemPrompt }, ...environment],
|
parts: [{ text: systemPrompt }],
|
||||||
},
|
},
|
||||||
|
...initialHistory,
|
||||||
...currentHistory,
|
...currentHistory,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -732,127 +663,37 @@ export class GeminiClient {
|
|||||||
prompt_id: string,
|
prompt_id: string,
|
||||||
force: boolean = false,
|
force: boolean = false,
|
||||||
): Promise<ChatCompressionInfo> {
|
): Promise<ChatCompressionInfo> {
|
||||||
const model = this.config.getModel();
|
const compressionService = new ChatCompressionService();
|
||||||
|
|
||||||
const curatedHistory = this.getChat().getHistory(true);
|
const { newHistory, info } = await compressionService.compress(
|
||||||
|
this.getChat(),
|
||||||
|
prompt_id,
|
||||||
|
force,
|
||||||
|
this.config.getModel(),
|
||||||
|
this.config,
|
||||||
|
this.hasFailedCompressionAttempt,
|
||||||
|
);
|
||||||
|
|
||||||
// Regardless of `force`, don't do anything if the history is empty.
|
// Handle compression result
|
||||||
if (
|
if (info.compressionStatus === CompressionStatus.COMPRESSED) {
|
||||||
curatedHistory.length === 0 ||
|
// Success: update chat with new compressed history
|
||||||
(this.hasFailedCompressionAttempt && !force)
|
if (newHistory) {
|
||||||
|
this.chat = await this.startChat(newHistory);
|
||||||
|
this.forceFullIdeContext = true;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
info.compressionStatus ===
|
||||||
|
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT ||
|
||||||
|
info.compressionStatus ===
|
||||||
|
CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY
|
||||||
) {
|
) {
|
||||||
return {
|
// Track failed attempts (only mark as failed if not forced)
|
||||||
originalTokenCount: 0,
|
if (!force) {
|
||||||
newTokenCount: 0,
|
this.hasFailedCompressionAttempt = true;
|
||||||
compressionStatus: CompressionStatus.NOOP,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalTokenCount = uiTelemetryService.getLastPromptTokenCount();
|
|
||||||
|
|
||||||
const contextPercentageThreshold =
|
|
||||||
this.config.getChatCompression()?.contextPercentageThreshold;
|
|
||||||
|
|
||||||
// Don't compress if not forced and we are under the limit.
|
|
||||||
if (!force) {
|
|
||||||
const threshold =
|
|
||||||
contextPercentageThreshold ?? COMPRESSION_TOKEN_THRESHOLD;
|
|
||||||
if (originalTokenCount < threshold * tokenLimit(model)) {
|
|
||||||
return {
|
|
||||||
originalTokenCount,
|
|
||||||
newTokenCount: originalTokenCount,
|
|
||||||
compressionStatus: CompressionStatus.NOOP,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const splitPoint = findCompressSplitPoint(
|
return info;
|
||||||
curatedHistory,
|
|
||||||
1 - COMPRESSION_PRESERVE_THRESHOLD,
|
|
||||||
);
|
|
||||||
|
|
||||||
const historyToCompress = curatedHistory.slice(0, splitPoint);
|
|
||||||
const historyToKeep = curatedHistory.slice(splitPoint);
|
|
||||||
|
|
||||||
const summaryResponse = await this.config
|
|
||||||
.getContentGenerator()
|
|
||||||
.generateContent(
|
|
||||||
{
|
|
||||||
model,
|
|
||||||
contents: [
|
|
||||||
...historyToCompress,
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
text: 'First, reason in your scratchpad. Then, generate the <state_snapshot>.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
systemInstruction: { text: getCompressionPrompt() },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
prompt_id,
|
|
||||||
);
|
|
||||||
const summary = getResponseText(summaryResponse) ?? '';
|
|
||||||
|
|
||||||
const chat = await this.startChat([
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
parts: [{ text: summary }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'model',
|
|
||||||
parts: [{ text: 'Got it. Thanks for the additional context!' }],
|
|
||||||
},
|
|
||||||
...historyToKeep,
|
|
||||||
]);
|
|
||||||
this.forceFullIdeContext = true;
|
|
||||||
|
|
||||||
// Estimate token count 1 token ≈ 4 characters
|
|
||||||
const newTokenCount = Math.floor(
|
|
||||||
chat
|
|
||||||
.getHistory()
|
|
||||||
.reduce((total, content) => total + JSON.stringify(content).length, 0) /
|
|
||||||
4,
|
|
||||||
);
|
|
||||||
|
|
||||||
logChatCompression(
|
|
||||||
this.config,
|
|
||||||
makeChatCompressionEvent({
|
|
||||||
tokens_before: originalTokenCount,
|
|
||||||
tokens_after: newTokenCount,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newTokenCount > originalTokenCount) {
|
|
||||||
this.hasFailedCompressionAttempt = !force && true;
|
|
||||||
return {
|
|
||||||
originalTokenCount,
|
|
||||||
newTokenCount,
|
|
||||||
compressionStatus:
|
|
||||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this.chat = chat; // Chat compression successful, set new state.
|
|
||||||
uiTelemetryService.setLastPromptTokenCount(newTokenCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
logChatCompression(
|
|
||||||
this.config,
|
|
||||||
makeChatCompressionEvent({
|
|
||||||
tokens_before: originalTokenCount,
|
|
||||||
tokens_after: newTokenCount,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
originalTokenCount,
|
|
||||||
newTokenCount,
|
|
||||||
compressionStatus: CompressionStatus.COMPRESSED,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ export enum CompressionStatus {
|
|||||||
/** The compression failed due to an error counting tokens */
|
/** The compression failed due to an error counting tokens */
|
||||||
COMPRESSION_FAILED_TOKEN_COUNT_ERROR,
|
COMPRESSION_FAILED_TOKEN_COUNT_ERROR,
|
||||||
|
|
||||||
|
/** The compression failed due to receiving an empty or null summary */
|
||||||
|
COMPRESSION_FAILED_EMPTY_SUMMARY,
|
||||||
|
|
||||||
/** The compression was not necessary and no action was taken */
|
/** The compression was not necessary and no action was taken */
|
||||||
NOOP,
|
NOOP,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ describe('IdeClient', () => {
|
|||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||||
new URL('http://localhost:8080/mcp'),
|
new URL('http://127.0.0.1:8080/mcp'),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
|
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
|
||||||
@@ -181,7 +181,7 @@ describe('IdeClient', () => {
|
|||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||||
new URL('http://localhost:9090/mcp'),
|
new URL('http://127.0.0.1:9090/mcp'),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
|
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
|
||||||
@@ -230,7 +230,7 @@ describe('IdeClient', () => {
|
|||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||||
new URL('http://localhost:8080/mcp'),
|
new URL('http://127.0.0.1:8080/mcp'),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
expect(ideClient.getConnectionStatus().status).toBe(
|
expect(ideClient.getConnectionStatus().status).toBe(
|
||||||
@@ -665,7 +665,7 @@ describe('IdeClient', () => {
|
|||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||||
new URL('http://localhost:8080/mcp'),
|
new URL('http://127.0.0.1:8080/mcp'),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
requestInit: {
|
requestInit: {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -667,10 +667,10 @@ export class IdeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createProxyAwareFetch() {
|
private createProxyAwareFetch() {
|
||||||
// ignore proxy for 'localhost' by deafult to allow connecting to the ide mcp server
|
// ignore proxy for '127.0.0.1' by deafult to allow connecting to the ide mcp server
|
||||||
const existingNoProxy = process.env['NO_PROXY'] || '';
|
const existingNoProxy = process.env['NO_PROXY'] || '';
|
||||||
const agent = new EnvHttpProxyAgent({
|
const agent = new EnvHttpProxyAgent({
|
||||||
noProxy: [existingNoProxy, 'localhost'].filter(Boolean).join(','),
|
noProxy: [existingNoProxy, '127.0.0.1'].filter(Boolean).join(','),
|
||||||
});
|
});
|
||||||
const undiciPromise = import('undici');
|
const undiciPromise = import('undici');
|
||||||
return async (url: string | URL, init?: RequestInit): Promise<Response> => {
|
return async (url: string | URL, init?: RequestInit): Promise<Response> => {
|
||||||
@@ -851,5 +851,5 @@ export class IdeClient {
|
|||||||
function getIdeServerHost() {
|
function getIdeServerHost() {
|
||||||
const isInContainer =
|
const isInContainer =
|
||||||
fs.existsSync('/.dockerenv') || fs.existsSync('/run/.containerenv');
|
fs.existsSync('/.dockerenv') || fs.existsSync('/run/.containerenv');
|
||||||
return isInContainer ? 'host.docker.internal' : 'localhost';
|
return isInContainer ? 'host.docker.internal' : '127.0.0.1';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,14 +112,19 @@ describe('ide-installer', () => {
|
|||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
});
|
});
|
||||||
await installer.install();
|
await installer.install();
|
||||||
|
|
||||||
|
// Note: The implementation uses process.platform, not the mocked platform
|
||||||
|
const isActuallyWindows = process.platform === 'win32';
|
||||||
|
const expectedCommand = isActuallyWindows ? '"code"' : 'code';
|
||||||
|
|
||||||
expect(child_process.spawnSync).toHaveBeenCalledWith(
|
expect(child_process.spawnSync).toHaveBeenCalledWith(
|
||||||
'code',
|
expectedCommand,
|
||||||
[
|
[
|
||||||
'--install-extension',
|
'--install-extension',
|
||||||
'qwenlm.qwen-code-vscode-ide-companion',
|
'qwenlm.qwen-code-vscode-ide-companion',
|
||||||
'--force',
|
'--force',
|
||||||
],
|
],
|
||||||
{ stdio: 'pipe' },
|
{ stdio: 'pipe', shell: isActuallyWindows },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -117,15 +117,16 @@ class VsCodeInstaller implements IdeInstaller {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
try {
|
try {
|
||||||
const result = child_process.spawnSync(
|
const result = child_process.spawnSync(
|
||||||
commandPath,
|
isWindows ? `"${commandPath}"` : commandPath,
|
||||||
[
|
[
|
||||||
'--install-extension',
|
'--install-extension',
|
||||||
'qwenlm.qwen-code-vscode-ide-companion',
|
'qwenlm.qwen-code-vscode-ide-companion',
|
||||||
'--force',
|
'--force',
|
||||||
],
|
],
|
||||||
{ stdio: 'pipe' },
|
{ stdio: 'pipe', shell: isWindows },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
|
|||||||
372
packages/core/src/services/chatCompressionService.test.ts
Normal file
372
packages/core/src/services/chatCompressionService.test.ts
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
ChatCompressionService,
|
||||||
|
findCompressSplitPoint,
|
||||||
|
} from './chatCompressionService.js';
|
||||||
|
import type { Content, GenerateContentResponse } from '@google/genai';
|
||||||
|
import { CompressionStatus } from '../core/turn.js';
|
||||||
|
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', () => {
|
||||||
|
expect(() => findCompressSplitPoint([], 0)).toThrow(
|
||||||
|
'Fraction must be between 0 and 1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error for a fraction greater than or equal to 1', () => {
|
||||||
|
expect(() => findCompressSplitPoint([], 1)).toThrow(
|
||||||
|
'Fraction must be between 0 and 1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle an empty history', () => {
|
||||||
|
expect(findCompressSplitPoint([], 0.5)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a fraction in the middle', () => {
|
||||||
|
const history: Content[] = [
|
||||||
|
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%)
|
||||||
|
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%)
|
||||||
|
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%)
|
||||||
|
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%)
|
||||||
|
{ role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%)
|
||||||
|
];
|
||||||
|
expect(findCompressSplitPoint(history, 0.5)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a fraction of last index', () => {
|
||||||
|
const history: Content[] = [
|
||||||
|
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%)
|
||||||
|
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%)
|
||||||
|
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%)
|
||||||
|
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%)
|
||||||
|
{ role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%)
|
||||||
|
];
|
||||||
|
expect(findCompressSplitPoint(history, 0.9)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a fraction of after last index', () => {
|
||||||
|
const history: Content[] = [
|
||||||
|
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (24%)
|
||||||
|
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (50%)
|
||||||
|
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (74%)
|
||||||
|
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (100%)
|
||||||
|
];
|
||||||
|
expect(findCompressSplitPoint(history, 0.8)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return earlier splitpoint if no valid ones are after threshhold', () => {
|
||||||
|
const history: Content[] = [
|
||||||
|
{ role: 'user', parts: [{ text: 'This is the first message.' }] },
|
||||||
|
{ role: 'model', parts: [{ text: 'This is the second message.' }] },
|
||||||
|
{ role: 'user', parts: [{ text: 'This is the third message.' }] },
|
||||||
|
{ role: 'model', parts: [{ functionCall: { name: 'foo', args: {} } }] },
|
||||||
|
];
|
||||||
|
// Can't return 4 because the previous item has a function call.
|
||||||
|
expect(findCompressSplitPoint(history, 0.99)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a history with only one item', () => {
|
||||||
|
const historyWithEmptyParts: Content[] = [
|
||||||
|
{ role: 'user', parts: [{ text: 'Message 1' }] },
|
||||||
|
];
|
||||||
|
expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle history with weird parts', () => {
|
||||||
|
const historyWithEmptyParts: Content[] = [
|
||||||
|
{ role: 'user', parts: [{ text: 'Message 1' }] },
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ fileData: { fileUri: 'derp', mimeType: 'text/plain' } }],
|
||||||
|
},
|
||||||
|
{ role: 'user', parts: [{ text: 'Message 2' }] },
|
||||||
|
];
|
||||||
|
expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ChatCompressionService', () => {
|
||||||
|
let service: ChatCompressionService;
|
||||||
|
let mockChat: GeminiChat;
|
||||||
|
let mockConfig: Config;
|
||||||
|
const mockModel = 'gemini-pro';
|
||||||
|
const mockPromptId = 'test-prompt-id';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new ChatCompressionService();
|
||||||
|
mockChat = {
|
||||||
|
getHistory: vi.fn(),
|
||||||
|
} as unknown as GeminiChat;
|
||||||
|
mockConfig = {
|
||||||
|
getChatCompression: vi.fn(),
|
||||||
|
getContentGenerator: vi.fn(),
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||||
|
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(500);
|
||||||
|
vi.mocked(getInitialChatHistory).mockImplementation(
|
||||||
|
async (_config, extraHistory) => extraHistory || [],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return NOOP if history is empty', async () => {
|
||||||
|
vi.mocked(mockChat.getHistory).mockReturnValue([]);
|
||||||
|
const result = await service.compress(
|
||||||
|
mockChat,
|
||||||
|
mockPromptId,
|
||||||
|
false,
|
||||||
|
mockModel,
|
||||||
|
mockConfig,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
|
||||||
|
expect(result.newHistory).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return NOOP if previously failed and not forced', async () => {
|
||||||
|
vi.mocked(mockChat.getHistory).mockReturnValue([
|
||||||
|
{ role: 'user', parts: [{ text: 'hi' }] },
|
||||||
|
]);
|
||||||
|
const result = await service.compress(
|
||||||
|
mockChat,
|
||||||
|
mockPromptId,
|
||||||
|
false,
|
||||||
|
mockModel,
|
||||||
|
mockConfig,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
|
||||||
|
expect(result.newHistory).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return NOOP if under token threshold and not forced', async () => {
|
||||||
|
vi.mocked(mockChat.getHistory).mockReturnValue([
|
||||||
|
{ role: 'user', parts: [{ text: 'hi' }] },
|
||||||
|
]);
|
||||||
|
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(600);
|
||||||
|
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||||
|
// Threshold is 0.7 * 1000 = 700. 600 < 700, so NOOP.
|
||||||
|
|
||||||
|
const result = await service.compress(
|
||||||
|
mockChat,
|
||||||
|
mockPromptId,
|
||||||
|
false,
|
||||||
|
mockModel,
|
||||||
|
mockConfig,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
|
||||||
|
expect(result.newHistory).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compress if over token threshold', 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' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} 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.COMPRESSED);
|
||||||
|
expect(result.newHistory).not.toBeNull();
|
||||||
|
expect(result.newHistory![0].parts![0].text).toBe('Summary');
|
||||||
|
expect(mockGenerateContent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should force compress even if under threshold', 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(100);
|
||||||
|
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||||
|
|
||||||
|
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'Summary' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as GenerateContentResponse);
|
||||||
|
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||||
|
generateContent: mockGenerateContent,
|
||||||
|
} as unknown as ContentGenerator);
|
||||||
|
|
||||||
|
const result = await service.compress(
|
||||||
|
mockChat,
|
||||||
|
mockPromptId,
|
||||||
|
true, // forced
|
||||||
|
mockModel,
|
||||||
|
mockConfig,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
|
||||||
|
expect(result.newHistory).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return FAILED if new token count is inflated', async () => {
|
||||||
|
const history: Content[] = [
|
||||||
|
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||||
|
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||||
|
];
|
||||||
|
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||||
|
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 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as GenerateContentResponse);
|
||||||
|
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||||
|
generateContent: mockGenerateContent,
|
||||||
|
} as unknown as ContentGenerator);
|
||||||
|
|
||||||
|
const result = await service.compress(
|
||||||
|
mockChat,
|
||||||
|
mockPromptId,
|
||||||
|
true,
|
||||||
|
mockModel,
|
||||||
|
mockConfig,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.info.compressionStatus).toBe(
|
||||||
|
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||||
|
);
|
||||||
|
expect(result.newHistory).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return FAILED if summary is empty string', async () => {
|
||||||
|
const history: Content[] = [
|
||||||
|
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||||
|
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||||
|
];
|
||||||
|
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||||
|
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(100);
|
||||||
|
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||||
|
|
||||||
|
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ text: '' }], // Empty summary
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as GenerateContentResponse);
|
||||||
|
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||||
|
generateContent: mockGenerateContent,
|
||||||
|
} as unknown as ContentGenerator);
|
||||||
|
|
||||||
|
const result = await service.compress(
|
||||||
|
mockChat,
|
||||||
|
mockPromptId,
|
||||||
|
true,
|
||||||
|
mockModel,
|
||||||
|
mockConfig,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.info.compressionStatus).toBe(
|
||||||
|
CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
|
||||||
|
);
|
||||||
|
expect(result.newHistory).toBeNull();
|
||||||
|
expect(result.info.originalTokenCount).toBe(100);
|
||||||
|
expect(result.info.newTokenCount).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return FAILED if summary is only whitespace', async () => {
|
||||||
|
const history: Content[] = [
|
||||||
|
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||||
|
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||||
|
];
|
||||||
|
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||||
|
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(100);
|
||||||
|
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||||
|
|
||||||
|
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ text: ' \n\t ' }], // Only whitespace
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as GenerateContentResponse);
|
||||||
|
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||||
|
generateContent: mockGenerateContent,
|
||||||
|
} as unknown as ContentGenerator);
|
||||||
|
|
||||||
|
const result = await service.compress(
|
||||||
|
mockChat,
|
||||||
|
mockPromptId,
|
||||||
|
true,
|
||||||
|
mockModel,
|
||||||
|
mockConfig,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.info.compressionStatus).toBe(
|
||||||
|
CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
|
||||||
|
);
|
||||||
|
expect(result.newHistory).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
235
packages/core/src/services/chatCompressionService.ts
Normal file
235
packages/core/src/services/chatCompressionService.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Content } from '@google/genai';
|
||||||
|
import type { Config } from '../config/config.js';
|
||||||
|
import type { GeminiChat } from '../core/geminiChat.js';
|
||||||
|
import { type ChatCompressionInfo, CompressionStatus } from '../core/turn.js';
|
||||||
|
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||||
|
import { tokenLimit } from '../core/tokenLimits.js';
|
||||||
|
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.
|
||||||
|
* If the chat history exceeds this threshold, it will be compressed.
|
||||||
|
*/
|
||||||
|
export const COMPRESSION_TOKEN_THRESHOLD = 0.7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fraction of the latest chat history to keep. A value of 0.3
|
||||||
|
* means that only the last 30% of the chat history will be kept after compression.
|
||||||
|
*/
|
||||||
|
export const COMPRESSION_PRESERVE_THRESHOLD = 0.3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the index of the oldest item to keep when compressing. May return
|
||||||
|
* contents.length which indicates that everything should be compressed.
|
||||||
|
*
|
||||||
|
* Exported for testing purposes.
|
||||||
|
*/
|
||||||
|
export function findCompressSplitPoint(
|
||||||
|
contents: Content[],
|
||||||
|
fraction: number,
|
||||||
|
): number {
|
||||||
|
if (fraction <= 0 || fraction >= 1) {
|
||||||
|
throw new Error('Fraction must be between 0 and 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
const charCounts = contents.map((content) => JSON.stringify(content).length);
|
||||||
|
const totalCharCount = charCounts.reduce((a, b) => a + b, 0);
|
||||||
|
const targetCharCount = totalCharCount * fraction;
|
||||||
|
|
||||||
|
let lastSplitPoint = 0; // 0 is always valid (compress nothing)
|
||||||
|
let cumulativeCharCount = 0;
|
||||||
|
for (let i = 0; i < contents.length; i++) {
|
||||||
|
const content = contents[i];
|
||||||
|
if (
|
||||||
|
content.role === 'user' &&
|
||||||
|
!content.parts?.some((part) => !!part.functionResponse)
|
||||||
|
) {
|
||||||
|
if (cumulativeCharCount >= targetCharCount) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
lastSplitPoint = i;
|
||||||
|
}
|
||||||
|
cumulativeCharCount += charCounts[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found no split points after targetCharCount.
|
||||||
|
// Check if it's safe to compress everything.
|
||||||
|
const lastContent = contents[contents.length - 1];
|
||||||
|
if (
|
||||||
|
lastContent?.role === 'model' &&
|
||||||
|
!lastContent?.parts?.some((part) => part.functionCall)
|
||||||
|
) {
|
||||||
|
return contents.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't compress everything so just compress at last splitpoint.
|
||||||
|
return lastSplitPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChatCompressionService {
|
||||||
|
async compress(
|
||||||
|
chat: GeminiChat,
|
||||||
|
promptId: string,
|
||||||
|
force: boolean,
|
||||||
|
model: string,
|
||||||
|
config: Config,
|
||||||
|
hasFailedCompressionAttempt: boolean,
|
||||||
|
): Promise<{ newHistory: Content[] | null; info: ChatCompressionInfo }> {
|
||||||
|
const curatedHistory = chat.getHistory(true);
|
||||||
|
|
||||||
|
// Regardless of `force`, don't do anything if the history is empty.
|
||||||
|
if (
|
||||||
|
curatedHistory.length === 0 ||
|
||||||
|
(hasFailedCompressionAttempt && !force)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
newHistory: null,
|
||||||
|
info: {
|
||||||
|
originalTokenCount: 0,
|
||||||
|
newTokenCount: 0,
|
||||||
|
compressionStatus: CompressionStatus.NOOP,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalTokenCount = uiTelemetryService.getLastPromptTokenCount();
|
||||||
|
|
||||||
|
const contextPercentageThreshold =
|
||||||
|
config.getChatCompression()?.contextPercentageThreshold;
|
||||||
|
|
||||||
|
// Don't compress if not forced and we are under the limit.
|
||||||
|
if (!force) {
|
||||||
|
const threshold =
|
||||||
|
contextPercentageThreshold ?? COMPRESSION_TOKEN_THRESHOLD;
|
||||||
|
if (originalTokenCount < threshold * tokenLimit(model)) {
|
||||||
|
return {
|
||||||
|
newHistory: null,
|
||||||
|
info: {
|
||||||
|
originalTokenCount,
|
||||||
|
newTokenCount: originalTokenCount,
|
||||||
|
compressionStatus: CompressionStatus.NOOP,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitPoint = findCompressSplitPoint(
|
||||||
|
curatedHistory,
|
||||||
|
1 - COMPRESSION_PRESERVE_THRESHOLD,
|
||||||
|
);
|
||||||
|
|
||||||
|
const historyToCompress = curatedHistory.slice(0, splitPoint);
|
||||||
|
const historyToKeep = curatedHistory.slice(splitPoint);
|
||||||
|
|
||||||
|
if (historyToCompress.length === 0) {
|
||||||
|
return {
|
||||||
|
newHistory: null,
|
||||||
|
info: {
|
||||||
|
originalTokenCount,
|
||||||
|
newTokenCount: originalTokenCount,
|
||||||
|
compressionStatus: CompressionStatus.NOOP,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryResponse = await config.getContentGenerator().generateContent(
|
||||||
|
{
|
||||||
|
model,
|
||||||
|
contents: [
|
||||||
|
...historyToCompress,
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
text: 'First, reason in your scratchpad. Then, generate the <state_snapshot>.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
config: {
|
||||||
|
systemInstruction: getCompressionPrompt(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
promptId,
|
||||||
|
);
|
||||||
|
const summary = getResponseText(summaryResponse) ?? '';
|
||||||
|
const isSummaryEmpty = !summary || summary.trim().length === 0;
|
||||||
|
|
||||||
|
let newTokenCount = originalTokenCount;
|
||||||
|
let extraHistory: Content[] = [];
|
||||||
|
|
||||||
|
if (!isSummaryEmpty) {
|
||||||
|
extraHistory = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: summary }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'Got it. Thanks for the additional context!' }],
|
||||||
|
},
|
||||||
|
...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,
|
||||||
|
0,
|
||||||
|
) / 4,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logChatCompression(
|
||||||
|
config,
|
||||||
|
makeChatCompressionEvent({
|
||||||
|
tokens_before: originalTokenCount,
|
||||||
|
tokens_after: newTokenCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSummaryEmpty) {
|
||||||
|
return {
|
||||||
|
newHistory: null,
|
||||||
|
info: {
|
||||||
|
originalTokenCount,
|
||||||
|
newTokenCount: originalTokenCount,
|
||||||
|
compressionStatus: CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (newTokenCount > originalTokenCount) {
|
||||||
|
return {
|
||||||
|
newHistory: null,
|
||||||
|
info: {
|
||||||
|
originalTokenCount,
|
||||||
|
newTokenCount,
|
||||||
|
compressionStatus:
|
||||||
|
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
uiTelemetryService.setLastPromptTokenCount(newTokenCount);
|
||||||
|
return {
|
||||||
|
newHistory: extraHistory,
|
||||||
|
info: {
|
||||||
|
originalTokenCount,
|
||||||
|
newTokenCount,
|
||||||
|
compressionStatus: CompressionStatus.COMPRESSED,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,6 @@ import { GeminiChat } from '../core/geminiChat.js';
|
|||||||
import { executeToolCall } from '../core/nonInteractiveToolExecutor.js';
|
import { executeToolCall } from '../core/nonInteractiveToolExecutor.js';
|
||||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||||
import { type AnyDeclarativeTool } from '../tools/tools.js';
|
import { type AnyDeclarativeTool } from '../tools/tools.js';
|
||||||
import { getEnvironmentContext } from '../utils/environmentContext.js';
|
|
||||||
import { ContextState, SubAgentScope } from './subagent.js';
|
import { ContextState, SubAgentScope } from './subagent.js';
|
||||||
import type {
|
import type {
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
@@ -44,7 +43,20 @@ import { SubagentTerminateMode } from './types.js';
|
|||||||
|
|
||||||
vi.mock('../core/geminiChat.js');
|
vi.mock('../core/geminiChat.js');
|
||||||
vi.mock('../core/contentGenerator.js');
|
vi.mock('../core/contentGenerator.js');
|
||||||
vi.mock('../utils/environmentContext.js');
|
vi.mock('../utils/environmentContext.js', () => ({
|
||||||
|
getEnvironmentContext: vi.fn().mockResolvedValue([{ text: 'Env Context' }]),
|
||||||
|
getInitialChatHistory: vi.fn(async (_config, extraHistory) => [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'Env Context' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'Got it. Thanks for the context!' }],
|
||||||
|
},
|
||||||
|
...(extraHistory ?? []),
|
||||||
|
]),
|
||||||
|
}));
|
||||||
vi.mock('../core/nonInteractiveToolExecutor.js');
|
vi.mock('../core/nonInteractiveToolExecutor.js');
|
||||||
vi.mock('../ide/ide-client.js');
|
vi.mock('../ide/ide-client.js');
|
||||||
vi.mock('../core/client.js');
|
vi.mock('../core/client.js');
|
||||||
@@ -174,9 +186,6 @@ describe('subagent.ts', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
vi.mocked(getEnvironmentContext).mockResolvedValue([
|
|
||||||
{ text: 'Env Context' },
|
|
||||||
]);
|
|
||||||
vi.mocked(createContentGenerator).mockResolvedValue({
|
vi.mocked(createContentGenerator).mockResolvedValue({
|
||||||
getGenerativeModel: vi.fn(),
|
getGenerativeModel: vi.fn(),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type {
|
|||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
ToolCallConfirmationDetails,
|
ToolCallConfirmationDetails,
|
||||||
} from '../tools/tools.js';
|
} from '../tools/tools.js';
|
||||||
import { getEnvironmentContext } from '../utils/environmentContext.js';
|
import { getInitialChatHistory } from '../utils/environmentContext.js';
|
||||||
import type {
|
import type {
|
||||||
Content,
|
Content,
|
||||||
Part,
|
Part,
|
||||||
@@ -807,11 +807,7 @@ export class SubAgentScope {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const envParts = await getEnvironmentContext(this.runtimeContext);
|
const envHistory = await getInitialChatHistory(this.runtimeContext);
|
||||||
const envHistory: Content[] = [
|
|
||||||
{ role: 'user', parts: envParts },
|
|
||||||
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const start_history = [
|
const start_history = [
|
||||||
...envHistory,
|
...envHistory,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Part } from '@google/genai';
|
import type { Content, Part } from '@google/genai';
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
import { getFolderStructure } from './getFolderStructure.js';
|
import { getFolderStructure } from './getFolderStructure.js';
|
||||||
|
|
||||||
@@ -107,3 +107,23 @@ ${directoryContext}
|
|||||||
|
|
||||||
return initialParts;
|
return initialParts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getInitialChatHistory(
|
||||||
|
config: Config,
|
||||||
|
extraHistory?: Content[],
|
||||||
|
): Promise<Content[]> {
|
||||||
|
const envParts = await getEnvironmentContext(config);
|
||||||
|
const envContextString = envParts.map((part) => part.text || '').join('\n\n');
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: envContextString }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'Got it. Thanks for the context!' }],
|
||||||
|
},
|
||||||
|
...(extraHistory ?? []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code-test-utils",
|
"name": "@qwen-code/qwen-code-test-utils",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3-preview.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "qwen-code-vscode-ide-companion",
|
"name": "qwen-code-vscode-ide-companion",
|
||||||
"displayName": "Qwen Code Companion",
|
"displayName": "Qwen Code Companion",
|
||||||
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
|
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3-preview.0",
|
||||||
"publisher": "qwenlm",
|
"publisher": "qwenlm",
|
||||||
"icon": "assets/icon.png",
|
"icon": "assets/icon.png",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const distPackageJson = {
|
|||||||
bin: {
|
bin: {
|
||||||
qwen: 'cli.js',
|
qwen: 'cli.js',
|
||||||
},
|
},
|
||||||
files: ['cli.js', 'vendor', 'README.md', 'LICENSE'],
|
files: ['cli.js', 'vendor', '*.sb', 'README.md', 'LICENSE'],
|
||||||
config: rootPackageJson.config,
|
config: rootPackageJson.config,
|
||||||
dependencies: runtimeDependencies,
|
dependencies: runtimeDependencies,
|
||||||
optionalDependencies: {
|
optionalDependencies: {
|
||||||
|
|||||||
Reference in New Issue
Block a user