Merge branch 'main' into feat/extension

This commit is contained in:
LaZzyMan 2026-01-19 21:16:07 +08:00
commit a61a3c5680
51 changed files with 2369 additions and 699 deletions

View file

@ -897,6 +897,373 @@ describe('loadCliConfig telemetry', () => {
});
});
describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
// Other common mocks would be reset here.
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should pass extension context file paths to loadServerHierarchicalMemory', async () => {
process.argv = ['node', 'script.js'];
const settings: Settings = {};
const extensions: Extension[] = [
{
path: '/path/to/ext1',
config: {
name: 'ext1',
version: '1.0.0',
},
contextFiles: ['/path/to/ext1/QWEN.md'],
},
{
path: '/path/to/ext2',
config: {
name: 'ext2',
version: '1.0.0',
},
contextFiles: [],
},
{
path: '/path/to/ext3',
config: {
name: 'ext3',
version: '1.0.0',
},
contextFiles: [
'/path/to/ext3/context1.md',
'/path/to/ext3/context2.md',
],
},
];
const argv = await parseArguments({} as Settings);
await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
expect.any(String),
[],
false,
expect.any(Object),
[
'/path/to/ext1/QWEN.md',
'/path/to/ext3/context1.md',
'/path/to/ext3/context2.md',
],
true,
'tree',
);
});
// NOTE TO FUTURE DEVELOPERS:
// To re-enable tests for loadHierarchicalGeminiMemory, ensure that:
// 1. os.homedir() is reliably mocked *before* the config.ts module is loaded
// and its functions (which use os.homedir()) are called.
// 2. fs/promises and fs mocks correctly simulate file/directory existence,
// readability, and content based on paths derived from the mocked os.homedir().
// 3. Spies on console functions (for logger output) are correctly set up if needed.
// Example of a previously failing test structure:
it.skip('should correctly use mocked homedir for global path', async () => {
const MOCK_GEMINI_DIR_LOCAL = path.join('/mock/home/user', '.qwen');
const MOCK_GLOBAL_PATH_LOCAL = path.join(MOCK_GEMINI_DIR_LOCAL, 'QWEN.md');
mockFs({
[MOCK_GLOBAL_PATH_LOCAL]: { type: 'file', content: 'GlobalContentOnly' },
});
const memory = await loadHierarchicalGeminiMemory('/some/other/cwd', false);
expect(memory).toBe('GlobalContentOnly');
expect(vi.mocked(os.homedir)).toHaveBeenCalled();
expect(fsPromises.readFile).toHaveBeenCalledWith(
MOCK_GLOBAL_PATH_LOCAL,
'utf-8',
);
});
});
describe('mergeMcpServers', () => {
it('should not modify the original settings object', async () => {
const settings: Settings = {
mcpServers: {
'test-server': {
url: 'http://localhost:8080',
},
},
};
const extensions: Extension[] = [
{
path: '/path/to/ext1',
config: {
name: 'ext1',
version: '1.0.0',
mcpServers: {
'ext1-server': {
url: 'http://localhost:8081',
},
},
},
contextFiles: [],
},
];
const originalSettings = JSON.parse(JSON.stringify(settings));
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(settings).toEqual(originalSettings);
});
});
describe('mergeExcludeTools', () => {
const defaultExcludes = [ShellTool.Name, EditTool.Name, WriteFileTool.Name];
const originalIsTTY = process.stdin.isTTY;
beforeEach(() => {
process.stdin.isTTY = true;
});
afterEach(() => {
process.stdin.isTTY = originalIsTTY;
});
it('should merge excludeTools from settings and extensions', async () => {
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
const extensions: Extension[] = [
{
path: '/path/to/ext1',
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool3', 'tool4'],
},
contextFiles: [],
},
{
path: '/path/to/ext2',
config: {
name: 'ext2',
version: '1.0.0',
excludeTools: ['tool5'],
},
contextFiles: [],
},
];
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.getExcludeTools()).toEqual(
expect.arrayContaining(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
);
expect(config.getExcludeTools()).toHaveLength(5);
});
it('should handle overlapping excludeTools between settings and extensions', async () => {
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
const extensions: Extension[] = [
{
path: '/path/to/ext1',
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool2', 'tool3'],
},
contextFiles: [],
},
];
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.getExcludeTools()).toEqual(
expect.arrayContaining(['tool1', 'tool2', 'tool3']),
);
expect(config.getExcludeTools()).toHaveLength(3);
});
it('should handle overlapping excludeTools between extensions', async () => {
const settings: Settings = { tools: { exclude: ['tool1'] } };
const extensions: Extension[] = [
{
path: '/path/to/ext1',
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool2', 'tool3'],
},
contextFiles: [],
},
{
path: '/path/to/ext2',
config: {
name: 'ext2',
version: '1.0.0',
excludeTools: ['tool3', 'tool4'],
},
contextFiles: [],
},
];
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.getExcludeTools()).toEqual(
expect.arrayContaining(['tool1', 'tool2', 'tool3', 'tool4']),
);
expect(config.getExcludeTools()).toHaveLength(4);
});
it('should return an empty array when no excludeTools are specified and it is interactive', async () => {
process.stdin.isTTY = true;
const settings: Settings = {};
const extensions: Extension[] = [];
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.getExcludeTools()).toEqual([]);
});
it('should return default excludes when no excludeTools are specified and it is not interactive', async () => {
process.stdin.isTTY = false;
const settings: Settings = {};
const extensions: Extension[] = [];
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.getExcludeTools()).toEqual(defaultExcludes);
});
it('should handle settings with excludeTools but no extensions', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.getExcludeTools()).toEqual(
expect.arrayContaining(['tool1', 'tool2']),
);
expect(config.getExcludeTools()).toHaveLength(2);
});
it('should handle extensions with excludeTools but no settings', async () => {
const settings: Settings = {};
const extensions: Extension[] = [
{
path: '/path/to/ext',
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool1', 'tool2'],
},
contextFiles: [],
},
];
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.getExcludeTools()).toEqual(
expect.arrayContaining(['tool1', 'tool2']),
);
expect(config.getExcludeTools()).toHaveLength(2);
});
it('should not modify the original settings object', async () => {
const settings: Settings = { tools: { exclude: ['tool1'] } };
const extensions: Extension[] = [
{
path: '/path/to/ext',
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool2'],
},
contextFiles: [],
},
];
const originalSettings = JSON.parse(JSON.stringify(settings));
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(settings).toEqual(originalSettings);
});
});
describe('Approval mode tool exclusion logic', () => {
const originalIsTTY = process.stdin.isTTY;

View file

@ -21,7 +21,6 @@ import {
isToolEnabled,
SessionService,
type ResumedSessionData,
type FileFilteringOptions,
type ToolName,
EditTool,
ShellTool,
@ -329,7 +328,14 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
.option('experimental-skills', {
type: 'boolean',
description: 'Enable experimental Skills feature',
default: settings.tools?.experimental?.skills ?? false,
default: (() => {
const legacySkills = (
settings as Settings & {
tools?: { experimental?: { skills?: boolean } };
}
).tools?.experimental?.skills;
return settings.experimental?.skills ?? legacySkills ?? false;
})(),
})
.option('channel', {
type: 'string',
@ -635,8 +641,6 @@ export async function loadHierarchicalGeminiMemory(
extensionContextFilePaths: string[] = [],
folderTrust: boolean,
memoryImportFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions,
maxDirs: number = 200,
): Promise<{ memoryContent: string; fileCount: number }> {
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory));
@ -662,8 +666,6 @@ export async function loadHierarchicalGeminiMemory(
extensionContextFilePaths,
folderTrust,
memoryImportFormat,
fileFilteringOptions,
maxDirs,
);
}

View file

@ -122,9 +122,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Auto-completion
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
// Completion navigation (arrow or Ctrl+P/N)
[Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
[Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
// Completion navigation uses only arrow keys
// Ctrl+P/N are reserved for history navigation (HISTORY_UP/DOWN)
[Command.COMPLETION_UP]: [{ key: 'up' }],
[Command.COMPLETION_DOWN]: [{ key: 'down' }],
// Text input
// Must also exclude shift to allow shift+enter for newline

View file

@ -104,7 +104,6 @@ const MIGRATION_MAP: Record<string, string> = {
mcpServers: 'mcpServers',
mcpServerCommand: 'mcp.serverCommand',
memoryImportFormat: 'context.importFormat',
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
model: 'model.name',
preferredEditor: 'general.preferredEditor',
sandbox: 'tools.sandbox',
@ -901,6 +900,31 @@ export function loadSettings(
);
}
export function migrateDeprecatedSettings(
loadedSettings: LoadedSettings,
): void {
const processScope = (scope: SettingScope) => {
const settings = loadedSettings.forScope(scope).settings;
const legacySkills = (
settings as Settings & {
tools?: { experimental?: { skills?: boolean } };
}
).tools?.experimental?.skills;
if (
legacySkills !== undefined &&
settings.experimental?.skills === undefined
) {
console.log(
`Migrating deprecated tools.experimental.skills setting from ${scope} settings...`,
);
loadedSettings.setValue(scope, 'experimental.skills', legacySkills);
}
};
processScope(SettingScope.User);
processScope(SettingScope.Workspace);
}
export function saveSettings(settingsFile: SettingsFile): void {
try {
// Ensure the directory exists

View file

@ -434,6 +434,16 @@ const SETTINGS_SCHEMA = {
'Show welcome back dialog when returning to a project with conversation history.',
showInDialog: true,
},
enableUserFeedback: {
type: 'boolean',
label: 'Enable User Feedback',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Show optional feedback dialog after conversations to help improve Qwen performance.',
showInDialog: true,
},
accessibility: {
type: 'object',
label: 'Accessibility',
@ -464,6 +474,15 @@ const SETTINGS_SCHEMA = {
},
},
},
feedbackLastShownTimestamp: {
type: 'number',
label: 'Feedback Last Shown Timestamp',
category: 'UI',
requiresRestart: false,
default: 0,
description: 'The last time the feedback dialog was shown.',
showInDialog: false,
},
},
},
@ -722,15 +741,6 @@ const SETTINGS_SCHEMA = {
description: 'The format to use when importing memory.',
showInDialog: false,
},
discoveryMaxDirs: {
type: 'number',
label: 'Memory Discovery Max Dirs',
category: 'Context',
requiresRestart: false,
default: 200,
description: 'Maximum number of directories to search for memory.',
showInDialog: true,
},
includeDirectories: {
type: 'array',
label: 'Include Directories',
@ -981,27 +991,6 @@ const SETTINGS_SCHEMA = {
description: 'The number of lines to keep when truncating tool output.',
showInDialog: true,
},
experimental: {
type: 'object',
label: 'Experimental',
category: 'Tools',
requiresRestart: true,
default: {},
description: 'Experimental tool features.',
showInDialog: false,
properties: {
skills: {
type: 'boolean',
label: 'Skills',
category: 'Tools',
requiresRestart: true,
default: false,
description:
'Enable experimental Agent Skills feature. When enabled, Qwen Code can use Skills from .qwen/skills/ and ~/.qwen/skills/.',
showInDialog: true,
},
},
},
},
},
@ -1228,6 +1217,16 @@ const SETTINGS_SCHEMA = {
description: 'Setting to enable experimental features',
showInDialog: false,
properties: {
skills: {
type: 'boolean',
label: 'Skills',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable experimental Agent Skills feature. When enabled, Qwen Code can use Skills from .qwen/skills/ and ~/.qwen/skills/.',
showInDialog: true,
},
visionModelPreview: {
type: 'boolean',
label: 'Vision Model Preview',