mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-20 07:46:20 +00:00
Compare commits
54 Commits
docs/code-
...
release/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7bcdb6b8a | ||
|
|
55a5df46ba | ||
|
|
eb7dc53d2e | ||
|
|
de47c4e98b | ||
|
|
eed46447da | ||
|
|
8de81b6299 | ||
|
|
b13c5bf090 | ||
|
|
0a64fa78f5 | ||
|
|
f99295462d | ||
|
|
1145045a5a | ||
|
|
95c551c1b4 | ||
|
|
ec2aa6d86d | ||
|
|
66ad936c31 | ||
|
|
8b5f198e3c | ||
|
|
e8356c5f9e | ||
|
|
dc067697dc | ||
|
|
79cce84280 | ||
|
|
b9207c5884 | ||
|
|
baf848a4d9 | ||
|
|
d0104dc487 | ||
|
|
531062aeaf | ||
|
|
ced1b1db80 | ||
|
|
cf140b1b9d | ||
|
|
1f1e78aa3b | ||
|
|
511269446f | ||
|
|
0901b228a7 | ||
|
|
0681c71894 | ||
|
|
155c4b9728 | ||
|
|
57ca2823b3 | ||
|
|
620341eeae | ||
|
|
da8c49cb9d | ||
|
|
d7d3371ddf | ||
|
|
c6c33233c5 | ||
|
|
4213d06ab9 | ||
|
|
106b69e5c0 | ||
|
|
6afe0f8c29 | ||
|
|
0b3be1a82c | ||
|
|
8af43e3ac3 | ||
|
|
04a11aa111 | ||
|
|
45236b6ec5 | ||
|
|
9e8724a749 | ||
|
|
d91e372c72 | ||
|
|
9325721811 | ||
|
|
56391b11ad | ||
|
|
e748532e6d | ||
|
|
d095a8b3f1 | ||
|
|
f7585153b7 | ||
|
|
d5ad3aebe4 | ||
|
|
98c680642f | ||
|
|
e4efd3a15d | ||
|
|
63406b4ba4 | ||
|
|
52db3a766d | ||
|
|
6f33d92b2c | ||
|
|
b923acd278 |
@@ -241,7 +241,6 @@ Per-field precedence for `generationConfig`:
|
||||
| ------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `context.fileName` | string or array of strings | The name of the context file(s). | `undefined` |
|
||||
| `context.importFormat` | string | The format to use when importing memory. | `undefined` |
|
||||
| `context.discoveryMaxDirs` | number | Maximum number of directories to search for memory. | `200` |
|
||||
| `context.includeDirectories` | array | Additional directories to include in the workspace context. Specifies an array of additional absolute or relative paths to include in the workspace context. Missing directories will be skipped with a warning by default. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag. | `[]` |
|
||||
| `context.loadFromIncludeDirectories` | boolean | Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory. | `false` |
|
||||
| `context.fileFiltering.respectGitIgnore` | boolean | Respect .gitignore files when searching. | `true` |
|
||||
@@ -311,6 +310,12 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
|
||||
>
|
||||
> **Note about advanced.tavilyApiKey:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format.
|
||||
|
||||
#### experimental
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| --------------------- | ------- | -------------------------------- | ------- |
|
||||
| `experimental.skills` | boolean | Enable experimental Agent Skills | `false` |
|
||||
|
||||
#### mcpServers
|
||||
|
||||
Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Qwen Code attempts to connect to each configured MCP server to discover available tools. If multiple MCP servers expose a tool with the same name, the tool names will be prefixed with the server alias you defined in the configuration (e.g., `serverAlias__actualToolName`) to avoid conflicts. Note that the system might strip certain schema properties from MCP tool definitions for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`.
|
||||
@@ -529,16 +534,13 @@ Here's a conceptual example of what a context file at the root of a TypeScript p
|
||||
|
||||
This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context.
|
||||
|
||||
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
|
||||
- **Hierarchical Loading and Precedence:** The CLI implements a hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
|
||||
1. **Global Context File:**
|
||||
- Location: `~/.qwen/<configured-context-filename>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
|
||||
- Scope: Provides default instructions for all your projects.
|
||||
2. **Project Root & Ancestors Context Files:**
|
||||
- Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory.
|
||||
- Scope: Provides context relevant to the entire project or a significant portion of it.
|
||||
3. **Sub-directory Context Files (Contextual/Local):**
|
||||
- Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with the `context.discoveryMaxDirs` setting in your `settings.json` file.
|
||||
- Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project.
|
||||
- **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context.
|
||||
- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../configuration/memory).
|
||||
- **Commands for Memory Management:**
|
||||
|
||||
@@ -11,12 +11,29 @@ This guide shows you how to create, use, and manage Agent Skills in **Qwen Code*
|
||||
## Prerequisites
|
||||
|
||||
- Qwen Code (recent version)
|
||||
- Run with the experimental flag enabled:
|
||||
|
||||
## How to enable
|
||||
|
||||
### Via CLI flag
|
||||
|
||||
```bash
|
||||
qwen --experimental-skills
|
||||
```
|
||||
|
||||
### Via settings.json
|
||||
|
||||
Add to your `~/.qwen/settings.json` or project's `.qwen/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"experimental": {
|
||||
"skills": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Basic familiarity with Qwen Code ([Quickstart](../quickstart.md))
|
||||
|
||||
## What are Agent Skills?
|
||||
|
||||
@@ -311,9 +311,9 @@ function setupAcpTest(
|
||||
}
|
||||
});
|
||||
|
||||
it('returns modes on initialize and allows setting approval mode', async () => {
|
||||
it('returns modes on initialize and allows setting mode and model', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp approval mode');
|
||||
rig.setup('acp mode and model');
|
||||
|
||||
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
|
||||
|
||||
@@ -366,8 +366,14 @@ function setupAcpTest(
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
})) as {
|
||||
sessionId: string;
|
||||
models: {
|
||||
availableModels: Array<{ modelId: string }>;
|
||||
};
|
||||
};
|
||||
expect(newSession.sessionId).toBeTruthy();
|
||||
expect(newSession.models.availableModels.length).toBeGreaterThan(0);
|
||||
|
||||
// Test 4: Set approval mode to 'yolo'
|
||||
const setModeResult = (await sendRequest('session/set_mode', {
|
||||
@@ -392,6 +398,15 @@ function setupAcpTest(
|
||||
})) as { modeId: string };
|
||||
expect(setModeResult3).toBeDefined();
|
||||
expect(setModeResult3.modeId).toBe('default');
|
||||
|
||||
// Test 7: Set model using first available model
|
||||
const firstModel = newSession.models.availableModels[0];
|
||||
const setModelResult = (await sendRequest('session/set_model', {
|
||||
sessionId: newSession.sessionId,
|
||||
modelId: firstModel.modelId,
|
||||
})) as { modelId: string };
|
||||
expect(setModelResult).toBeDefined();
|
||||
expect(setModelResult.modelId).toBeTruthy();
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.1-preview.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.1-preview.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -17310,7 +17310,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.1-preview.0",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -17947,7 +17947,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.1-preview.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.36.1",
|
||||
@@ -21408,7 +21408,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.1-preview.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -21420,7 +21420,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.1-preview.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.1-preview.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1-preview.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.1-preview.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,7 +33,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1-preview.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
|
||||
@@ -70,6 +70,13 @@ export class AgentSideConnection implements Client {
|
||||
const validatedParams = schema.setModeRequestSchema.parse(params);
|
||||
return agent.setMode(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_set_model: {
|
||||
if (!agent.setModel) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams = schema.setModelRequestSchema.parse(params);
|
||||
return agent.setModel(validatedParams);
|
||||
}
|
||||
default:
|
||||
throw RequestError.methodNotFound(method);
|
||||
}
|
||||
@@ -408,4 +415,5 @@ export interface Agent {
|
||||
prompt(params: schema.PromptRequest): Promise<schema.PromptResponse>;
|
||||
cancel(params: schema.CancelNotification): Promise<void>;
|
||||
setMode?(params: schema.SetModeRequest): Promise<schema.SetModeResponse>;
|
||||
setModel?(params: schema.SetModelRequest): Promise<schema.SetModelResponse>;
|
||||
}
|
||||
|
||||
@@ -165,30 +165,11 @@ class GeminiAgent {
|
||||
this.setupFileSystem(config);
|
||||
|
||||
const session = await this.createAndStoreSession(config);
|
||||
const configuredModel = (
|
||||
config.getModel() ||
|
||||
this.config.getModel() ||
|
||||
''
|
||||
).trim();
|
||||
const modelId = configuredModel || 'default';
|
||||
const modelName = configuredModel || modelId;
|
||||
const availableModels = this.buildAvailableModels(config);
|
||||
|
||||
return {
|
||||
sessionId: session.getId(),
|
||||
models: {
|
||||
currentModelId: modelId,
|
||||
availableModels: [
|
||||
{
|
||||
modelId,
|
||||
name: modelName,
|
||||
description: null,
|
||||
_meta: {
|
||||
contextLimit: tokenLimit(modelId),
|
||||
},
|
||||
},
|
||||
],
|
||||
_meta: null,
|
||||
},
|
||||
models: availableModels,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -305,15 +286,29 @@ class GeminiAgent {
|
||||
async setMode(params: acp.SetModeRequest): Promise<acp.SetModeResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
throw acp.RequestError.invalidParams(
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setMode(params);
|
||||
}
|
||||
|
||||
async setModel(params: acp.SetModelRequest): Promise<acp.SetModelResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setModel(params);
|
||||
}
|
||||
|
||||
private async ensureAuthenticated(config: Config): Promise<void> {
|
||||
const selectedType = this.settings.merged.security?.auth?.selectedType;
|
||||
if (!selectedType) {
|
||||
throw acp.RequestError.authRequired('No Selected Type');
|
||||
throw acp.RequestError.authRequired(
|
||||
'Use Qwen Code CLI to authenticate first.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -382,4 +377,43 @@ class GeminiAgent {
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private buildAvailableModels(
|
||||
config: Config,
|
||||
): acp.NewSessionResponse['models'] {
|
||||
const currentModelId = (
|
||||
config.getModel() ||
|
||||
this.config.getModel() ||
|
||||
''
|
||||
).trim();
|
||||
const availableModels = config.getAvailableModels();
|
||||
|
||||
const mappedAvailableModels = availableModels.map((model) => ({
|
||||
modelId: model.id,
|
||||
name: model.label,
|
||||
description: model.description ?? null,
|
||||
_meta: {
|
||||
contextLimit: tokenLimit(model.id),
|
||||
},
|
||||
}));
|
||||
|
||||
if (
|
||||
currentModelId &&
|
||||
!mappedAvailableModels.some((model) => model.modelId === currentModelId)
|
||||
) {
|
||||
mappedAvailableModels.unshift({
|
||||
modelId: currentModelId,
|
||||
name: currentModelId,
|
||||
description: null,
|
||||
_meta: {
|
||||
contextLimit: tokenLimit(currentModelId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currentModelId,
|
||||
availableModels: mappedAvailableModels,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export const AGENT_METHODS = {
|
||||
session_prompt: 'session/prompt',
|
||||
session_list: 'session/list',
|
||||
session_set_mode: 'session/set_mode',
|
||||
session_set_model: 'session/set_model',
|
||||
};
|
||||
|
||||
export const CLIENT_METHODS = {
|
||||
@@ -266,6 +267,18 @@ export const modelInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const setModelRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export const setModelResponseSchema = z.object({
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export type SetModelRequest = z.infer<typeof setModelRequestSchema>;
|
||||
export type SetModelResponse = z.infer<typeof setModelResponseSchema>;
|
||||
|
||||
export const sessionModelStateSchema = z.object({
|
||||
_meta: acpMetaSchema,
|
||||
availableModels: z.array(modelInfoSchema),
|
||||
@@ -592,6 +605,7 @@ export const agentResponseSchema = z.union([
|
||||
promptResponseSchema,
|
||||
listSessionsResponseSchema,
|
||||
setModeResponseSchema,
|
||||
setModelResponseSchema,
|
||||
]);
|
||||
|
||||
export const requestPermissionRequestSchema = z.object({
|
||||
@@ -624,6 +638,7 @@ export const agentRequestSchema = z.union([
|
||||
promptRequestSchema,
|
||||
listSessionsRequestSchema,
|
||||
setModeRequestSchema,
|
||||
setModelRequestSchema,
|
||||
]);
|
||||
|
||||
export const agentNotificationSchema = sessionNotificationSchema;
|
||||
|
||||
174
packages/cli/src/acp-integration/session/Session.test.ts
Normal file
174
packages/cli/src/acp-integration/session/Session.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Session } from './Session.js';
|
||||
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js';
|
||||
|
||||
vi.mock('../../nonInteractiveCliCommands.js', () => ({
|
||||
getAvailableCommands: vi.fn(),
|
||||
handleSlashCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Session', () => {
|
||||
let mockChat: GeminiChat;
|
||||
let mockConfig: Config;
|
||||
let mockClient: acp.Client;
|
||||
let mockSettings: LoadedSettings;
|
||||
let session: Session;
|
||||
let currentModel: string;
|
||||
let setModelSpy: ReturnType<typeof vi.fn>;
|
||||
let getAvailableCommandsSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
currentModel = 'qwen3-code-plus';
|
||||
setModelSpy = vi.fn().mockImplementation(async (modelId: string) => {
|
||||
currentModel = modelId;
|
||||
});
|
||||
|
||||
mockChat = {
|
||||
sendMessageStream: vi.fn(),
|
||||
addHistory: vi.fn(),
|
||||
} as unknown as GeminiChat;
|
||||
|
||||
mockConfig = {
|
||||
setApprovalMode: vi.fn(),
|
||||
setModel: setModelSpy,
|
||||
getModel: vi.fn().mockImplementation(() => currentModel),
|
||||
} as unknown as Config;
|
||||
|
||||
mockClient = {
|
||||
sessionUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
requestPermission: vi.fn().mockResolvedValue({
|
||||
outcome: { outcome: 'selected', optionId: 'proceed_once' },
|
||||
}),
|
||||
sendCustomNotification: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as acp.Client;
|
||||
|
||||
mockSettings = {
|
||||
merged: {},
|
||||
} as LoadedSettings;
|
||||
|
||||
getAvailableCommandsSpy = vi.mocked(nonInteractiveCliCommands)
|
||||
.getAvailableCommands as unknown as ReturnType<typeof vi.fn>;
|
||||
getAvailableCommandsSpy.mockResolvedValue([]);
|
||||
|
||||
session = new Session(
|
||||
'test-session-id',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockClient,
|
||||
mockSettings,
|
||||
);
|
||||
});
|
||||
|
||||
describe('setMode', () => {
|
||||
it.each([
|
||||
['plan', ApprovalMode.PLAN],
|
||||
['default', ApprovalMode.DEFAULT],
|
||||
['auto-edit', ApprovalMode.AUTO_EDIT],
|
||||
['yolo', ApprovalMode.YOLO],
|
||||
] as const)('maps %s mode', async (modeId, expected) => {
|
||||
const result = await session.setMode({
|
||||
sessionId: 'test-session-id',
|
||||
modeId,
|
||||
});
|
||||
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(expected);
|
||||
expect(result).toEqual({ modeId });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setModel', () => {
|
||||
it('sets model via config and returns current model', async () => {
|
||||
const result = await session.setModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: ' qwen3-coder-plus ',
|
||||
});
|
||||
|
||||
expect(mockConfig.setModel).toHaveBeenCalledWith('qwen3-coder-plus', {
|
||||
reason: 'user_request_acp',
|
||||
context: 'session/set_model',
|
||||
});
|
||||
expect(mockConfig.getModel).toHaveBeenCalled();
|
||||
expect(result).toEqual({ modelId: 'qwen3-coder-plus' });
|
||||
});
|
||||
|
||||
it('rejects empty/whitespace model IDs', async () => {
|
||||
await expect(
|
||||
session.setModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: ' ',
|
||||
}),
|
||||
).rejects.toThrow('Invalid params');
|
||||
|
||||
expect(mockConfig.setModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates errors from config.setModel', async () => {
|
||||
const configError = new Error('Invalid model');
|
||||
setModelSpy.mockRejectedValueOnce(configError);
|
||||
|
||||
await expect(
|
||||
session.setModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: 'invalid-model',
|
||||
}),
|
||||
).rejects.toThrow('Invalid model');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendAvailableCommandsUpdate', () => {
|
||||
it('sends available_commands_update from getAvailableCommands()', async () => {
|
||||
getAvailableCommandsSpy.mockResolvedValueOnce([
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize project context',
|
||||
},
|
||||
]);
|
||||
|
||||
await session.sendAvailableCommandsUpdate();
|
||||
|
||||
expect(getAvailableCommandsSpy).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: 'test-session-id',
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize project context',
|
||||
input: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('swallows errors and does not throw', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
getAvailableCommandsSpy.mockRejectedValueOnce(
|
||||
new Error('Command discovery failed'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
session.sendAvailableCommandsUpdate(),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockClient.sessionUpdate).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,8 @@ import type {
|
||||
AvailableCommandsUpdate,
|
||||
SetModeRequest,
|
||||
SetModeResponse,
|
||||
SetModelRequest,
|
||||
SetModelResponse,
|
||||
ApprovalModeValue,
|
||||
CurrentModeUpdate,
|
||||
} from '../schema.js';
|
||||
@@ -348,6 +350,31 @@ export class Session implements SessionContext {
|
||||
return { modeId: params.modeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the model for the current session.
|
||||
* Validates the model ID and switches the model via Config.
|
||||
*/
|
||||
async setModel(params: SetModelRequest): Promise<SetModelResponse> {
|
||||
const modelId = params.modelId.trim();
|
||||
|
||||
if (!modelId) {
|
||||
throw acp.RequestError.invalidParams('modelId cannot be empty');
|
||||
}
|
||||
|
||||
// Attempt to set the model using config
|
||||
await this.config.setModel(modelId, {
|
||||
reason: 'user_request_acp',
|
||||
context: 'session/set_model',
|
||||
});
|
||||
|
||||
// Get updated model info
|
||||
const currentModel = this.config.getModel();
|
||||
|
||||
return {
|
||||
modelId: currentModel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a current_mode_update notification to the client.
|
||||
* Called after the agent switches modes (e.g., from exit_plan_mode tool).
|
||||
|
||||
@@ -1196,11 +1196,6 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
],
|
||||
true,
|
||||
'tree',
|
||||
{
|
||||
respectGitIgnore: false,
|
||||
respectQwenIgnore: true,
|
||||
},
|
||||
undefined, // maxDirs
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
AuthType,
|
||||
Config,
|
||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
FileDiscoveryService,
|
||||
getCurrentGeminiMdFilename,
|
||||
loadServerHierarchicalMemory,
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
isToolEnabled,
|
||||
SessionService,
|
||||
type ResumedSessionData,
|
||||
type FileFilteringOptions,
|
||||
type MCPServerConfig,
|
||||
type ToolName,
|
||||
EditTool,
|
||||
@@ -334,7 +332,14 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
.option('experimental-skills', {
|
||||
type: 'boolean',
|
||||
description: 'Enable experimental Skills feature',
|
||||
default: false,
|
||||
default: (() => {
|
||||
const legacySkills = (
|
||||
settings as Settings & {
|
||||
tools?: { experimental?: { skills?: boolean } };
|
||||
}
|
||||
).tools?.experimental?.skills;
|
||||
return settings.experimental?.skills ?? legacySkills ?? false;
|
||||
})(),
|
||||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
@@ -643,7 +648,6 @@ export async function loadHierarchicalGeminiMemory(
|
||||
extensionContextFilePaths: string[] = [],
|
||||
folderTrust: boolean,
|
||||
memoryImportFormat: 'flat' | 'tree' = 'tree',
|
||||
fileFilteringOptions?: FileFilteringOptions,
|
||||
): Promise<{ memoryContent: string; fileCount: number }> {
|
||||
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
|
||||
const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory));
|
||||
@@ -669,8 +673,6 @@ export async function loadHierarchicalGeminiMemory(
|
||||
extensionContextFilePaths,
|
||||
folderTrust,
|
||||
memoryImportFormat,
|
||||
fileFilteringOptions,
|
||||
settings.context?.discoveryMaxDirs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -740,11 +742,6 @@ export async function loadCliConfig(
|
||||
|
||||
const fileService = new FileDiscoveryService(cwd);
|
||||
|
||||
const fileFiltering = {
|
||||
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
...settings.context?.fileFiltering,
|
||||
};
|
||||
|
||||
const includeDirectories = (settings.context?.includeDirectories || [])
|
||||
.map(resolvePath)
|
||||
.concat((argv.includeDirectories || []).map(resolvePath));
|
||||
@@ -761,7 +758,6 @@ export async function loadCliConfig(
|
||||
extensionContextFilePaths,
|
||||
trustedFolder,
|
||||
memoryImportFormat,
|
||||
fileFiltering,
|
||||
);
|
||||
|
||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -106,7 +106,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',
|
||||
@@ -922,6 +921,21 @@ export function migrateDeprecatedSettings(
|
||||
|
||||
loadedSettings.setValue(scope, 'extensions', newExtensionsValue);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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',
|
||||
@@ -1207,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,
|
||||
},
|
||||
extensionManagement: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Management',
|
||||
|
||||
@@ -289,6 +289,13 @@ export default {
|
||||
'Show Citations': 'Quellenangaben anzeigen',
|
||||
'Custom Witty Phrases': 'Benutzerdefinierte Witzige Sprüche',
|
||||
'Enable Welcome Back': 'Willkommen-zurück aktivieren',
|
||||
'Enable User Feedback': 'Benutzerfeedback aktivieren',
|
||||
'How is Qwen doing this session? (optional)':
|
||||
'Wie macht sich Qwen in dieser Sitzung? (optional)',
|
||||
Bad: 'Schlecht',
|
||||
Good: 'Gut',
|
||||
'Not Sure Yet': 'Noch nicht sicher',
|
||||
'Any other key': 'Beliebige andere Taste',
|
||||
'Disable Loading Phrases': 'Ladesprüche deaktivieren',
|
||||
'Screen Reader Mode': 'Bildschirmleser-Modus',
|
||||
'IDE Mode': 'IDE-Modus',
|
||||
|
||||
@@ -286,6 +286,13 @@ export default {
|
||||
'Show Citations': 'Show Citations',
|
||||
'Custom Witty Phrases': 'Custom Witty Phrases',
|
||||
'Enable Welcome Back': 'Enable Welcome Back',
|
||||
'Enable User Feedback': 'Enable User Feedback',
|
||||
'How is Qwen doing this session? (optional)':
|
||||
'How is Qwen doing this session? (optional)',
|
||||
Bad: 'Bad',
|
||||
Good: 'Good',
|
||||
'Not Sure Yet': 'Not Sure Yet',
|
||||
'Any other key': 'Any other key',
|
||||
'Disable Loading Phrases': 'Disable Loading Phrases',
|
||||
'Screen Reader Mode': 'Screen Reader Mode',
|
||||
'IDE Mode': 'IDE Mode',
|
||||
|
||||
@@ -289,6 +289,13 @@ export default {
|
||||
'Show Citations': 'Показывать цитаты',
|
||||
'Custom Witty Phrases': 'Пользовательские остроумные фразы',
|
||||
'Enable Welcome Back': 'Включить приветствие при возврате',
|
||||
'Enable User Feedback': 'Включить отзывы пользователей',
|
||||
'How is Qwen doing this session? (optional)':
|
||||
'Как дела у Qwen в этой сессии? (необязательно)',
|
||||
Bad: 'Плохо',
|
||||
Good: 'Хорошо',
|
||||
'Not Sure Yet': 'Пока не уверен',
|
||||
'Any other key': 'Любая другая клавиша',
|
||||
'Disable Loading Phrases': 'Отключить фразы при загрузке',
|
||||
'Screen Reader Mode': 'Режим программы чтения с экрана',
|
||||
'IDE Mode': 'Режим IDE',
|
||||
|
||||
@@ -277,6 +277,12 @@ export default {
|
||||
'Show Citations': '显示引用',
|
||||
'Custom Witty Phrases': '自定义诙谐短语',
|
||||
'Enable Welcome Back': '启用欢迎回来',
|
||||
'Enable User Feedback': '启用用户反馈',
|
||||
'How is Qwen doing this session? (optional)': 'Qwen 这次表现如何?(可选)',
|
||||
Bad: '不满意',
|
||||
Good: '满意',
|
||||
'Not Sure Yet': '暂不评价',
|
||||
'Any other key': '任意其他键',
|
||||
'Disable Loading Phrases': '禁用加载短语',
|
||||
'Screen Reader Mode': '屏幕阅读器模式',
|
||||
'IDE Mode': 'IDE 模式',
|
||||
@@ -873,11 +879,11 @@ export default {
|
||||
'Session Stats': '会话统计',
|
||||
'Model Usage': '模型使用情况',
|
||||
Reqs: '请求数',
|
||||
'Input Tokens': '输入令牌',
|
||||
'Output Tokens': '输出令牌',
|
||||
'Input Tokens': '输入 token 数',
|
||||
'Output Tokens': '输出 token 数',
|
||||
'Savings Highlight:': '节省亮点:',
|
||||
'of input tokens were served from the cache, reducing costs.':
|
||||
'的输入令牌来自缓存,降低了成本',
|
||||
'从缓存载入 token ,降低了成本',
|
||||
'Tip: For a full token breakdown, run `/stats model`.':
|
||||
'提示:要查看完整的令牌明细,请运行 `/stats model`',
|
||||
'Model Stats For Nerds': '模型统计(技术细节)',
|
||||
|
||||
@@ -45,6 +45,7 @@ import process from 'node:process';
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
import { useFeedbackDialog } from './hooks/useFeedbackDialog.js';
|
||||
import { useAuthCommand } from './auth/useAuth.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
@@ -575,7 +576,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
config.getExtensionContextFilePaths(),
|
||||
config.isTrustedFolder(),
|
||||
settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree'
|
||||
config.getFileFilteringOptions(),
|
||||
);
|
||||
|
||||
config.setUserMemory(memoryContent);
|
||||
@@ -1196,6 +1196,19 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isApprovalModeDialogOpen ||
|
||||
isResumeDialogOpen;
|
||||
|
||||
const {
|
||||
isFeedbackDialogOpen,
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
} = useFeedbackDialog({
|
||||
config,
|
||||
settings,
|
||||
streamingState,
|
||||
history: historyManager.history,
|
||||
sessionStats,
|
||||
});
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
|
||||
@@ -1292,6 +1305,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen,
|
||||
isAgentsManagerDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
@@ -1382,6 +1397,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen,
|
||||
isAgentsManagerDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1422,6 +1439,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
// Feedback dialog
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
}),
|
||||
[
|
||||
handleThemeSelect,
|
||||
@@ -1457,6 +1478,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
// Feedback dialog
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
61
packages/cli/src/ui/FeedbackDialog.tsx
Normal file
61
packages/cli/src/ui/FeedbackDialog.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { useUIActions } from './contexts/UIActionsContext.js';
|
||||
import { useUIState } from './contexts/UIStateContext.js';
|
||||
import { useKeypress } from './hooks/useKeypress.js';
|
||||
|
||||
const FEEDBACK_OPTIONS = {
|
||||
GOOD: 1,
|
||||
BAD: 2,
|
||||
NOT_SURE: 3,
|
||||
} as const;
|
||||
|
||||
const FEEDBACK_OPTION_KEYS = {
|
||||
[FEEDBACK_OPTIONS.GOOD]: '1',
|
||||
[FEEDBACK_OPTIONS.BAD]: '2',
|
||||
[FEEDBACK_OPTIONS.NOT_SURE]: 'any',
|
||||
} as const;
|
||||
|
||||
export const FEEDBACK_DIALOG_KEYS = ['1', '2'] as const;
|
||||
|
||||
export const FeedbackDialog: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]) {
|
||||
uiActions.submitFeedback(FEEDBACK_OPTIONS.GOOD);
|
||||
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]) {
|
||||
uiActions.submitFeedback(FEEDBACK_OPTIONS.BAD);
|
||||
} else {
|
||||
uiActions.submitFeedback(FEEDBACK_OPTIONS.NOT_SURE);
|
||||
}
|
||||
|
||||
uiActions.closeFeedbackDialog();
|
||||
},
|
||||
{ isActive: uiState.isFeedbackDialogOpen },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Box>
|
||||
<Text color="cyan">● </Text>
|
||||
<Text bold>{t('How is Qwen doing this session? (optional)')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="cyan">
|
||||
{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]}:{' '}
|
||||
</Text>
|
||||
<Text>{t('Good')}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="cyan">{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]}: </Text>
|
||||
<Text>{t('Bad')}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="cyan">{t('Any other key')}: </Text>
|
||||
<Text>{t('Not Sure Yet')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
Config,
|
||||
ContentGeneratorConfig,
|
||||
ModelProvidersConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthEvent,
|
||||
AuthType,
|
||||
@@ -214,11 +218,19 @@ export const useAuthCommand = (
|
||||
|
||||
if (authType === AuthType.USE_OPENAI) {
|
||||
if (credentials) {
|
||||
config.updateCredentials({
|
||||
apiKey: credentials.apiKey,
|
||||
baseUrl: credentials.baseUrl,
|
||||
model: credentials.model,
|
||||
});
|
||||
// Pass settings.model.generationConfig to updateCredentials so it can be merged
|
||||
// after clearing provider-sourced config. This ensures settings.json generationConfig
|
||||
// fields (e.g., samplingParams, timeout) are preserved.
|
||||
const settingsGenerationConfig = settings.merged.model
|
||||
?.generationConfig as Partial<ContentGeneratorConfig> | undefined;
|
||||
config.updateCredentials(
|
||||
{
|
||||
apiKey: credentials.apiKey,
|
||||
baseUrl: credentials.baseUrl,
|
||||
model: credentials.model,
|
||||
},
|
||||
settingsGenerationConfig,
|
||||
);
|
||||
await performAuth(authType, credentials);
|
||||
}
|
||||
return;
|
||||
@@ -226,7 +238,13 @@ export const useAuthCommand = (
|
||||
|
||||
await performAuth(authType);
|
||||
},
|
||||
[config, performAuth, isProviderManagedModel, onAuthError],
|
||||
[
|
||||
config,
|
||||
performAuth,
|
||||
isProviderManagedModel,
|
||||
onAuthError,
|
||||
settings.merged.model?.generationConfig,
|
||||
],
|
||||
);
|
||||
|
||||
const openAuthDialog = useCallback(() => {
|
||||
|
||||
@@ -54,9 +54,7 @@ describe('directoryCommand', () => {
|
||||
services: {
|
||||
config: mockConfig,
|
||||
settings: {
|
||||
merged: {
|
||||
memoryDiscoveryMaxDirs: 1000,
|
||||
},
|
||||
merged: {},
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
|
||||
@@ -119,8 +119,6 @@ export const directoryCommand: SlashCommand = {
|
||||
config.getFolderTrust(),
|
||||
context.services.settings.merged.context?.importFormat ||
|
||||
'tree', // Use setting or default to 'tree'
|
||||
config.getFileFilteringOptions(),
|
||||
context.services.settings.merged.context?.discoveryMaxDirs,
|
||||
);
|
||||
config.setUserMemory(memoryContent);
|
||||
config.setGeminiMdFileCount(fileCount);
|
||||
|
||||
@@ -299,9 +299,7 @@ describe('memoryCommand', () => {
|
||||
services: {
|
||||
config: mockConfig,
|
||||
settings: {
|
||||
merged: {
|
||||
memoryDiscoveryMaxDirs: 1000,
|
||||
},
|
||||
merged: {},
|
||||
} as LoadedSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -315,8 +315,6 @@ export const memoryCommand: SlashCommand = {
|
||||
config.getFolderTrust(),
|
||||
context.services.settings.merged.context?.importFormat ||
|
||||
'tree', // Use setting or default to 'tree'
|
||||
config.getFileFilteringOptions(),
|
||||
context.services.settings.merged.context?.discoveryMaxDirs,
|
||||
);
|
||||
config.setUserMemory(memoryContent);
|
||||
config.setGeminiMdFileCount(fileCount);
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
import { FeedbackDialog } from '../FeedbackDialog.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const Composer = () => {
|
||||
@@ -134,6 +135,8 @@ export const Composer = () => {
|
||||
</OverflowProvider>
|
||||
)}
|
||||
|
||||
{uiState.isFeedbackDialogOpen && <FeedbackDialog />}
|
||||
|
||||
{uiState.isInputActive && (
|
||||
<InputPrompt
|
||||
buffer={uiState.buffer}
|
||||
|
||||
@@ -33,6 +33,9 @@ vi.mock('../hooks/useCommandCompletion.js');
|
||||
vi.mock('../hooks/useInputHistory.js');
|
||||
vi.mock('../hooks/useReverseSearchCompletion.js');
|
||||
vi.mock('../utils/clipboardUtils.js');
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false })),
|
||||
}));
|
||||
|
||||
const mockSlashCommands: SlashCommand[] = [
|
||||
{
|
||||
@@ -278,7 +281,7 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
|
||||
it('should call completion.navigateUp for up arrow when suggestions are showing', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
@@ -293,19 +296,22 @@ describe('InputPrompt', () => {
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Test up arrow
|
||||
// Test up arrow for completion navigation
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
await wait();
|
||||
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
|
||||
|
||||
// Ctrl+P should navigate history, not completion
|
||||
stdin.write('\u0010'); // Ctrl+P
|
||||
await wait();
|
||||
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(2);
|
||||
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
|
||||
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1);
|
||||
expect(mockInputHistory.navigateUp).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
|
||||
it('should call completion.navigateDown for down arrow when suggestions are showing', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
@@ -319,14 +325,17 @@ describe('InputPrompt', () => {
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Test down arrow
|
||||
// Test down arrow for completion navigation
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait();
|
||||
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
|
||||
|
||||
// Ctrl+N should navigate history, not completion
|
||||
stdin.write('\u000E'); // Ctrl+N
|
||||
await wait();
|
||||
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(2);
|
||||
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
|
||||
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1);
|
||||
expect(mockInputHistory.navigateDown).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -764,6 +773,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -791,6 +802,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -818,6 +831,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -845,6 +860,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -872,6 +889,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -900,6 +919,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -927,6 +948,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -955,6 +978,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -983,6 +1008,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1011,6 +1038,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1039,6 +1068,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1069,6 +1100,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1097,6 +1130,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1127,6 +1162,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
import * as path from 'node:path';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
|
||||
export interface InputPromptProps {
|
||||
buffer: TextBuffer;
|
||||
onSubmit: (value: string) => void;
|
||||
@@ -100,6 +102,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
isEmbeddedShellFocused,
|
||||
}) => {
|
||||
const isShellFocused = useShellFocusState();
|
||||
const uiState = useUIState();
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
const [escPressCount, setEscPressCount] = useState(0);
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
@@ -135,6 +138,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
commandContext,
|
||||
reverseSearchActive,
|
||||
config,
|
||||
// Suppress completion when history navigation just occurred
|
||||
!justNavigatedHistory,
|
||||
);
|
||||
|
||||
const reverseSearchCompletion = useReverseSearchCompletion(
|
||||
@@ -219,9 +224,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const inputHistory = useInputHistory({
|
||||
userMessages,
|
||||
onSubmit: handleSubmitAndClear,
|
||||
isActive:
|
||||
(!completion.showSuggestions || completion.suggestions.length === 1) &&
|
||||
!shellModeActive,
|
||||
// History navigation (Ctrl+P/N) now always works since completion navigation
|
||||
// only uses arrow keys. Only disable in shell mode.
|
||||
isActive: !shellModeActive,
|
||||
currentQuery: buffer.text,
|
||||
onChange: customSetTextAndResetCompletionSignal,
|
||||
});
|
||||
@@ -326,6 +331,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept feedback dialog option keys (1, 2) when dialog is open
|
||||
if (
|
||||
uiState.isFeedbackDialogOpen &&
|
||||
(FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset ESC count and hide prompt on any non-ESC key
|
||||
if (key.name !== 'escape') {
|
||||
if (escPressCount > 0 || showEscapePrompt) {
|
||||
@@ -670,6 +683,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
recentPasteTime,
|
||||
commandSearchActive,
|
||||
commandSearchCompletion,
|
||||
uiState,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
persistModelSelection(settings, effectiveModelId);
|
||||
persistAuthTypeSelection(settings, effectiveAuthType);
|
||||
|
||||
const baseUrl = after?.baseUrl ?? '(default)';
|
||||
const baseUrl = after?.baseUrl ?? t('(default)');
|
||||
const maskedKey = maskApiKey(after?.apiKey);
|
||||
uiState?.historyManager.addItem(
|
||||
{
|
||||
@@ -322,7 +322,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
<>
|
||||
<ConfigRow
|
||||
label="Base URL"
|
||||
value={effectiveConfig?.baseUrl ?? ''}
|
||||
value={effectiveConfig?.baseUrl ?? t('(default)')}
|
||||
badge={formatSourceBadge(sources['baseUrl'])}
|
||||
/>
|
||||
<ConfigRow
|
||||
|
||||
@@ -1331,9 +1331,7 @@ describe('SettingsDialog', () => {
|
||||
truncateToolOutputThreshold: 50000,
|
||||
truncateToolOutputLines: 1000,
|
||||
},
|
||||
context: {
|
||||
discoveryMaxDirs: 500,
|
||||
},
|
||||
context: {},
|
||||
model: {
|
||||
maxSessionTurns: 100,
|
||||
skipNextSpeakerCheck: false,
|
||||
@@ -1466,7 +1464,6 @@ describe('SettingsDialog', () => {
|
||||
disableFuzzySearch: true,
|
||||
},
|
||||
loadMemoryFromIncludeDirectories: true,
|
||||
discoveryMaxDirs: 100,
|
||||
},
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
|
||||
@@ -66,6 +66,10 @@ export interface UIActions {
|
||||
openResumeDialog: () => void;
|
||||
closeResumeDialog: () => void;
|
||||
handleResume: (sessionId: string) => void;
|
||||
// Feedback dialog
|
||||
openFeedbackDialog: () => void;
|
||||
closeFeedbackDialog: () => void;
|
||||
submitFeedback: (rating: number) => void;
|
||||
}
|
||||
|
||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface UIState {
|
||||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen: boolean;
|
||||
isAgentsManagerDialogOpen: boolean;
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen: boolean;
|
||||
}
|
||||
|
||||
export const UIStateContext = createContext<UIState | null>(null);
|
||||
|
||||
@@ -45,6 +45,8 @@ export function useCommandCompletion(
|
||||
commandContext: CommandContext,
|
||||
reverseSearchActive: boolean = false,
|
||||
config?: Config,
|
||||
// When false, suppresses showing suggestions (e.g., after history navigation)
|
||||
active: boolean = true,
|
||||
): UseCommandCompletionReturn {
|
||||
const {
|
||||
suggestions,
|
||||
@@ -152,7 +154,11 @@ export function useCommandCompletion(
|
||||
}, [suggestions, setActiveSuggestionIndex, setVisibleStartIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (completionMode === CompletionMode.IDLE || reverseSearchActive) {
|
||||
if (
|
||||
completionMode === CompletionMode.IDLE ||
|
||||
reverseSearchActive ||
|
||||
!active
|
||||
) {
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
@@ -163,6 +169,7 @@ export function useCommandCompletion(
|
||||
suggestions.length,
|
||||
isLoadingSuggestions,
|
||||
reverseSearchActive,
|
||||
active,
|
||||
resetCompletionState,
|
||||
setShowSuggestions,
|
||||
]);
|
||||
|
||||
178
packages/cli/src/ui/hooks/useFeedbackDialog.ts
Normal file
178
packages/cli/src/ui/hooks/useFeedbackDialog.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import * as fs from 'node:fs';
|
||||
import {
|
||||
type Config,
|
||||
logUserFeedback,
|
||||
UserFeedbackEvent,
|
||||
type UserFeedbackRating,
|
||||
isNodeError,
|
||||
AuthType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { StreamingState, MessageType, type HistoryItem } from '../types.js';
|
||||
import {
|
||||
SettingScope,
|
||||
type LoadedSettings,
|
||||
USER_SETTINGS_PATH,
|
||||
} from '../../config/settings.js';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
const FEEDBACK_SHOW_PROBABILITY = 0.25; // 25% probability of showing feedback dialog
|
||||
const MIN_TOOL_CALLS = 10; // Minimum tool calls to show feedback dialog
|
||||
const MIN_USER_MESSAGES = 5; // Minimum user messages to show feedback dialog
|
||||
|
||||
// Fatigue mechanism constants
|
||||
const FEEDBACK_COOLDOWN_HOURS = 24; // Hours to wait before showing feedback dialog again
|
||||
|
||||
/**
|
||||
* Check if the last message in the conversation history is an AI response
|
||||
*/
|
||||
const lastMessageIsAIResponse = (history: HistoryItem[]): boolean =>
|
||||
history.length > 0 && history[history.length - 1].type === MessageType.GEMINI;
|
||||
|
||||
/**
|
||||
* Read feedbackLastShownTimestamp directly from the user settings file
|
||||
*/
|
||||
const getFeedbackLastShownTimestampFromFile = (): number => {
|
||||
try {
|
||||
if (fs.existsSync(USER_SETTINGS_PATH)) {
|
||||
const content = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
|
||||
const settings = JSON.parse(stripJsonComments(content));
|
||||
return settings?.ui?.feedbackLastShownTimestamp ?? 0;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code !== 'ENOENT') {
|
||||
console.warn(
|
||||
'Failed to read feedbackLastShownTimestamp from settings file:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if we should show the feedback dialog based on fatigue mechanism
|
||||
*/
|
||||
const shouldShowFeedbackBasedOnFatigue = (): boolean => {
|
||||
const feedbackLastShownTimestamp = getFeedbackLastShownTimestampFromFile();
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastShown = now - feedbackLastShownTimestamp;
|
||||
const cooldownMs = FEEDBACK_COOLDOWN_HOURS * 60 * 60 * 1000;
|
||||
|
||||
return timeSinceLastShown >= cooldownMs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the session meets the minimum requirements for showing feedback
|
||||
* Either tool calls > 10 OR user messages > 5
|
||||
*/
|
||||
const meetsMinimumSessionRequirements = (
|
||||
sessionStats: SessionStatsState,
|
||||
): boolean => {
|
||||
const toolCallsCount = sessionStats.metrics.tools.totalCalls;
|
||||
const userMessagesCount = sessionStats.promptCount;
|
||||
|
||||
return (
|
||||
toolCallsCount > MIN_TOOL_CALLS || userMessagesCount > MIN_USER_MESSAGES
|
||||
);
|
||||
};
|
||||
|
||||
export interface UseFeedbackDialogProps {
|
||||
config: Config;
|
||||
settings: LoadedSettings;
|
||||
streamingState: StreamingState;
|
||||
history: HistoryItem[];
|
||||
sessionStats: SessionStatsState;
|
||||
}
|
||||
|
||||
export const useFeedbackDialog = ({
|
||||
config,
|
||||
settings,
|
||||
streamingState,
|
||||
history,
|
||||
sessionStats,
|
||||
}: UseFeedbackDialogProps) => {
|
||||
// Feedback dialog state
|
||||
const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false);
|
||||
|
||||
const openFeedbackDialog = useCallback(() => {
|
||||
setIsFeedbackDialogOpen(true);
|
||||
|
||||
// Record the timestamp when feedback dialog is shown (fire and forget)
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'ui.feedbackLastShownTimestamp',
|
||||
Date.now(),
|
||||
);
|
||||
}, [settings]);
|
||||
|
||||
const closeFeedbackDialog = useCallback(
|
||||
() => setIsFeedbackDialogOpen(false),
|
||||
[],
|
||||
);
|
||||
|
||||
const submitFeedback = useCallback(
|
||||
(rating: number) => {
|
||||
// Create and log the feedback event
|
||||
const feedbackEvent = new UserFeedbackEvent(
|
||||
sessionStats.sessionId,
|
||||
rating as UserFeedbackRating,
|
||||
config.getModel(),
|
||||
config.getApprovalMode(),
|
||||
);
|
||||
|
||||
logUserFeedback(config, feedbackEvent);
|
||||
closeFeedbackDialog();
|
||||
},
|
||||
[config, sessionStats, closeFeedbackDialog],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAndShowFeedback = () => {
|
||||
if (streamingState === StreamingState.Idle && history.length > 0) {
|
||||
// Show feedback dialog if:
|
||||
// 1. User is authenticated via QWEN_OAUTH
|
||||
// 2. Qwen logger is enabled (required for feedback submission)
|
||||
// 3. User feedback is enabled in settings
|
||||
// 4. The last message is an AI response
|
||||
// 5. Random chance (25% probability)
|
||||
// 6. Meets minimum requirements (tool calls > 10 OR user messages > 5)
|
||||
// 7. Fatigue mechanism allows showing (not shown recently across sessions)
|
||||
if (
|
||||
config.getAuthType() !== AuthType.QWEN_OAUTH ||
|
||||
!config.getUsageStatisticsEnabled() ||
|
||||
settings.merged.ui?.enableUserFeedback === false ||
|
||||
!lastMessageIsAIResponse(history) ||
|
||||
Math.random() > FEEDBACK_SHOW_PROBABILITY ||
|
||||
!meetsMinimumSessionRequirements(sessionStats)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check fatigue mechanism (synchronous)
|
||||
if (shouldShowFeedbackBasedOnFatigue()) {
|
||||
openFeedbackDialog();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkAndShowFeedback();
|
||||
}, [
|
||||
streamingState,
|
||||
history,
|
||||
sessionStats,
|
||||
isFeedbackDialogOpen,
|
||||
openFeedbackDialog,
|
||||
settings.merged.ui?.enableUserFeedback,
|
||||
config,
|
||||
]);
|
||||
|
||||
return {
|
||||
isFeedbackDialogOpen,
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
};
|
||||
};
|
||||
@@ -38,10 +38,10 @@ describe('keyMatchers', () => {
|
||||
[Command.NAVIGATION_DOWN]: (key: Key) => key.name === 'down',
|
||||
[Command.ACCEPT_SUGGESTION]: (key: Key) =>
|
||||
key.name === 'tab' || (key.name === 'return' && !key.ctrl),
|
||||
[Command.COMPLETION_UP]: (key: Key) =>
|
||||
key.name === 'up' || (key.ctrl && key.name === 'p'),
|
||||
[Command.COMPLETION_DOWN]: (key: Key) =>
|
||||
key.name === 'down' || (key.ctrl && key.name === 'n'),
|
||||
// Completion navigation only uses arrow keys (not Ctrl+P/N)
|
||||
// to allow Ctrl+P/N to always navigate history
|
||||
[Command.COMPLETION_UP]: (key: Key) => key.name === 'up',
|
||||
[Command.COMPLETION_DOWN]: (key: Key) => key.name === 'down',
|
||||
[Command.ESCAPE]: (key: Key) => key.name === 'escape',
|
||||
[Command.SUBMIT]: (key: Key) =>
|
||||
key.name === 'return' && !key.ctrl && !key.meta && !key.paste,
|
||||
@@ -164,14 +164,26 @@ describe('keyMatchers', () => {
|
||||
negative: [createKey('return', { ctrl: true }), createKey('space')],
|
||||
},
|
||||
{
|
||||
// Completion navigation only uses arrow keys (not Ctrl+P/N)
|
||||
// to allow Ctrl+P/N to always navigate history
|
||||
command: Command.COMPLETION_UP,
|
||||
positive: [createKey('up'), createKey('p', { ctrl: true })],
|
||||
negative: [createKey('p'), createKey('down')],
|
||||
positive: [createKey('up')],
|
||||
negative: [
|
||||
createKey('p'),
|
||||
createKey('down'),
|
||||
createKey('p', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
{
|
||||
// Completion navigation only uses arrow keys (not Ctrl+P/N)
|
||||
// to allow Ctrl+P/N to always navigate history
|
||||
command: Command.COMPLETION_DOWN,
|
||||
positive: [createKey('down'), createKey('n', { ctrl: true })],
|
||||
negative: [createKey('n'), createKey('up')],
|
||||
positive: [createKey('down')],
|
||||
negative: [
|
||||
createKey('n'),
|
||||
createKey('up'),
|
||||
createKey('n', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
|
||||
// Text input
|
||||
|
||||
@@ -8,7 +8,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { updateSettingsFilePreservingFormat } from './commentJson.js';
|
||||
import {
|
||||
updateSettingsFilePreservingFormat,
|
||||
applyUpdates,
|
||||
} from './commentJson.js';
|
||||
|
||||
describe('commentJson', () => {
|
||||
let tempDir: string;
|
||||
@@ -180,3 +183,18 @@ describe('commentJson', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyUpdates', () => {
|
||||
it('should apply updates correctly', () => {
|
||||
const original = { a: 1, b: { c: 2 } };
|
||||
const updates = { b: { c: 3 } };
|
||||
const result = applyUpdates(original, updates);
|
||||
expect(result).toEqual({ a: 1, b: { c: 3 } });
|
||||
});
|
||||
it('should apply updates correctly when empty', () => {
|
||||
const original = { a: 1, b: { c: 2 } };
|
||||
const updates = { b: {} };
|
||||
const result = applyUpdates(original, updates);
|
||||
expect(result).toEqual({ a: 1, b: {} });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ export function updateSettingsFilePreservingFormat(
|
||||
fs.writeFileSync(filePath, updatedContent, 'utf-8');
|
||||
}
|
||||
|
||||
function applyUpdates(
|
||||
export function applyUpdates(
|
||||
current: Record<string, unknown>,
|
||||
updates: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
@@ -50,6 +50,7 @@ function applyUpdates(
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length > 0 &&
|
||||
typeof result[key] === 'object' &&
|
||||
result[key] !== null &&
|
||||
!Array.isArray(result[key])
|
||||
|
||||
722
packages/cli/src/utils/modelConfigUtils.test.ts
Normal file
722
packages/cli/src/utils/modelConfigUtils.test.ts
Normal file
@@ -0,0 +1,722 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
AuthType,
|
||||
resolveModelConfig,
|
||||
type ProviderModelConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
getAuthTypeFromEnv,
|
||||
resolveCliGenerationConfig,
|
||||
} from './modelConfigUtils.js';
|
||||
import type { Settings } from '../config/settings.js';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...original,
|
||||
resolveModelConfig: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('modelConfigUtils', () => {
|
||||
describe('getAuthTypeFromEnv', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
// Start with a clean env - getAuthTypeFromEnv only checks auth-related vars
|
||||
process.env = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should return USE_OPENAI when all OpenAI env vars are set', () => {
|
||||
process.env['OPENAI_API_KEY'] = 'test-key';
|
||||
process.env['OPENAI_MODEL'] = 'gpt-4';
|
||||
process.env['OPENAI_BASE_URL'] = 'https://api.openai.com';
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_OPENAI);
|
||||
});
|
||||
|
||||
it('should return undefined when OpenAI env vars are incomplete', () => {
|
||||
process.env['OPENAI_API_KEY'] = 'test-key';
|
||||
process.env['OPENAI_MODEL'] = 'gpt-4';
|
||||
// Missing OPENAI_BASE_URL
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return QWEN_OAUTH when QWEN_OAUTH is set', () => {
|
||||
process.env['QWEN_OAUTH'] = 'true';
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBe(AuthType.QWEN_OAUTH);
|
||||
});
|
||||
|
||||
it('should return USE_GEMINI when Gemini env vars are set', () => {
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
process.env['GEMINI_MODEL'] = 'gemini-pro';
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_GEMINI);
|
||||
});
|
||||
|
||||
it('should return undefined when Gemini env vars are incomplete', () => {
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
// Missing GEMINI_MODEL
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return USE_VERTEX_AI when Google env vars are set', () => {
|
||||
process.env['GOOGLE_API_KEY'] = 'test-key';
|
||||
process.env['GOOGLE_MODEL'] = 'vertex-model';
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_VERTEX_AI);
|
||||
});
|
||||
|
||||
it('should return undefined when Google env vars are incomplete', () => {
|
||||
process.env['GOOGLE_API_KEY'] = 'test-key';
|
||||
// Missing GOOGLE_MODEL
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return USE_ANTHROPIC when Anthropic env vars are set', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'test-key';
|
||||
process.env['ANTHROPIC_MODEL'] = 'claude-3';
|
||||
process.env['ANTHROPIC_BASE_URL'] = 'https://api.anthropic.com';
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_ANTHROPIC);
|
||||
});
|
||||
|
||||
it('should return undefined when Anthropic env vars are incomplete', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'test-key';
|
||||
process.env['ANTHROPIC_MODEL'] = 'claude-3';
|
||||
// Missing ANTHROPIC_BASE_URL
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prioritize QWEN_OAUTH over other auth types when explicitly set', () => {
|
||||
process.env['QWEN_OAUTH'] = 'true';
|
||||
process.env['OPENAI_API_KEY'] = 'test-key';
|
||||
process.env['OPENAI_MODEL'] = 'gpt-4';
|
||||
process.env['OPENAI_BASE_URL'] = 'https://api.openai.com';
|
||||
|
||||
// QWEN_OAUTH is checked first, so it should be returned even when other auth vars are set
|
||||
expect(getAuthTypeFromEnv()).toBe(AuthType.QWEN_OAUTH);
|
||||
});
|
||||
|
||||
it('should return undefined when no auth env vars are set', () => {
|
||||
expect(getAuthTypeFromEnv()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCliGenerationConfig', () => {
|
||||
const originalEnv = process.env;
|
||||
const originalConsoleWarn = console.warn;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
console.warn = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
console.warn = originalConsoleWarn;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeMockSettings(overrides?: Partial<Settings>): Settings {
|
||||
return {
|
||||
model: { name: 'default-model' },
|
||||
security: {
|
||||
auth: {
|
||||
apiKey: 'settings-api-key',
|
||||
baseUrl: 'https://settings.example.com',
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
} as Settings;
|
||||
}
|
||||
|
||||
it('should resolve config from argv with highest precedence', () => {
|
||||
const argv = {
|
||||
model: 'argv-model',
|
||||
openaiApiKey: 'argv-key',
|
||||
openaiBaseUrl: 'https://argv.example.com',
|
||||
};
|
||||
const settings = makeMockSettings();
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'argv-model',
|
||||
apiKey: 'argv-key',
|
||||
baseUrl: 'https://argv.example.com',
|
||||
},
|
||||
sources: {
|
||||
model: { kind: 'cli', detail: '--model' },
|
||||
apiKey: { kind: 'cli', detail: '--openaiApiKey' },
|
||||
baseUrl: { kind: 'cli', detail: '--openaiBaseUrl' },
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.model).toBe('argv-model');
|
||||
expect(result.apiKey).toBe('argv-key');
|
||||
expect(result.baseUrl).toBe('https://argv.example.com');
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cli: {
|
||||
model: 'argv-model',
|
||||
apiKey: 'argv-key',
|
||||
baseUrl: 'https://argv.example.com',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve config from settings when argv is not provided', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings({
|
||||
model: { name: 'settings-model' },
|
||||
security: {
|
||||
auth: {
|
||||
apiKey: 'settings-key',
|
||||
baseUrl: 'https://settings.example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'settings-model',
|
||||
apiKey: 'settings-key',
|
||||
baseUrl: 'https://settings.example.com',
|
||||
},
|
||||
sources: {
|
||||
model: { kind: 'settings', detail: 'model.name' },
|
||||
apiKey: { kind: 'settings', detail: 'security.auth.apiKey' },
|
||||
baseUrl: { kind: 'settings', detail: 'security.auth.baseUrl' },
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.model).toBe('settings-model');
|
||||
expect(result.apiKey).toBe('settings-key');
|
||||
expect(result.baseUrl).toBe('https://settings.example.com');
|
||||
});
|
||||
|
||||
it('should merge generationConfig from settings', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings({
|
||||
model: {
|
||||
name: 'test-model',
|
||||
generationConfig: {
|
||||
samplingParams: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
},
|
||||
timeout: 5000,
|
||||
} as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
samplingParams: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.generationConfig.samplingParams?.temperature).toBe(0.7);
|
||||
expect(result.generationConfig.samplingParams?.max_tokens).toBe(1000);
|
||||
expect(result.generationConfig.timeout).toBe(5000);
|
||||
});
|
||||
|
||||
it('should resolve OpenAI logging from argv', () => {
|
||||
const argv = {
|
||||
openaiLogging: true,
|
||||
openaiLoggingDir: '/custom/log/dir',
|
||||
};
|
||||
const settings = makeMockSettings();
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.generationConfig.enableOpenAILogging).toBe(true);
|
||||
expect(result.generationConfig.openAILoggingDir).toBe('/custom/log/dir');
|
||||
});
|
||||
|
||||
it('should resolve OpenAI logging from settings when argv is undefined', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings({
|
||||
model: {
|
||||
name: 'test-model',
|
||||
enableOpenAILogging: true,
|
||||
openAILoggingDir: '/settings/log/dir',
|
||||
},
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.generationConfig.enableOpenAILogging).toBe(true);
|
||||
expect(result.generationConfig.openAILoggingDir).toBe(
|
||||
'/settings/log/dir',
|
||||
);
|
||||
});
|
||||
|
||||
it('should default OpenAI logging to false when not provided', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings();
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.generationConfig.enableOpenAILogging).toBe(false);
|
||||
});
|
||||
|
||||
it('should find modelProvider from settings when authType and model match', () => {
|
||||
const argv = { model: 'provider-model' };
|
||||
const modelProvider: ProviderModelConfig = {
|
||||
id: 'provider-model',
|
||||
name: 'Provider Model',
|
||||
generationConfig: {
|
||||
samplingParams: { temperature: 0.8 },
|
||||
},
|
||||
};
|
||||
const settings = makeMockSettings({
|
||||
modelProviders: {
|
||||
[AuthType.USE_OPENAI]: [modelProvider],
|
||||
},
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'provider-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProvider,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should find modelProvider from settings.model.name when argv.model is not provided', () => {
|
||||
const argv = {};
|
||||
const modelProvider: ProviderModelConfig = {
|
||||
id: 'settings-model',
|
||||
name: 'Settings Model',
|
||||
generationConfig: {
|
||||
samplingParams: { temperature: 0.9 },
|
||||
},
|
||||
};
|
||||
const settings = makeMockSettings({
|
||||
model: { name: 'settings-model' },
|
||||
modelProviders: {
|
||||
[AuthType.USE_OPENAI]: [modelProvider],
|
||||
},
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'settings-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProvider,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not find modelProvider when authType is undefined', () => {
|
||||
const argv = { model: 'test-model' };
|
||||
const settings = makeMockSettings({
|
||||
modelProviders: {
|
||||
[AuthType.USE_OPENAI]: [{ id: 'test-model', name: 'Test Model' }],
|
||||
},
|
||||
});
|
||||
const selectedAuthType = undefined;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProvider: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not find modelProvider when modelProviders is not an array', () => {
|
||||
const argv = { model: 'test-model' };
|
||||
const settings = makeMockSettings({
|
||||
modelProviders: {
|
||||
[AuthType.USE_OPENAI]: null as unknown as ProviderModelConfig[],
|
||||
},
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProvider: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log warnings from resolveModelConfig', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings();
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: ['Warning 1', 'Warning 2'],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith('Warning 1');
|
||||
expect(console.warn).toHaveBeenCalledWith('Warning 2');
|
||||
});
|
||||
|
||||
it('should use custom env when provided', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings();
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
const customEnv = {
|
||||
OPENAI_API_KEY: 'custom-key',
|
||||
OPENAI_MODEL: 'custom-model',
|
||||
};
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'custom-model',
|
||||
apiKey: 'custom-key',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
env: customEnv,
|
||||
});
|
||||
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: customEnv,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use process.env when env is not provided', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings();
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: process.env,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty strings for missing model, apiKey, and baseUrl', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings();
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: '',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.model).toBe('');
|
||||
expect(result.apiKey).toBe('');
|
||||
expect(result.baseUrl).toBe('');
|
||||
});
|
||||
|
||||
it('should merge resolved config with logging settings', () => {
|
||||
const argv = {
|
||||
openaiLogging: true,
|
||||
};
|
||||
const settings = makeMockSettings({
|
||||
model: {
|
||||
name: 'test-model',
|
||||
generationConfig: {
|
||||
timeout: 5000,
|
||||
} as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://test.com',
|
||||
samplingParams: { temperature: 0.5 },
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.generationConfig).toEqual({
|
||||
model: 'test-model',
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://test.com',
|
||||
samplingParams: { temperature: 0.5 },
|
||||
enableOpenAILogging: true,
|
||||
openAILoggingDir: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle settings without model property', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings({
|
||||
model: undefined as unknown as Settings['model'],
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: '',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.model).toBe('');
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
settings: expect.objectContaining({
|
||||
model: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle settings without security.auth property', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings({
|
||||
security: undefined,
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: '',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
settings: expect.objectContaining({
|
||||
apiKey: undefined,
|
||||
baseUrl: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,20 +44,31 @@ export interface ResolvedCliGenerationConfig {
|
||||
}
|
||||
|
||||
export function getAuthTypeFromEnv(): AuthType | undefined {
|
||||
if (process.env['OPENAI_API_KEY']) {
|
||||
return AuthType.USE_OPENAI;
|
||||
}
|
||||
if (process.env['QWEN_OAUTH']) {
|
||||
return AuthType.QWEN_OAUTH;
|
||||
}
|
||||
|
||||
if (process.env['GEMINI_API_KEY']) {
|
||||
if (
|
||||
process.env['OPENAI_API_KEY'] &&
|
||||
process.env['OPENAI_MODEL'] &&
|
||||
process.env['OPENAI_BASE_URL']
|
||||
) {
|
||||
return AuthType.USE_OPENAI;
|
||||
}
|
||||
|
||||
if (process.env['GEMINI_API_KEY'] && process.env['GEMINI_MODEL']) {
|
||||
return AuthType.USE_GEMINI;
|
||||
}
|
||||
if (process.env['GOOGLE_API_KEY']) {
|
||||
|
||||
if (process.env['GOOGLE_API_KEY'] && process.env['GOOGLE_MODEL']) {
|
||||
return AuthType.USE_VERTEX_AI;
|
||||
}
|
||||
if (process.env['ANTHROPIC_API_KEY']) {
|
||||
|
||||
if (
|
||||
process.env['ANTHROPIC_API_KEY'] &&
|
||||
process.env['ANTHROPIC_MODEL'] &&
|
||||
process.env['ANTHROPIC_BASE_URL']
|
||||
) {
|
||||
return AuthType.USE_ANTHROPIC;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.1-preview.0",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -708,12 +708,15 @@ export class Config {
|
||||
* Exclusive for `OpenAIKeyPrompt` to update credentials via `/auth`
|
||||
* Delegates to ModelsConfig.
|
||||
*/
|
||||
updateCredentials(credentials: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
}): void {
|
||||
this._modelsConfig.updateCredentials(credentials);
|
||||
updateCredentials(
|
||||
credentials: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
},
|
||||
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
|
||||
): void {
|
||||
this._modelsConfig.updateCredentials(credentials, settingsGenerationConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('ModelsConfig', () => {
|
||||
expect(gc.apiKeyEnvKey).toBe('API_KEY_SHARED');
|
||||
});
|
||||
|
||||
it('should preserve settings generationConfig when model is updated via updateCredentials even if it matches modelProviders', () => {
|
||||
it('should use provider config when modelId exists in registry even after updateCredentials', () => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
@@ -213,7 +213,7 @@ describe('ModelsConfig', () => {
|
||||
initialAuthType: AuthType.USE_OPENAI,
|
||||
modelProvidersConfig,
|
||||
generationConfig: {
|
||||
model: 'model-a',
|
||||
model: 'custom-model',
|
||||
samplingParams: { temperature: 0.9, max_tokens: 999 },
|
||||
timeout: 9999,
|
||||
maxRetries: 9,
|
||||
@@ -235,30 +235,30 @@ describe('ModelsConfig', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// User manually updates the model via updateCredentials (e.g. key prompt flow).
|
||||
// Even if the model ID matches a modelProviders entry, we must not apply provider defaults
|
||||
// that would overwrite settings.model.generationConfig.
|
||||
modelsConfig.updateCredentials({ model: 'model-a' });
|
||||
// User manually updates credentials via updateCredentials.
|
||||
// Note: In practice, handleAuthSelect prevents using a modelId that matches a provider model,
|
||||
// but if syncAfterAuthRefresh is called with a modelId that exists in registry,
|
||||
// we should use provider config.
|
||||
modelsConfig.updateCredentials({ apiKey: 'manual-key' });
|
||||
|
||||
modelsConfig.syncAfterAuthRefresh(
|
||||
AuthType.USE_OPENAI,
|
||||
modelsConfig.getModel(),
|
||||
);
|
||||
// syncAfterAuthRefresh with a modelId that exists in registry should use provider config
|
||||
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'model-a');
|
||||
|
||||
const gc = currentGenerationConfig(modelsConfig);
|
||||
expect(gc.model).toBe('model-a');
|
||||
expect(gc.samplingParams?.temperature).toBe(0.9);
|
||||
expect(gc.samplingParams?.max_tokens).toBe(999);
|
||||
expect(gc.timeout).toBe(9999);
|
||||
expect(gc.maxRetries).toBe(9);
|
||||
// Provider config should be applied
|
||||
expect(gc.samplingParams?.temperature).toBe(0.1);
|
||||
expect(gc.samplingParams?.max_tokens).toBe(123);
|
||||
expect(gc.timeout).toBe(111);
|
||||
expect(gc.maxRetries).toBe(1);
|
||||
});
|
||||
|
||||
it('should preserve settings generationConfig across multiple auth refreshes after updateCredentials', () => {
|
||||
it('should preserve settings generationConfig when modelId does not exist in registry', () => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
id: 'model-a',
|
||||
name: 'Model A',
|
||||
id: 'provider-model',
|
||||
name: 'Provider Model',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
envKey: 'API_KEY_A',
|
||||
generationConfig: {
|
||||
@@ -270,11 +270,12 @@ describe('ModelsConfig', () => {
|
||||
],
|
||||
};
|
||||
|
||||
// Simulate settings with a custom model (not in registry)
|
||||
const modelsConfig = new ModelsConfig({
|
||||
initialAuthType: AuthType.USE_OPENAI,
|
||||
modelProvidersConfig,
|
||||
generationConfig: {
|
||||
model: 'model-a',
|
||||
model: 'custom-model',
|
||||
samplingParams: { temperature: 0.9, max_tokens: 999 },
|
||||
timeout: 9999,
|
||||
maxRetries: 9,
|
||||
@@ -296,25 +297,21 @@ describe('ModelsConfig', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// User manually sets credentials for a custom model (not in registry)
|
||||
modelsConfig.updateCredentials({
|
||||
apiKey: 'manual-key',
|
||||
baseUrl: 'https://manual.example.com/v1',
|
||||
model: 'model-a',
|
||||
model: 'custom-model',
|
||||
});
|
||||
|
||||
// First auth refresh
|
||||
modelsConfig.syncAfterAuthRefresh(
|
||||
AuthType.USE_OPENAI,
|
||||
modelsConfig.getModel(),
|
||||
);
|
||||
// First auth refresh - modelId doesn't exist in registry, so credentials should be preserved
|
||||
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'custom-model');
|
||||
// Second auth refresh should still preserve settings generationConfig
|
||||
modelsConfig.syncAfterAuthRefresh(
|
||||
AuthType.USE_OPENAI,
|
||||
modelsConfig.getModel(),
|
||||
);
|
||||
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'custom-model');
|
||||
|
||||
const gc = currentGenerationConfig(modelsConfig);
|
||||
expect(gc.model).toBe('model-a');
|
||||
expect(gc.model).toBe('custom-model');
|
||||
// Settings-sourced generation config should be preserved since modelId doesn't exist in registry
|
||||
expect(gc.samplingParams?.temperature).toBe(0.9);
|
||||
expect(gc.samplingParams?.max_tokens).toBe(999);
|
||||
expect(gc.timeout).toBe(9999);
|
||||
@@ -681,4 +678,120 @@ describe('ModelsConfig', () => {
|
||||
expect(modelsConfig.getModel()).toBe('updated-model');
|
||||
expect(modelsConfig.getGenerationConfig().model).toBe('updated-model');
|
||||
});
|
||||
|
||||
describe('getAllAvailableModels', () => {
|
||||
it('should return all models across all authTypes', () => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
id: 'openai-model-1',
|
||||
name: 'OpenAI Model 1',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
envKey: 'OPENAI_API_KEY',
|
||||
},
|
||||
{
|
||||
id: 'openai-model-2',
|
||||
name: 'OpenAI Model 2',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
envKey: 'OPENAI_API_KEY',
|
||||
},
|
||||
],
|
||||
anthropic: [
|
||||
{
|
||||
id: 'anthropic-model-1',
|
||||
name: 'Anthropic Model 1',
|
||||
baseUrl: 'https://api.anthropic.com/v1',
|
||||
envKey: 'ANTHROPIC_API_KEY',
|
||||
},
|
||||
],
|
||||
gemini: [
|
||||
{
|
||||
id: 'gemini-model-1',
|
||||
name: 'Gemini Model 1',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1',
|
||||
envKey: 'GEMINI_API_KEY',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const modelsConfig = new ModelsConfig({
|
||||
modelProvidersConfig,
|
||||
});
|
||||
|
||||
const allModels = modelsConfig.getAllAvailableModels();
|
||||
|
||||
// Should include qwen-oauth models (hard-coded)
|
||||
const qwenModels = allModels.filter(
|
||||
(m) => m.authType === AuthType.QWEN_OAUTH,
|
||||
);
|
||||
expect(qwenModels.length).toBeGreaterThan(0);
|
||||
|
||||
// Should include openai models
|
||||
const openaiModels = allModels.filter(
|
||||
(m) => m.authType === AuthType.USE_OPENAI,
|
||||
);
|
||||
expect(openaiModels.length).toBe(2);
|
||||
expect(openaiModels.map((m) => m.id)).toContain('openai-model-1');
|
||||
expect(openaiModels.map((m) => m.id)).toContain('openai-model-2');
|
||||
|
||||
// Should include anthropic models
|
||||
const anthropicModels = allModels.filter(
|
||||
(m) => m.authType === AuthType.USE_ANTHROPIC,
|
||||
);
|
||||
expect(anthropicModels.length).toBe(1);
|
||||
expect(anthropicModels[0].id).toBe('anthropic-model-1');
|
||||
|
||||
// Should include gemini models
|
||||
const geminiModels = allModels.filter(
|
||||
(m) => m.authType === AuthType.USE_GEMINI,
|
||||
);
|
||||
expect(geminiModels.length).toBe(1);
|
||||
expect(geminiModels[0].id).toBe('gemini-model-1');
|
||||
});
|
||||
|
||||
it('should return empty array when no models are registered', () => {
|
||||
const modelsConfig = new ModelsConfig();
|
||||
|
||||
const allModels = modelsConfig.getAllAvailableModels();
|
||||
|
||||
// Should still include qwen-oauth models (hard-coded)
|
||||
expect(allModels.length).toBeGreaterThan(0);
|
||||
const qwenModels = allModels.filter(
|
||||
(m) => m.authType === AuthType.QWEN_OAUTH,
|
||||
);
|
||||
expect(qwenModels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return models with correct structure', () => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
id: 'test-model',
|
||||
name: 'Test Model',
|
||||
description: 'A test model',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
envKey: 'TEST_API_KEY',
|
||||
capabilities: {
|
||||
vision: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const modelsConfig = new ModelsConfig({
|
||||
modelProvidersConfig,
|
||||
});
|
||||
|
||||
const allModels = modelsConfig.getAllAvailableModels();
|
||||
const testModel = allModels.find((m) => m.id === 'test-model');
|
||||
|
||||
expect(testModel).toBeDefined();
|
||||
expect(testModel?.id).toBe('test-model');
|
||||
expect(testModel?.label).toBe('Test Model');
|
||||
expect(testModel?.description).toBe('A test model');
|
||||
expect(testModel?.authType).toBe(AuthType.USE_OPENAI);
|
||||
expect(testModel?.isVision).toBe(true);
|
||||
expect(testModel?.capabilities?.vision).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,6 +203,18 @@ export class ModelsConfig {
|
||||
return this.modelRegistry.getModelsForAuthType(authType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available models across all authTypes
|
||||
*/
|
||||
getAllAvailableModels(): AvailableModel[] {
|
||||
const allModels: AvailableModel[] = [];
|
||||
for (const authType of Object.values(AuthType)) {
|
||||
const models = this.modelRegistry.getModelsForAuthType(authType);
|
||||
allModels.push(...models);
|
||||
}
|
||||
return allModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model exists for the given authType
|
||||
*/
|
||||
@@ -307,6 +319,33 @@ export class ModelsConfig {
|
||||
return this.generationConfigSources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge settings generation config, preserving existing values.
|
||||
* Used when provider-sourced config is cleared but settings should still apply.
|
||||
*/
|
||||
mergeSettingsGenerationConfig(
|
||||
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
|
||||
): void {
|
||||
if (!settingsGenerationConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const field of MODEL_GENERATION_CONFIG_FIELDS) {
|
||||
if (
|
||||
!(field in this._generationConfig) &&
|
||||
field in settingsGenerationConfig
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this._generationConfig as any)[field] =
|
||||
settingsGenerationConfig[field];
|
||||
this.generationConfigSources[field] = {
|
||||
kind: 'settings',
|
||||
detail: `model.generationConfig.${field}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update credentials in generation config.
|
||||
* Sets a flag to prevent syncAfterAuthRefresh from overriding these credentials.
|
||||
@@ -314,12 +353,20 @@ export class ModelsConfig {
|
||||
* When credentials are manually set, we clear all provider-sourced configuration
|
||||
* to maintain provider atomicity (either fully applied or not at all).
|
||||
* Other layers (CLI, env, settings, defaults) will participate in resolve.
|
||||
*
|
||||
* @param settingsGenerationConfig Optional generation config from settings.json
|
||||
* to merge after clearing provider-sourced config.
|
||||
* This ensures settings.model.generationConfig fields
|
||||
* (e.g., samplingParams, timeout) are preserved.
|
||||
*/
|
||||
updateCredentials(credentials: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
}): void {
|
||||
updateCredentials(
|
||||
credentials: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
},
|
||||
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
|
||||
): void {
|
||||
/**
|
||||
* If any fields are updated here, we treat the resulting config as manually overridden
|
||||
* and avoid applying modelProvider defaults during the next auth refresh.
|
||||
@@ -359,6 +406,14 @@ export class ModelsConfig {
|
||||
this.strictModelProviderSelection = false;
|
||||
// Clear apiKeyEnvKey to prevent validation from requiring environment variable
|
||||
this._generationConfig.apiKeyEnvKey = undefined;
|
||||
|
||||
// After clearing provider-sourced config, merge settings.model.generationConfig
|
||||
// to ensure fields like samplingParams, timeout, etc. are preserved.
|
||||
// This follows the resolution strategy where settings.model.generationConfig
|
||||
// has lower priority than programmatic overrides but should still be applied.
|
||||
if (settingsGenerationConfig) {
|
||||
this.mergeSettingsGenerationConfig(settingsGenerationConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -587,50 +642,88 @@ export class ModelsConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by Config.refreshAuth to sync state after auth refresh.
|
||||
*
|
||||
* IMPORTANT: If credentials were manually set via updateCredentials(),
|
||||
* we should NOT override them with modelProvider defaults.
|
||||
* This handles the case where user inputs credentials via OpenAIKeyPrompt
|
||||
* after removing environment variables for a previously selected model.
|
||||
* Sync state after auth refresh with fallback strategy:
|
||||
* 1. If modelId can be found in modelRegistry, use the config from modelRegistry.
|
||||
* 2. Otherwise, if existing credentials exist in resolved generationConfig from other sources
|
||||
* (not modelProviders), preserve them and update authType/modelId only.
|
||||
* 3. Otherwise, fall back to default model for the authType.
|
||||
* 4. If no default is available, leave the generationConfig incomplete and let
|
||||
* resolveContentGeneratorConfigWithSources throw exceptions as expected.
|
||||
*/
|
||||
syncAfterAuthRefresh(authType: AuthType, modelId?: string): void {
|
||||
// Check if we have manually set credentials that should be preserved
|
||||
const preserveManualCredentials = this.hasManualCredentials;
|
||||
this.strictModelProviderSelection = false;
|
||||
const previousAuthType = this.currentAuthType;
|
||||
this.currentAuthType = authType;
|
||||
|
||||
// If credentials were manually set, don't apply modelProvider defaults
|
||||
// Just update the authType and preserve the manually set credentials
|
||||
if (preserveManualCredentials && authType === AuthType.USE_OPENAI) {
|
||||
this.strictModelProviderSelection = false;
|
||||
this.currentAuthType = authType;
|
||||
// Step 1: If modelId exists in registry, always use config from modelRegistry
|
||||
// Manual credentials won't have a modelId that matches a provider model (handleAuthSelect prevents it),
|
||||
// so if modelId exists in registry, we should always use provider config.
|
||||
// This handles provider switching even within the same authType.
|
||||
if (modelId && this.modelRegistry.hasModel(authType, modelId)) {
|
||||
const resolved = this.modelRegistry.getModel(authType, modelId);
|
||||
if (resolved) {
|
||||
this.applyResolvedModelDefaults(resolved);
|
||||
this.strictModelProviderSelection = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Check if there are existing credentials from other sources (not modelProviders)
|
||||
const apiKeySource = this.generationConfigSources['apiKey'];
|
||||
const baseUrlSource = this.generationConfigSources['baseUrl'];
|
||||
const hasExistingCredentials =
|
||||
(this._generationConfig.apiKey &&
|
||||
apiKeySource?.kind !== 'modelProviders') ||
|
||||
(this._generationConfig.baseUrl &&
|
||||
baseUrlSource?.kind !== 'modelProviders');
|
||||
|
||||
// Only preserve credentials if:
|
||||
// 1. AuthType hasn't changed (credentials are authType-specific), AND
|
||||
// 2. The modelId doesn't exist in the registry (if it did, we would have used provider config in Step 1), AND
|
||||
// 3. Either:
|
||||
// a. We have manual credentials (set via updateCredentials), OR
|
||||
// b. We have existing credentials
|
||||
// Note: Even if authType hasn't changed, switching to a different provider model (that exists in registry)
|
||||
// will use provider config (Step 1), not preserve old credentials. This ensures credentials change when
|
||||
// switching providers, independent of authType changes.
|
||||
const isAuthTypeChange = previousAuthType !== authType;
|
||||
const shouldPreserveCredentials =
|
||||
!isAuthTypeChange &&
|
||||
(modelId === undefined ||
|
||||
!this.modelRegistry.hasModel(authType, modelId)) &&
|
||||
(this.hasManualCredentials || hasExistingCredentials);
|
||||
|
||||
if (shouldPreserveCredentials) {
|
||||
// Preserve existing credentials, just update authType and modelId if provided
|
||||
if (modelId) {
|
||||
this._generationConfig.model = modelId;
|
||||
if (!this.generationConfigSources['model']) {
|
||||
this.generationConfigSources['model'] = {
|
||||
kind: 'programmatic',
|
||||
detail: 'auth refresh (preserved credentials)',
|
||||
};
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.strictModelProviderSelection = false;
|
||||
// Step 3: Fall back to default model for the authType
|
||||
const defaultModel =
|
||||
this.modelRegistry.getDefaultModelForAuthType(authType);
|
||||
if (defaultModel) {
|
||||
this.applyResolvedModelDefaults(defaultModel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (modelId && this.modelRegistry.hasModel(authType, modelId)) {
|
||||
const resolved = this.modelRegistry.getModel(authType, modelId);
|
||||
if (resolved) {
|
||||
// Ensure applyResolvedModelDefaults can correctly apply authType-specific
|
||||
// behavior (e.g., Qwen OAuth placeholder token) by setting currentAuthType
|
||||
// before applying defaults.
|
||||
this.currentAuthType = authType;
|
||||
this.applyResolvedModelDefaults(resolved);
|
||||
}
|
||||
} else {
|
||||
// If the provided modelId doesn't exist in the registry for the new authType,
|
||||
// use the default model for that authType instead of keeping the old model.
|
||||
// This handles the case where switching from one authType (e.g., OPENAI with
|
||||
// env vars) to another (e.g., qwen-oauth) - we should use the default model
|
||||
// for the new authType, not the old model.
|
||||
this.currentAuthType = authType;
|
||||
const defaultModel =
|
||||
this.modelRegistry.getDefaultModelForAuthType(authType);
|
||||
if (defaultModel) {
|
||||
this.applyResolvedModelDefaults(defaultModel);
|
||||
// Step 4: No default available - leave generationConfig incomplete
|
||||
// resolveContentGeneratorConfigWithSources will throw exceptions as expected
|
||||
if (modelId) {
|
||||
this._generationConfig.model = modelId;
|
||||
if (!this.generationConfigSources['model']) {
|
||||
this.generationConfigSources['model'] = {
|
||||
kind: 'programmatic',
|
||||
detail: 'auth refresh (no default model)',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,6 +751,7 @@ describe('getQwenOAuthClient', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
originalFetch = global.fetch;
|
||||
@@ -839,9 +840,7 @@ describe('getQwenOAuthClient', () => {
|
||||
requireCachedCredentials: true,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
|
||||
);
|
||||
).rejects.toThrow('Please use /auth to re-authenticate.');
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1007,6 +1006,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
originalFetch = global.fetch;
|
||||
@@ -1202,6 +1202,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
originalFetch = global.fetch;
|
||||
@@ -1405,6 +1406,7 @@ describe('Browser Launch and Error Handling', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
originalFetch = global.fetch;
|
||||
@@ -2043,6 +2045,7 @@ describe('SharedTokenManager Integration in QwenOAuth2Client', () => {
|
||||
it('should handle TokenManagerError types correctly in getQwenOAuthClient', async () => {
|
||||
const mockConfig = {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
// Test different TokenManagerError types
|
||||
|
||||
@@ -516,9 +516,7 @@ export async function getQwenOAuthClient(
|
||||
}
|
||||
|
||||
if (options?.requireCachedCredentials) {
|
||||
throw new Error(
|
||||
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
|
||||
);
|
||||
throw new Error('Please use /auth to re-authenticate.');
|
||||
}
|
||||
|
||||
// If we couldn't obtain valid credentials via SharedTokenManager, fall back to
|
||||
@@ -740,11 +738,9 @@ async function authWithQwenDeviceFlow(
|
||||
// Emit device authorization event for UI integration immediately
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, deviceAuth);
|
||||
|
||||
// Always show the fallback message in non-interactive environments to ensure
|
||||
// users can see the authorization URL even if browser launching is attempted.
|
||||
// This is critical for headless/remote environments where browser launching
|
||||
// may silently fail without throwing an error.
|
||||
showFallbackMessage(deviceAuth.verification_uri_complete);
|
||||
if (config.isBrowserLaunchSuppressed() || !config.isInteractive()) {
|
||||
showFallbackMessage(deviceAuth.verification_uri_complete);
|
||||
}
|
||||
|
||||
// Try to open browser if not suppressed
|
||||
if (!config.isBrowserLaunchSuppressed()) {
|
||||
|
||||
@@ -112,6 +112,62 @@ You are a helpful assistant with this skill.
|
||||
expect(config.filePath).toBe(validSkillConfig.filePath);
|
||||
});
|
||||
|
||||
it('should parse markdown with CRLF line endings', () => {
|
||||
const markdownCrlf = `---\r
|
||||
name: test-skill\r
|
||||
description: A test skill\r
|
||||
---\r
|
||||
\r
|
||||
You are a helpful assistant with this skill.\r
|
||||
`;
|
||||
|
||||
const config = manager.parseSkillContent(
|
||||
markdownCrlf,
|
||||
validSkillConfig.filePath,
|
||||
'project',
|
||||
);
|
||||
|
||||
expect(config.name).toBe('test-skill');
|
||||
expect(config.description).toBe('A test skill');
|
||||
expect(config.body).toBe('You are a helpful assistant with this skill.');
|
||||
});
|
||||
|
||||
it('should parse markdown with UTF-8 BOM', () => {
|
||||
const markdownWithBom = `\uFEFF---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
You are a helpful assistant with this skill.
|
||||
`;
|
||||
|
||||
const config = manager.parseSkillContent(
|
||||
markdownWithBom,
|
||||
validSkillConfig.filePath,
|
||||
'project',
|
||||
);
|
||||
|
||||
expect(config.name).toBe('test-skill');
|
||||
expect(config.description).toBe('A test skill');
|
||||
});
|
||||
|
||||
it('should parse markdown when body is empty and file ends after frontmatter', () => {
|
||||
const frontmatterOnly = `---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
---`;
|
||||
|
||||
const config = manager.parseSkillContent(
|
||||
frontmatterOnly,
|
||||
validSkillConfig.filePath,
|
||||
'project',
|
||||
);
|
||||
|
||||
expect(config.name).toBe('test-skill');
|
||||
expect(config.description).toBe('A test skill');
|
||||
expect(config.body).toBe('');
|
||||
});
|
||||
|
||||
it('should parse content with allowedTools', () => {
|
||||
const markdownWithTools = `---
|
||||
name: test-skill
|
||||
|
||||
@@ -307,9 +307,11 @@ export class SkillManager {
|
||||
level: SkillLevel,
|
||||
): SkillConfig {
|
||||
try {
|
||||
const normalizedContent = normalizeSkillFileContent(content);
|
||||
|
||||
// Split frontmatter and content
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/;
|
||||
const match = normalizedContent.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Invalid format: missing YAML frontmatter');
|
||||
@@ -556,3 +558,13 @@ export class SkillManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSkillFileContent(content: string): string {
|
||||
// Strip UTF-8 BOM to ensure frontmatter starts at the first character.
|
||||
let normalized = content.replace(/^\uFEFF/, '');
|
||||
|
||||
// Normalize line endings so skills authored on Windows (CRLF) parse correctly.
|
||||
normalized = normalized.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
|
||||
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
|
||||
export const EVENT_SKILL_LAUNCH = 'qwen-code.skill_launch';
|
||||
export const EVENT_AUTH = 'qwen-code.auth';
|
||||
export const EVENT_USER_FEEDBACK = 'qwen-code.user_feedback';
|
||||
|
||||
// Performance Events
|
||||
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';
|
||||
|
||||
@@ -45,6 +45,7 @@ export {
|
||||
logNextSpeakerCheck,
|
||||
logAuth,
|
||||
logSkillLaunch,
|
||||
logUserFeedback,
|
||||
} from './loggers.js';
|
||||
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
|
||||
export {
|
||||
@@ -65,6 +66,8 @@ export {
|
||||
NextSpeakerCheckEvent,
|
||||
AuthEvent,
|
||||
SkillLaunchEvent,
|
||||
UserFeedbackEvent,
|
||||
UserFeedbackRating,
|
||||
} from './types.js';
|
||||
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
|
||||
export type { TelemetryEvent } from './types.js';
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
EVENT_INVALID_CHUNK,
|
||||
EVENT_AUTH,
|
||||
EVENT_SKILL_LAUNCH,
|
||||
EVENT_USER_FEEDBACK,
|
||||
} from './constants.js';
|
||||
import {
|
||||
recordApiErrorMetrics,
|
||||
@@ -86,6 +87,7 @@ import type {
|
||||
InvalidChunkEvent,
|
||||
AuthEvent,
|
||||
SkillLaunchEvent,
|
||||
UserFeedbackEvent,
|
||||
} from './types.js';
|
||||
import type { UiEvent } from './uiTelemetry.js';
|
||||
import { uiTelemetryService } from './uiTelemetry.js';
|
||||
@@ -887,3 +889,32 @@ export function logSkillLaunch(config: Config, event: SkillLaunchEvent): void {
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
export function logUserFeedback(
|
||||
config: Config,
|
||||
event: UserFeedbackEvent,
|
||||
): void {
|
||||
const uiEvent = {
|
||||
...event,
|
||||
'event.name': EVENT_USER_FEEDBACK,
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
} as UiEvent;
|
||||
uiTelemetryService.addEvent(uiEvent);
|
||||
config.getChatRecordingService()?.recordUiTelemetryEvent(uiEvent);
|
||||
QwenLogger.getInstance(config)?.logUserFeedbackEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
...getCommonAttributes(config),
|
||||
...event,
|
||||
'event.name': EVENT_USER_FEEDBACK,
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
};
|
||||
|
||||
const logger = logs.getLogger(SERVICE_NAME);
|
||||
const logRecord: LogRecord = {
|
||||
body: `User feedback: Rating ${event.rating} for session ${event.session_id}.`,
|
||||
attributes,
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
ExtensionDisableEvent,
|
||||
AuthEvent,
|
||||
SkillLaunchEvent,
|
||||
UserFeedbackEvent,
|
||||
RipgrepFallbackEvent,
|
||||
EndSessionEvent,
|
||||
} from '../types.js';
|
||||
@@ -842,6 +843,21 @@ export class QwenLogger {
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logUserFeedbackEvent(event: UserFeedbackEvent): void {
|
||||
const rumEvent = this.createActionEvent('user', 'user_feedback', {
|
||||
properties: {
|
||||
session_id: event.session_id,
|
||||
rating: event.rating,
|
||||
model: event.model,
|
||||
approval_mode: event.approval_mode,
|
||||
prompt_id: event.prompt_id || '',
|
||||
},
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logChatCompressionEvent(event: ChatCompressionEvent): void {
|
||||
const rumEvent = this.createActionEvent('misc', 'chat_compression', {
|
||||
properties: {
|
||||
|
||||
@@ -757,6 +757,38 @@ export class SkillLaunchEvent implements BaseTelemetryEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export enum UserFeedbackRating {
|
||||
BAD = 1,
|
||||
FINE = 2,
|
||||
GOOD = 3,
|
||||
}
|
||||
|
||||
export class UserFeedbackEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'user_feedback';
|
||||
'event.timestamp': string;
|
||||
session_id: string;
|
||||
rating: UserFeedbackRating;
|
||||
model: string;
|
||||
approval_mode: string;
|
||||
prompt_id?: string;
|
||||
|
||||
constructor(
|
||||
session_id: string,
|
||||
rating: UserFeedbackRating,
|
||||
model: string,
|
||||
approval_mode: string,
|
||||
prompt_id?: string,
|
||||
) {
|
||||
this['event.name'] = 'user_feedback';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.session_id = session_id;
|
||||
this.rating = rating;
|
||||
this.model = model;
|
||||
this.approval_mode = approval_mode;
|
||||
this.prompt_id = prompt_id;
|
||||
}
|
||||
}
|
||||
|
||||
export type TelemetryEvent =
|
||||
| StartSessionEvent
|
||||
| EndSessionEvent
|
||||
@@ -786,7 +818,8 @@ export type TelemetryEvent =
|
||||
| ToolOutputTruncatedEvent
|
||||
| ModelSlashCommandEvent
|
||||
| AuthEvent
|
||||
| SkillLaunchEvent;
|
||||
| SkillLaunchEvent
|
||||
| UserFeedbackEvent;
|
||||
|
||||
export class ExtensionDisableEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'extension_disable';
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { bfsFileSearch } from './bfsFileSearch.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
|
||||
describe('bfsFileSearch', () => {
|
||||
let testRootDir: string;
|
||||
|
||||
async function createEmptyDir(...pathSegments: string[]) {
|
||||
const fullPath = path.join(testRootDir, ...pathSegments);
|
||||
await fsPromises.mkdir(fullPath, { recursive: true });
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
async function createTestFile(content: string, ...pathSegments: string[]) {
|
||||
const fullPath = path.join(testRootDir, ...pathSegments);
|
||||
await fsPromises.mkdir(path.dirname(fullPath), { recursive: true });
|
||||
await fsPromises.writeFile(fullPath, content);
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
testRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'bfs-file-search-test-'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fsPromises.rm(testRootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should find a file in the root directory', async () => {
|
||||
const targetFilePath = await createTestFile('content', 'target.txt');
|
||||
const result = await bfsFileSearch(testRootDir, { fileName: 'target.txt' });
|
||||
expect(result).toEqual([targetFilePath]);
|
||||
});
|
||||
|
||||
it('should find a file in a nested directory', async () => {
|
||||
const targetFilePath = await createTestFile(
|
||||
'content',
|
||||
'a',
|
||||
'b',
|
||||
'target.txt',
|
||||
);
|
||||
const result = await bfsFileSearch(testRootDir, { fileName: 'target.txt' });
|
||||
expect(result).toEqual([targetFilePath]);
|
||||
});
|
||||
|
||||
it('should find multiple files with the same name', async () => {
|
||||
const targetFilePath1 = await createTestFile('content1', 'a', 'target.txt');
|
||||
const targetFilePath2 = await createTestFile('content2', 'b', 'target.txt');
|
||||
const result = await bfsFileSearch(testRootDir, { fileName: 'target.txt' });
|
||||
result.sort();
|
||||
expect(result).toEqual([targetFilePath1, targetFilePath2].sort());
|
||||
});
|
||||
|
||||
it('should return an empty array if no file is found', async () => {
|
||||
await createTestFile('content', 'other.txt');
|
||||
const result = await bfsFileSearch(testRootDir, { fileName: 'target.txt' });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore directories specified in ignoreDirs', async () => {
|
||||
await createTestFile('content', 'ignored', 'target.txt');
|
||||
const targetFilePath = await createTestFile(
|
||||
'content',
|
||||
'not-ignored',
|
||||
'target.txt',
|
||||
);
|
||||
const result = await bfsFileSearch(testRootDir, {
|
||||
fileName: 'target.txt',
|
||||
ignoreDirs: ['ignored'],
|
||||
});
|
||||
expect(result).toEqual([targetFilePath]);
|
||||
});
|
||||
|
||||
it('should respect the maxDirs limit and not find the file', async () => {
|
||||
await createTestFile('content', 'a', 'b', 'c', 'target.txt');
|
||||
const result = await bfsFileSearch(testRootDir, {
|
||||
fileName: 'target.txt',
|
||||
maxDirs: 3,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should respect the maxDirs limit and find the file', async () => {
|
||||
const targetFilePath = await createTestFile(
|
||||
'content',
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'target.txt',
|
||||
);
|
||||
const result = await bfsFileSearch(testRootDir, {
|
||||
fileName: 'target.txt',
|
||||
maxDirs: 4,
|
||||
});
|
||||
expect(result).toEqual([targetFilePath]);
|
||||
});
|
||||
|
||||
describe('with FileDiscoveryService', () => {
|
||||
let projectRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectRoot = await createEmptyDir('project');
|
||||
});
|
||||
|
||||
it('should ignore gitignored files', async () => {
|
||||
await createEmptyDir('project', '.git');
|
||||
await createTestFile('node_modules/', 'project', '.gitignore');
|
||||
await createTestFile('content', 'project', 'node_modules', 'target.txt');
|
||||
const targetFilePath = await createTestFile(
|
||||
'content',
|
||||
'project',
|
||||
'not-ignored',
|
||||
'target.txt',
|
||||
);
|
||||
|
||||
const fileService = new FileDiscoveryService(projectRoot);
|
||||
const result = await bfsFileSearch(projectRoot, {
|
||||
fileName: 'target.txt',
|
||||
fileService,
|
||||
fileFilteringOptions: {
|
||||
respectGitIgnore: true,
|
||||
respectQwenIgnore: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([targetFilePath]);
|
||||
});
|
||||
|
||||
it('should ignore qwenignored files', async () => {
|
||||
await createTestFile('node_modules/', 'project', '.qwenignore');
|
||||
await createTestFile('content', 'project', 'node_modules', 'target.txt');
|
||||
const targetFilePath = await createTestFile(
|
||||
'content',
|
||||
'project',
|
||||
'not-ignored',
|
||||
'target.txt',
|
||||
);
|
||||
|
||||
const fileService = new FileDiscoveryService(projectRoot);
|
||||
const result = await bfsFileSearch(projectRoot, {
|
||||
fileName: 'target.txt',
|
||||
fileService,
|
||||
fileFilteringOptions: {
|
||||
respectGitIgnore: false,
|
||||
respectQwenIgnore: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([targetFilePath]);
|
||||
});
|
||||
|
||||
it('should not ignore files if respect flags are false', async () => {
|
||||
await createEmptyDir('project', '.git');
|
||||
await createTestFile('node_modules/', 'project', '.gitignore');
|
||||
const target1 = await createTestFile(
|
||||
'content',
|
||||
'project',
|
||||
'node_modules',
|
||||
'target.txt',
|
||||
);
|
||||
const target2 = await createTestFile(
|
||||
'content',
|
||||
'project',
|
||||
'not-ignored',
|
||||
'target.txt',
|
||||
);
|
||||
|
||||
const fileService = new FileDiscoveryService(projectRoot);
|
||||
const result = await bfsFileSearch(projectRoot, {
|
||||
fileName: 'target.txt',
|
||||
fileService,
|
||||
fileFilteringOptions: {
|
||||
respectGitIgnore: false,
|
||||
respectQwenIgnore: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.sort()).toEqual([target1, target2].sort());
|
||||
});
|
||||
});
|
||||
|
||||
it('should find all files in a complex directory structure', async () => {
|
||||
// Create a complex directory structure to test correctness at scale
|
||||
// without flaky performance checks.
|
||||
const numDirs = 50;
|
||||
const numFilesPerDir = 2;
|
||||
const numTargetDirs = 10;
|
||||
|
||||
const dirCreationPromises: Array<Promise<unknown>> = [];
|
||||
for (let i = 0; i < numDirs; i++) {
|
||||
dirCreationPromises.push(createEmptyDir(`dir${i}`));
|
||||
dirCreationPromises.push(createEmptyDir(`dir${i}`, 'subdir1'));
|
||||
dirCreationPromises.push(createEmptyDir(`dir${i}`, 'subdir2'));
|
||||
dirCreationPromises.push(createEmptyDir(`dir${i}`, 'subdir1', 'deep'));
|
||||
}
|
||||
await Promise.all(dirCreationPromises);
|
||||
|
||||
const fileCreationPromises: Array<Promise<string>> = [];
|
||||
for (let i = 0; i < numTargetDirs; i++) {
|
||||
// Add target files in some directories
|
||||
fileCreationPromises.push(
|
||||
createTestFile('content', `dir${i}`, 'QWEN.md'),
|
||||
);
|
||||
fileCreationPromises.push(
|
||||
createTestFile('content', `dir${i}`, 'subdir1', 'QWEN.md'),
|
||||
);
|
||||
}
|
||||
const expectedFiles = await Promise.all(fileCreationPromises);
|
||||
|
||||
const result = await bfsFileSearch(testRootDir, {
|
||||
fileName: 'QWEN.md',
|
||||
// Provide a generous maxDirs limit to ensure it doesn't prematurely stop
|
||||
// in this large test case. Total dirs created is 200.
|
||||
maxDirs: 250,
|
||||
});
|
||||
|
||||
// Verify we found the exact files we created
|
||||
expect(result.length).toBe(numTargetDirs * numFilesPerDir);
|
||||
expect(result.sort()).toEqual(expectedFiles.sort());
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import type { FileFilteringOptions } from '../config/constants.js';
|
||||
// Simple console logger for now.
|
||||
// TODO: Integrate with a more robust server-side logger.
|
||||
const logger = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
debug: (...args: any[]) => console.debug('[DEBUG] [BfsFileSearch]', ...args),
|
||||
};
|
||||
|
||||
interface BfsFileSearchOptions {
|
||||
fileName: string;
|
||||
ignoreDirs?: string[];
|
||||
maxDirs?: number;
|
||||
debug?: boolean;
|
||||
fileService?: FileDiscoveryService;
|
||||
fileFilteringOptions?: FileFilteringOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a breadth-first search for a specific file within a directory structure.
|
||||
*
|
||||
* @param rootDir The directory to start the search from.
|
||||
* @param options Configuration for the search.
|
||||
* @returns A promise that resolves to an array of paths where the file was found.
|
||||
*/
|
||||
export async function bfsFileSearch(
|
||||
rootDir: string,
|
||||
options: BfsFileSearchOptions,
|
||||
): Promise<string[]> {
|
||||
const {
|
||||
fileName,
|
||||
ignoreDirs = [],
|
||||
maxDirs = Infinity,
|
||||
debug = false,
|
||||
fileService,
|
||||
} = options;
|
||||
const foundFiles: string[] = [];
|
||||
const queue: string[] = [rootDir];
|
||||
const visited = new Set<string>();
|
||||
let scannedDirCount = 0;
|
||||
let queueHead = 0; // Pointer-based queue head to avoid expensive splice operations
|
||||
|
||||
// Convert ignoreDirs array to Set for O(1) lookup performance
|
||||
const ignoreDirsSet = new Set(ignoreDirs);
|
||||
|
||||
// Process directories in parallel batches for maximum performance
|
||||
const PARALLEL_BATCH_SIZE = 15; // Parallel processing batch size for optimal performance
|
||||
|
||||
while (queueHead < queue.length && scannedDirCount < maxDirs) {
|
||||
// Fill batch with unvisited directories up to the desired size
|
||||
const batchSize = Math.min(PARALLEL_BATCH_SIZE, maxDirs - scannedDirCount);
|
||||
const currentBatch = [];
|
||||
while (currentBatch.length < batchSize && queueHead < queue.length) {
|
||||
const currentDir = queue[queueHead];
|
||||
queueHead++;
|
||||
if (!visited.has(currentDir)) {
|
||||
visited.add(currentDir);
|
||||
currentBatch.push(currentDir);
|
||||
}
|
||||
}
|
||||
scannedDirCount += currentBatch.length;
|
||||
|
||||
if (currentBatch.length === 0) continue;
|
||||
|
||||
if (debug) {
|
||||
logger.debug(
|
||||
`Scanning [${scannedDirCount}/${maxDirs}]: batch of ${currentBatch.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Read directories in parallel instead of one by one
|
||||
const readPromises = currentBatch.map(async (currentDir) => {
|
||||
try {
|
||||
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
return { currentDir, entries };
|
||||
} catch (error) {
|
||||
// Warn user that a directory could not be read, as this affects search results.
|
||||
const message = (error as Error)?.message ?? 'Unknown error';
|
||||
console.warn(
|
||||
`[WARN] Skipping unreadable directory: ${currentDir} (${message})`,
|
||||
);
|
||||
if (debug) {
|
||||
logger.debug(`Full error for ${currentDir}:`, error);
|
||||
}
|
||||
return { currentDir, entries: [] };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(readPromises);
|
||||
|
||||
for (const { currentDir, entries } of results) {
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
const isDirectory = entry.isDirectory();
|
||||
const isMatchingFile = entry.isFile() && entry.name === fileName;
|
||||
|
||||
if (!isDirectory && !isMatchingFile) {
|
||||
continue;
|
||||
}
|
||||
if (isDirectory && ignoreDirsSet.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
fileService?.shouldIgnoreFile(fullPath, {
|
||||
respectGitIgnore: options.fileFilteringOptions?.respectGitIgnore,
|
||||
respectQwenIgnore: options.fileFilteringOptions?.respectQwenIgnore,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
queue.push(fullPath);
|
||||
} else {
|
||||
foundFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return foundFiles;
|
||||
}
|
||||
@@ -209,7 +209,7 @@ describe('loadServerHierarchicalMemory', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should load context files by downward traversal with custom filename', async () => {
|
||||
it('should load context files from CWD with custom filename (not subdirectories)', async () => {
|
||||
const customFilename = 'LOCAL_CONTEXT.md';
|
||||
setGeminiMdFilename(customFilename);
|
||||
|
||||
@@ -228,9 +228,10 @@ describe('loadServerHierarchicalMemory', () => {
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
);
|
||||
|
||||
// Only upward traversal is performed, subdirectory files are not loaded
|
||||
expect(result).toEqual({
|
||||
memoryContent: `--- Context from: ${customFilename} ---\nCWD custom memory\n--- End of Context from: ${customFilename} ---\n\n--- Context from: ${path.join('subdir', customFilename)} ---\nSubdir custom memory\n--- End of Context from: ${path.join('subdir', customFilename)} ---`,
|
||||
fileCount: 2,
|
||||
memoryContent: `--- Context from: ${customFilename} ---\nCWD custom memory\n--- End of Context from: ${customFilename} ---`,
|
||||
fileCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,7 +260,7 @@ describe('loadServerHierarchicalMemory', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should load ORIGINAL_GEMINI_MD_FILENAME files by downward traversal from CWD', async () => {
|
||||
it('should only load context files from CWD, not subdirectories', async () => {
|
||||
await createTestFile(
|
||||
path.join(cwd, 'subdir', DEFAULT_CONTEXT_FILENAME),
|
||||
'Subdir memory',
|
||||
@@ -278,13 +279,14 @@ describe('loadServerHierarchicalMemory', () => {
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
);
|
||||
|
||||
// Subdirectory files are not loaded, only CWD and upward
|
||||
expect(result).toEqual({
|
||||
memoryContent: `--- Context from: ${DEFAULT_CONTEXT_FILENAME} ---\nCWD memory\n--- End of Context from: ${DEFAULT_CONTEXT_FILENAME} ---\n\n--- Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} ---\nSubdir memory\n--- End of Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} ---`,
|
||||
fileCount: 2,
|
||||
memoryContent: `--- Context from: ${DEFAULT_CONTEXT_FILENAME} ---\nCWD memory\n--- End of Context from: ${DEFAULT_CONTEXT_FILENAME} ---`,
|
||||
fileCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should load and correctly order global, upward, and downward ORIGINAL_GEMINI_MD_FILENAME files', async () => {
|
||||
it('should load and correctly order global and upward context files', async () => {
|
||||
const defaultContextFile = await createTestFile(
|
||||
path.join(homedir, QWEN_DIR, DEFAULT_CONTEXT_FILENAME),
|
||||
'default context content',
|
||||
@@ -301,7 +303,7 @@ describe('loadServerHierarchicalMemory', () => {
|
||||
path.join(cwd, DEFAULT_CONTEXT_FILENAME),
|
||||
'CWD memory',
|
||||
);
|
||||
const subDirGeminiFile = await createTestFile(
|
||||
await createTestFile(
|
||||
path.join(cwd, 'sub', DEFAULT_CONTEXT_FILENAME),
|
||||
'Subdir memory',
|
||||
);
|
||||
@@ -315,92 +317,10 @@ describe('loadServerHierarchicalMemory', () => {
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
);
|
||||
|
||||
// Subdirectory files are not loaded, only global and upward from CWD
|
||||
expect(result).toEqual({
|
||||
memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} ---\ndefault context content\n--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---\n\n--- Context from: ${path.relative(cwd, rootGeminiFile)} ---\nProject parent memory\n--- End of Context from: ${path.relative(cwd, rootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, cwdGeminiFile)} ---\nCWD memory\n--- End of Context from: ${path.relative(cwd, cwdGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, subDirGeminiFile)} ---\nSubdir memory\n--- End of Context from: ${path.relative(cwd, subDirGeminiFile)} ---`,
|
||||
fileCount: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore specified directories during downward scan', async () => {
|
||||
await createEmptyDir(path.join(projectRoot, '.git'));
|
||||
await createTestFile(path.join(projectRoot, '.gitignore'), 'node_modules');
|
||||
|
||||
await createTestFile(
|
||||
path.join(cwd, 'node_modules', DEFAULT_CONTEXT_FILENAME),
|
||||
'Ignored memory',
|
||||
);
|
||||
const regularSubDirGeminiFile = await createTestFile(
|
||||
path.join(cwd, 'my_code', DEFAULT_CONTEXT_FILENAME),
|
||||
'My code memory',
|
||||
);
|
||||
|
||||
const result = await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
false,
|
||||
new FileDiscoveryService(projectRoot),
|
||||
[],
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
'tree',
|
||||
{
|
||||
respectGitIgnore: true,
|
||||
respectQwenIgnore: true,
|
||||
},
|
||||
200, // maxDirs parameter
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
memoryContent: `--- Context from: ${path.relative(cwd, regularSubDirGeminiFile)} ---\nMy code memory\n--- End of Context from: ${path.relative(cwd, regularSubDirGeminiFile)} ---`,
|
||||
fileCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect the maxDirs parameter during downward scan', async () => {
|
||||
const consoleDebugSpy = vi
|
||||
.spyOn(console, 'debug')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// Create directories in parallel for better performance
|
||||
const dirPromises = Array.from({ length: 2 }, (_, i) =>
|
||||
createEmptyDir(path.join(cwd, `deep_dir_${i}`)),
|
||||
);
|
||||
await Promise.all(dirPromises);
|
||||
|
||||
// Pass the custom limit directly to the function
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
true,
|
||||
new FileDiscoveryService(projectRoot),
|
||||
[],
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
'tree', // importFormat
|
||||
{
|
||||
respectGitIgnore: true,
|
||||
respectQwenIgnore: true,
|
||||
},
|
||||
1, // maxDirs
|
||||
);
|
||||
|
||||
expect(consoleDebugSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[DEBUG] [BfsFileSearch]'),
|
||||
expect.stringContaining('Scanning [1/1]:'),
|
||||
);
|
||||
|
||||
vi.mocked(console.debug).mockRestore();
|
||||
|
||||
const result = await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
false,
|
||||
new FileDiscoveryService(projectRoot),
|
||||
[],
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} ---\ndefault context content\n--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---\n\n--- Context from: ${path.relative(cwd, rootGeminiFile)} ---\nProject parent memory\n--- End of Context from: ${path.relative(cwd, rootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, cwdGeminiFile)} ---\nCWD memory\n--- End of Context from: ${path.relative(cwd, cwdGeminiFile)} ---`,
|
||||
fileCount: 4,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,12 +8,9 @@ import * as fs from 'node:fs/promises';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { bfsFileSearch } from './bfsFileSearch.js';
|
||||
import { getAllGeminiMdFilenames } from '../tools/memoryTool.js';
|
||||
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { processImports } from './memoryImportProcessor.js';
|
||||
import type { FileFilteringOptions } from '../config/constants.js';
|
||||
import { DEFAULT_MEMORY_FILE_FILTERING_OPTIONS } from '../config/constants.js';
|
||||
import { QWEN_DIR } from './paths.js';
|
||||
|
||||
// Simple console logger, similar to the one previously in CLI's config.ts
|
||||
@@ -86,8 +83,6 @@ async function getGeminiMdFilePathsInternal(
|
||||
fileService: FileDiscoveryService,
|
||||
extensionContextFilePaths: string[] = [],
|
||||
folderTrust: boolean,
|
||||
fileFilteringOptions: FileFilteringOptions,
|
||||
maxDirs: number,
|
||||
): Promise<string[]> {
|
||||
const dirs = new Set<string>([
|
||||
...includeDirectoriesToReadGemini,
|
||||
@@ -109,8 +104,6 @@ async function getGeminiMdFilePathsInternal(
|
||||
fileService,
|
||||
extensionContextFilePaths,
|
||||
folderTrust,
|
||||
fileFilteringOptions,
|
||||
maxDirs,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -139,8 +132,6 @@ async function getGeminiMdFilePathsInternalForEachDir(
|
||||
fileService: FileDiscoveryService,
|
||||
extensionContextFilePaths: string[] = [],
|
||||
folderTrust: boolean,
|
||||
fileFilteringOptions: FileFilteringOptions,
|
||||
maxDirs: number,
|
||||
): Promise<string[]> {
|
||||
const allPaths = new Set<string>();
|
||||
const geminiMdFilenames = getAllGeminiMdFilenames();
|
||||
@@ -185,7 +176,7 @@ async function getGeminiMdFilePathsInternalForEachDir(
|
||||
// Not found, which is okay
|
||||
}
|
||||
} else if (dir && folderTrust) {
|
||||
// FIX: Only perform the workspace search (upward and downward scans)
|
||||
// FIX: Only perform the workspace search (upward scan from CWD to project root)
|
||||
// if a valid currentWorkingDirectory is provided and it's not the home directory.
|
||||
const resolvedCwd = path.resolve(dir);
|
||||
if (debugMode)
|
||||
@@ -225,23 +216,6 @@ async function getGeminiMdFilePathsInternalForEachDir(
|
||||
currentDir = path.dirname(currentDir);
|
||||
}
|
||||
upwardPaths.forEach((p) => allPaths.add(p));
|
||||
|
||||
const mergedOptions: FileFilteringOptions = {
|
||||
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
...fileFilteringOptions,
|
||||
};
|
||||
|
||||
const downwardPaths = await bfsFileSearch(resolvedCwd, {
|
||||
fileName: geminiMdFilename,
|
||||
maxDirs,
|
||||
debug: debugMode,
|
||||
fileService,
|
||||
fileFilteringOptions: mergedOptions,
|
||||
});
|
||||
downwardPaths.sort();
|
||||
for (const dPath of downwardPaths) {
|
||||
allPaths.add(dPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,8 +338,6 @@ export async function loadServerHierarchicalMemory(
|
||||
extensionContextFilePaths: string[] = [],
|
||||
folderTrust: boolean,
|
||||
importFormat: 'flat' | 'tree' = 'tree',
|
||||
fileFilteringOptions?: FileFilteringOptions,
|
||||
maxDirs: number = 200,
|
||||
): Promise<LoadServerHierarchicalMemoryResponse> {
|
||||
if (debugMode)
|
||||
logger.debug(
|
||||
@@ -383,8 +355,6 @@ export async function loadServerHierarchicalMemory(
|
||||
fileService,
|
||||
extensionContextFilePaths,
|
||||
folderTrust,
|
||||
fileFilteringOptions || DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
maxDirs,
|
||||
);
|
||||
if (filePaths.length === 0) {
|
||||
if (debugMode) logger.debug('No QWEN.md files found in hierarchy.');
|
||||
@@ -400,6 +370,14 @@ export async function loadServerHierarchicalMemory(
|
||||
contentsWithPaths,
|
||||
currentWorkingDirectory,
|
||||
);
|
||||
|
||||
// Only count files that match configured memory filenames (e.g., QWEN.md),
|
||||
// excluding system context files like output-language.md
|
||||
const memoryFilenames = new Set(getAllGeminiMdFilenames());
|
||||
const fileCount = contentsWithPaths.filter((item) =>
|
||||
memoryFilenames.has(path.basename(item.filePath)),
|
||||
).length;
|
||||
|
||||
if (debugMode)
|
||||
logger.debug(
|
||||
`Combined instructions length: ${combinedInstructions.length}`,
|
||||
@@ -410,6 +388,6 @@ export async function loadServerHierarchicalMemory(
|
||||
);
|
||||
return {
|
||||
memoryContent: combinedInstructions,
|
||||
fileCount: contentsWithPaths.length,
|
||||
fileCount, // Only count the context files
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.1-preview.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"displayName": "Qwen Code Companion",
|
||||
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.1-preview.0",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user