Compare commits

..

13 Commits

Author SHA1 Message Date
mingholy.lmh
bf206237fa fix: align supported image formats with bailian doc 2025-09-18 11:12:51 +08:00
mingholy.lmh
9229134086 fix: remove deprecated files 2025-09-18 10:08:01 +08:00
mingholy.lmh
a8ca4ebf89 feat: add visionModelPreview to control default visibility of vision models 2025-09-17 20:58:54 +08:00
mingholy.lmh
caedd8338f fix: lint and type errors 2025-09-17 20:29:16 +08:00
mingholy.lmh
b4ba23fd80 Merge branch 'main' into feat/vision-model-autoswitch 2025-09-17 20:20:28 +08:00
mingholy.lmh
93fbc54f88 feat: add image tokenizer to fit vlm context window 2025-09-17 20:10:19 +08:00
mingholy.lmh
30b463b7ee fix: lint error 2025-09-16 11:18:36 +08:00
mingholy.lmh
e969dbd5d2 Merge branch 'main' into feat/vision-model-autoswitch 2025-09-16 11:04:32 +08:00
mingholy.lmh
71cf4fbae0 feat: /model command for switching to vision model 2025-09-05 16:06:20 +08:00
mingholy.lmh
413be4467f fix: unit test cases 2025-09-04 12:18:41 +08:00
mingholy.lmh
6005051713 refactor: re-organize refactored files 2025-09-04 12:00:00 +08:00
mingholy.lmh
65549193c1 refactor: optimize stream handling 2025-09-03 21:43:25 +08:00
mingholy.lmh
002f1e2f36 refactor: openaiContentGenerator 2025-09-02 15:13:46 +08:00
70 changed files with 1238 additions and 4377 deletions

13
.vscode/launch.json vendored
View File

@@ -101,13 +101,6 @@
"env": {
"GEMINI_SANDBOX": "false"
}
},
{
"name": "Attach by Process ID",
"processId": "${command:PickProcess}",
"request": "attach",
"skipFiles": ["<node_internals>/**"],
"type": "node"
}
],
"inputs": [
@@ -122,12 +115,6 @@
"type": "promptString",
"description": "Enter your prompt for non-interactive mode",
"default": "Explain this code"
},
{
"id": "debugPort",
"type": "promptString",
"description": "Enter the debug port number (default: 9229)",
"default": "9229"
}
]
}

View File

@@ -1,25 +1,5 @@
# Changelog
## 0.0.12
- Added vision model support for Qwen-OAuth authentication.
- Synced upstream `gemini-cli` to v0.3.4 with numerous improvements and bug fixes.
- Enhanced subagent functionality with system reminders and improved user experience.
- Added tool call type coercion for better compatibility.
- Fixed arrow key navigation issues on Windows.
- Fixed missing tool call chunks for OpenAI logging.
- Fixed system prompt issues to avoid malformed tool calls.
- Fixed terminal flicker when subagent is executing.
- Fixed duplicate subagents configuration when running in home directory.
- Fixed Esc key unable to cancel subagent dialog.
- Added confirmation prompt for `/init` command when context file exists.
- Added `skipLoopDetection` configuration option.
- Fixed `is_background` parameter reset issues.
- Enhanced Windows compatibility with multi-line paste handling.
- Improved subagent documentation and branding consistency.
- Fixed various linting errors and improved code quality.
- Miscellaneous improvements and bug fixes.
## 0.0.11
- Added subagents feature with file-based configuration system for specialized AI assistants.

View File

@@ -54,7 +54,6 @@ For detailed setup instructions, see [Authorization](#authorization).
- **Code Understanding & Editing** - Query and edit large codebases beyond traditional context window limits
- **Workflow Automation** - Automate operational tasks like handling pull requests and complex rebases
- **Enhanced Parser** - Adapted parser specifically optimized for Qwen-Coder models
- **Vision Model Support** - Automatically detect images in your input and seamlessly switch to vision-capable models for multimodal analysis
## Installation
@@ -122,58 +121,6 @@ Create or edit `.qwen/settings.json` in your home directory:
> 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls.
### Vision Model Configuration
Qwen Code includes intelligent vision model auto-switching that detects images in your input and can automatically switch to vision-capable models for multimodal analysis. **This feature is enabled by default** - when you include images in your queries, you'll see a dialog asking how you'd like to handle the vision model switch.
#### Skip the Switch Dialog (Optional)
If you don't want to see the interactive dialog each time, configure the default behavior in your `.qwen/settings.json`:
```json
{
"experimental": {
"vlmSwitchMode": "once"
}
}
```
**Available modes:**
- **`"once"`** - Switch to vision model for this query only, then revert
- **`"session"`** - Switch to vision model for the entire session
- **`"persist"`** - Continue with current model (no switching)
- **Not set** - Show interactive dialog each time (default)
#### Command Line Override
You can also set the behavior via command line:
```bash
# Switch once per query
qwen --vlm-switch-mode once
# Switch for entire session
qwen --vlm-switch-mode session
# Never switch automatically
qwen --vlm-switch-mode persist
```
#### Disable Vision Models (Optional)
To completely disable vision model support, add to your `.qwen/settings.json`:
```json
{
"experimental": {
"visionModelPreview": false
}
}
```
> 💡 **Tip**: In YOLO mode (`--yolo`), vision switching happens automatically without prompts when images are detected.
### Authorization
Choose your preferred authentication method based on your needs:

View File

@@ -133,28 +133,6 @@ Focus on creating clear, comprehensive documentation that helps both
new contributors and end users understand the project.
```
## Using Subagents Effectively
### Automatic Delegation
Qwen Code proactively delegates tasks based on:
- The task description in your request
- The description field in subagent configurations
- Current context and available tools
To encourage more proactive subagent use, include phrases like "use PROACTIVELY" or "MUST BE USED" in your description field.
### Explicit Invocation
Request a specific subagent by mentioning it in your command:
```
> Let the testing-expert subagent create unit tests for the payment module
> Have the documentation-writer subagent update the API reference
> Get the react-specialist subagent to optimize this component's performance
```
## Examples
### Development Workflow Agents

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.13-nightly.2",
"version": "0.0.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.0.13-nightly.2",
"version": "0.0.11",
"workspaces": [
"packages/*"
],
@@ -13454,7 +13454,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.0.13-nightly.2",
"version": "0.0.11",
"dependencies": {
"@google/genai": "1.9.0",
"@iarna/toml": "^2.2.5",
@@ -13662,7 +13662,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.0.13-nightly.2",
"version": "0.0.11",
"dependencies": {
"@google/genai": "1.13.0",
"@lvce-editor/ripgrep": "^1.6.0",
@@ -13788,7 +13788,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.0.13-nightly.2",
"version": "0.0.11",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -13800,7 +13800,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.0.13-nightly.2",
"version": "0.0.11",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.13-nightly.2",
"version": "0.0.11",
"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.0.13-nightly.2"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.11"
},
"scripts": {
"start": "node scripts/start.js",

View File

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

View File

@@ -1514,7 +1514,7 @@ describe('loadCliConfig model selection', () => {
argv,
);
expect(config.getModel()).toBe('coder-model');
expect(config.getModel()).toBe('qwen3-coder-plus');
});
it('always prefers model from argvs', async () => {

View File

@@ -82,7 +82,6 @@ export interface CliArgs {
includeDirectories: string[] | undefined;
tavilyApiKey: string | undefined;
screenReader: boolean | undefined;
vlmSwitchMode: string | undefined;
}
export async function parseArguments(settings: Settings): Promise<CliArgs> {
@@ -250,13 +249,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
description: 'Enable screen reader mode for accessibility.',
default: false,
})
.option('vlm-switch-mode', {
type: 'string',
choices: ['once', 'session', 'persist'],
description:
'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.',
default: process.env['VLM_SWITCH_MODE'],
})
.check((argv) => {
if (argv.prompt && argv['promptInteractive']) {
throw new Error(
@@ -532,9 +524,6 @@ export async function loadCliConfig(
argv.screenReader !== undefined
? argv.screenReader
: (settings.ui?.accessibility?.screenReader ?? false);
const vlmSwitchMode =
argv.vlmSwitchMode || settings.experimental?.vlmSwitchMode;
return new Config({
sessionId,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -641,7 +630,6 @@ export async function loadCliConfig(
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
skipLoopDetection: settings.skipLoopDetection ?? false,
vlmSwitchMode,
});
}

View File

@@ -69,11 +69,7 @@ const MOCK_WORKSPACE_SETTINGS_PATH = pathActual.join(
);
// A more flexible type for test data that allows arbitrary properties.
type TestSettings = Settings & {
[key: string]: unknown;
nested?: { [key: string]: unknown };
nestedObj?: { [key: string]: unknown };
};
type TestSettings = Settings & { [key: string]: unknown };
vi.mock('fs', async (importOriginal) => {
// Get all the functions from the real 'fs' module
@@ -141,9 +137,6 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -204,9 +197,6 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -270,9 +260,6 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -333,9 +320,6 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -401,9 +385,6 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -496,9 +477,6 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -584,9 +562,6 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -716,9 +691,6 @@ describe('Settings Loading and Merging', () => {
'/system/dir',
],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -1459,9 +1431,6 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -1547,11 +1516,7 @@ describe('Settings Loading and Merging', () => {
'workspace_endpoint_from_env/api',
);
expect(
(
(settings.workspace.settings as TestSettings).nested as {
[key: string]: unknown;
}
)['value'],
(settings.workspace.settings as TestSettings)['nested']['value'],
).toBe('workspace_endpoint_from_env');
expect((settings.merged as TestSettings)['endpoint']).toBe(
'workspace_endpoint_from_env/api',
@@ -1801,39 +1766,19 @@ describe('Settings Loading and Merging', () => {
).toBeUndefined();
expect(
(
(settings.user.settings as TestSettings).nestedObj as {
[key: string]: unknown;
}
)['nestedNull'],
(settings.user.settings as TestSettings)['nestedObj']['nestedNull'],
).toBeNull();
expect(
(
(settings.user.settings as TestSettings).nestedObj as {
[key: string]: unknown;
}
)['nestedBool'],
(settings.user.settings as TestSettings)['nestedObj']['nestedBool'],
).toBe(true);
expect(
(
(settings.user.settings as TestSettings).nestedObj as {
[key: string]: unknown;
}
)['nestedNum'],
(settings.user.settings as TestSettings)['nestedObj']['nestedNum'],
).toBe(0);
expect(
(
(settings.user.settings as TestSettings).nestedObj as {
[key: string]: unknown;
}
)['nestedString'],
(settings.user.settings as TestSettings)['nestedObj']['nestedString'],
).toBe('literal');
expect(
(
(settings.user.settings as TestSettings).nestedObj as {
[key: string]: unknown;
}
)['anotherEnv'],
(settings.user.settings as TestSettings)['nestedObj']['anotherEnv'],
).toBe('env_string_nested_value');
delete process.env['MY_ENV_STRING'];
@@ -1919,9 +1864,6 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -2394,14 +2336,14 @@ describe('Settings Loading and Merging', () => {
vimMode: false,
},
model: {
maxSessionTurns: -1,
maxSessionTurns: 0,
},
context: {
includeDirectories: [],
},
security: {
folderTrust: {
enabled: false,
enabled: null,
},
},
};
@@ -2410,9 +2352,9 @@ describe('Settings Loading and Merging', () => {
expect(v1Settings).toEqual({
vimMode: false,
maxSessionTurns: -1,
maxSessionTurns: 0,
includeDirectories: [],
folderTrust: false,
folderTrust: null,
});
});

View File

@@ -396,24 +396,6 @@ function mergeSettings(
]),
],
},
experimental: {
...(systemDefaults.experimental || {}),
...(user.experimental || {}),
...(safeWorkspaceWithoutFolderTrust.experimental || {}),
...(system.experimental || {}),
},
contentGenerator: {
...(systemDefaults.contentGenerator || {}),
...(user.contentGenerator || {}),
...(safeWorkspaceWithoutFolderTrust.contentGenerator || {}),
...(system.contentGenerator || {}),
},
systemPromptMappings: {
...(systemDefaults.systemPromptMappings || {}),
...(user.systemPromptMappings || {}),
...(safeWorkspaceWithoutFolderTrust.systemPromptMappings || {}),
...(system.systemPromptMappings || {}),
},
extensions: {
...(systemDefaults.extensions || {}),
...(user.extensions || {}),

View File

@@ -746,21 +746,11 @@ export const SETTINGS_SCHEMA = {
label: 'Vision Model Preview',
category: 'Experimental',
requiresRestart: false,
default: true,
default: false,
description:
'Enable vision model support and auto-switching functionality. When disabled, vision models like qwen-vl-max-latest will be hidden and auto-switching will not occur.',
showInDialog: true,
},
vlmSwitchMode: {
type: 'string',
label: 'VLM Switch Mode',
category: 'Experimental',
requiresRestart: false,
default: undefined as string | undefined,
description:
'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). If not set, user will be prompted each time. This is a temporary experimental feature.',
showInDialog: false,
},
},
},

View File

@@ -670,7 +670,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
if (!contentGeneratorConfig) return [];
const visionModelPreviewEnabled =
settings.merged.experimental?.visionModelPreview ?? true;
settings.merged.experimental?.visionModelPreview ?? false;
switch (contentGeneratorConfig.authType) {
case AuthType.QWEN_OAUTH:
@@ -759,18 +759,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
setModelSwitchedFromQuotaError,
refreshStatic,
() => cancelHandlerRef.current(),
settings.merged.experimental?.visionModelPreview ?? true,
settings.merged.experimental?.visionModelPreview ?? false,
handleVisionSwitchRequired,
);
const pendingHistoryItems = useMemo(
() =>
[...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems].map(
(item, index) => ({
...item,
id: index,
}),
),
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
@@ -1228,14 +1222,16 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Static>
<OverflowProvider>
<Box ref={pendingHistoryItemRef} flexDirection="column">
{pendingHistoryItems.map((item) => (
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={item.id}
key={i}
availableTerminalHeight={
constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
item={item}
// TODO(taehykim): It seems like references to ids aren't necessary in
// HistoryItemDisplay. Refactor later. Use a fake id for now.
item={{ ...item, id: 0 }}
isPending={true}
config={config}
isFocused={!isEditorDialogOpen}

View File

@@ -5,7 +5,6 @@
*/
import type React from 'react';
import { memo } from 'react';
import type { HistoryItem } from '../types.js';
import { UserMessage } from './messages/UserMessage.js';
import { UserShellMessage } from './messages/UserShellMessage.js';
@@ -36,7 +35,7 @@ interface HistoryItemDisplayProps {
commands?: readonly SlashCommand[];
}
const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
item,
availableTerminalHeight,
terminalWidth,
@@ -102,7 +101,3 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{item.type === 'summary' && <SummaryMessage summary={item.summary} />}
</Box>
);
HistoryItemDisplayComponent.displayName = 'HistoryItemDisplay';
export const HistoryItemDisplay = memo(HistoryItemDisplayComponent);

View File

@@ -46,8 +46,8 @@ describe('ModelSwitchDialog', () => {
value: VisionSwitchOutcome.SwitchSessionToVL,
},
{
label: 'Continue with current model',
value: VisionSwitchOutcome.ContinueWithCurrentModel,
label: 'Do not switch, show guidance',
value: VisionSwitchOutcome.DisallowWithGuidance,
},
];
@@ -81,18 +81,18 @@ describe('ModelSwitchDialog', () => {
);
});
it('should call onSelect with ContinueWithCurrentModel when third option is selected', () => {
it('should call onSelect with DisallowWithGuidance when third option is selected', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel);
onSelectCallback(VisionSwitchOutcome.DisallowWithGuidance);
expect(mockOnSelect).toHaveBeenCalledWith(
VisionSwitchOutcome.ContinueWithCurrentModel,
VisionSwitchOutcome.DisallowWithGuidance,
);
});
it('should setup escape key handler to call onSelect with ContinueWithCurrentModel', () => {
it('should setup escape key handler to call onSelect with DisallowWithGuidance', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
expect(mockUseKeypress).toHaveBeenCalledWith(expect.any(Function), {
@@ -104,7 +104,7 @@ describe('ModelSwitchDialog', () => {
keypressHandler({ name: 'escape' });
expect(mockOnSelect).toHaveBeenCalledWith(
VisionSwitchOutcome.ContinueWithCurrentModel,
VisionSwitchOutcome.DisallowWithGuidance,
);
});
@@ -126,9 +126,13 @@ describe('ModelSwitchDialog', () => {
describe('VisionSwitchOutcome enum', () => {
it('should have correct enum values', () => {
expect(VisionSwitchOutcome.SwitchOnce).toBe('once');
expect(VisionSwitchOutcome.SwitchSessionToVL).toBe('session');
expect(VisionSwitchOutcome.ContinueWithCurrentModel).toBe('persist');
expect(VisionSwitchOutcome.SwitchOnce).toBe('switch_once');
expect(VisionSwitchOutcome.SwitchSessionToVL).toBe(
'switch_session_to_vl',
);
expect(VisionSwitchOutcome.DisallowWithGuidance).toBe(
'disallow_with_guidance',
);
});
});
@@ -140,7 +144,7 @@ describe('ModelSwitchDialog', () => {
// Call multiple times
onSelectCallback(VisionSwitchOutcome.SwitchOnce);
onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL);
onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel);
onSelectCallback(VisionSwitchOutcome.DisallowWithGuidance);
expect(mockOnSelect).toHaveBeenCalledTimes(3);
expect(mockOnSelect).toHaveBeenNthCalledWith(
@@ -153,7 +157,7 @@ describe('ModelSwitchDialog', () => {
);
expect(mockOnSelect).toHaveBeenNthCalledWith(
3,
VisionSwitchOutcome.ContinueWithCurrentModel,
VisionSwitchOutcome.DisallowWithGuidance,
);
});
@@ -175,7 +179,7 @@ describe('ModelSwitchDialog', () => {
expect(mockOnSelect).toHaveBeenCalledTimes(2);
expect(mockOnSelect).toHaveBeenCalledWith(
VisionSwitchOutcome.ContinueWithCurrentModel,
VisionSwitchOutcome.DisallowWithGuidance,
);
});
});

View File

@@ -14,9 +14,9 @@ import {
import { useKeypress } from '../hooks/useKeypress.js';
export enum VisionSwitchOutcome {
SwitchOnce = 'once',
SwitchSessionToVL = 'session',
ContinueWithCurrentModel = 'persist',
SwitchOnce = 'switch_once',
SwitchSessionToVL = 'switch_session_to_vl',
DisallowWithGuidance = 'disallow_with_guidance',
}
export interface ModelSwitchDialogProps {
@@ -29,7 +29,7 @@ export const ModelSwitchDialog: React.FC<ModelSwitchDialogProps> = ({
useKeypress(
(key) => {
if (key.name === 'escape') {
onSelect(VisionSwitchOutcome.ContinueWithCurrentModel);
onSelect(VisionSwitchOutcome.DisallowWithGuidance);
}
},
{ isActive: true },
@@ -45,8 +45,8 @@ export const ModelSwitchDialog: React.FC<ModelSwitchDialogProps> = ({
value: VisionSwitchOutcome.SwitchSessionToVL,
},
{
label: 'Continue with current model',
value: VisionSwitchOutcome.ContinueWithCurrentModel,
label: 'Do not switch, show guidance',
value: VisionSwitchOutcome.DisallowWithGuidance,
},
];

View File

@@ -27,7 +27,6 @@ export interface ToolConfirmationMessageProps {
isFocused?: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
compactMode?: boolean;
}
export const ToolConfirmationMessage: React.FC<
@@ -38,7 +37,6 @@ export const ToolConfirmationMessage: React.FC<
isFocused = true,
availableTerminalHeight,
terminalWidth,
compactMode = false,
}) => {
const { onConfirm } = confirmationDetails;
const childWidth = terminalWidth - 2; // 2 for padding
@@ -72,40 +70,6 @@ export const ToolConfirmationMessage: React.FC<
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
// Compact mode: return simple 3-option display
if (compactMode) {
const compactOptions: Array<RadioSelectItem<ToolConfirmationOutcome>> = [
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: 'Allow always',
value: ToolConfirmationOutcome.ProceedAlways,
},
{
label: 'No',
value: ToolConfirmationOutcome.Cancel,
},
];
return (
<Box flexDirection="column">
<Box>
<Text wrap="truncate">Do you want to proceed?</Text>
</Box>
<Box>
<RadioButtonSelect
items={compactOptions}
onSelect={handleSelect}
isFocused={isFocused}
/>
</Box>
</Box>
);
}
// Original logic continues unchanged below
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
let question: string;

View File

@@ -5,7 +5,7 @@
*/
import { useReducer, useCallback, useMemo } from 'react';
import { Box, Text } from 'ink';
import { Box, Text, useInput } from 'ink';
import { wizardReducer, initialWizardState } from '../reducers.js';
import { LocationSelector } from './LocationSelector.js';
import { GenerationMethodSelector } from './GenerationMethodSelector.js';
@@ -20,7 +20,6 @@ import type { Config } from '@qwen-code/qwen-code-core';
import { Colors } from '../../../colors.js';
import { theme } from '../../../semantic-colors.js';
import { TextEntryStep } from './TextEntryStep.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
interface AgentCreationWizardProps {
onClose: () => void;
@@ -50,12 +49,8 @@ export function AgentCreationWizard({
}, [onClose]);
// Centralized ESC key handling for the entire wizard
useKeypress(
(key) => {
if (key.name !== 'escape') {
return;
}
useInput((input, key) => {
if (key.escape) {
// LLM DescriptionInput handles its own ESC logic when generating
const kind = getStepKind(state.generationMethod, state.currentStep);
if (kind === 'LLM_DESC' && state.isGenerating) {
@@ -69,9 +64,8 @@ export function AgentCreationWizard({
// On other steps, ESC goes back to previous step
handlePrevious();
}
},
{ isActive: true },
);
}
});
const stepProps: WizardStepProps = useMemo(
() => ({

View File

@@ -227,7 +227,7 @@ export const AgentSelectionStep = ({
const textColor = isSelected ? theme.text.accent : theme.text.primary;
return (
<Box key={`${agent.name}-${agent.level}`} alignItems="center">
<Box key={agent.name} alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text color={isSelected ? theme.text.accent : theme.text.primary}>
{isSelected ? '●' : ' '}

View File

@@ -5,7 +5,7 @@
*/
import { useState, useCallback, useMemo, useEffect } from 'react';
import { Box, Text } from 'ink';
import { Box, Text, useInput } from 'ink';
import { AgentSelectionStep } from './AgentSelectionStep.js';
import { ActionSelectionStep } from './ActionSelectionStep.js';
import { AgentViewerStep } from './AgentViewerStep.js';
@@ -17,8 +17,7 @@ import { MANAGEMENT_STEPS } from '../types.js';
import { Colors } from '../../../colors.js';
import { theme } from '../../../semantic-colors.js';
import { getColorForDisplay, shouldShowColor } from '../utils.js';
import type { SubagentConfig, Config } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../../../hooks/useKeypress.js';
import type { Config, SubagentConfig } from '@qwen-code/qwen-code-core';
interface AgentsManagerDialogProps {
onClose: () => void;
@@ -53,7 +52,18 @@ export function AgentsManagerDialog({
const manager = config.getSubagentManager();
// Load agents from all levels separately to show all agents including conflicts
const allAgents = await manager.listSubagents();
const [projectAgents, userAgents, builtinAgents] = await Promise.all([
manager.listSubagents({ level: 'project' }),
manager.listSubagents({ level: 'user' }),
manager.listSubagents({ level: 'builtin' }),
]);
// Combine all agents (project, user, and builtin level)
const allAgents = [
...(projectAgents || []),
...(userAgents || []),
...(builtinAgents || []),
];
setAvailableAgents(allAgents);
}, [config]);
@@ -112,12 +122,8 @@ export function AgentsManagerDialog({
);
// Centralized ESC key handling for the entire dialog
useKeypress(
(key) => {
if (key.name !== 'escape') {
return;
}
useInput((input, key) => {
if (key.escape) {
const currentStep = getCurrentStep();
if (currentStep === MANAGEMENT_STEPS.AGENT_SELECTION) {
// On first step, ESC cancels the entire dialog
@@ -126,9 +132,8 @@ export function AgentsManagerDialog({
// On other steps, ESC goes back to previous step in navigation stack
handleNavigateBack();
}
},
{ isActive: true },
);
}
});
// Props for child components - now using direct state and callbacks
const commonProps = useMemo(

View File

@@ -18,12 +18,12 @@ import { COLOR_OPTIONS } from '../constants.js';
import { fmtDuration } from '../utils.js';
import { ToolConfirmationMessage } from '../../messages/ToolConfirmationMessage.js';
export type DisplayMode = 'compact' | 'default' | 'verbose';
export type DisplayMode = 'default' | 'verbose';
export interface AgentExecutionDisplayProps {
data: TaskResultDisplay;
availableHeight?: number;
childWidth: number;
childWidth?: number;
config: Config;
}
@@ -80,7 +80,7 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
childWidth,
config,
}) => {
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('compact');
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('default');
const agentColor = useMemo(() => {
const colorOption = COLOR_OPTIONS.find(
@@ -93,6 +93,8 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
// This component only listens to keyboard shortcut events when the subagent is running
if (data.status !== 'running') return '';
if (displayMode === 'verbose') return 'Press ctrl+r to show less.';
if (displayMode === 'default') {
const hasMoreLines =
data.taskPrompt.split('\n').length > MAX_TASK_PROMPT_LINES;
@@ -100,28 +102,17 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
data.toolCalls && data.toolCalls.length > MAX_TOOL_CALLS;
if (hasMoreToolCalls || hasMoreLines) {
return 'Press ctrl+r to show less, ctrl+e to show more.';
return 'Press ctrl+r to show more.';
}
return 'Press ctrl+r to show less.';
return '';
}
if (displayMode === 'verbose') {
return 'Press ctrl+e to show less.';
}
return '';
}, [displayMode, data]);
}, [displayMode, data.toolCalls, data.taskPrompt, data.status]);
// Handle keyboard shortcuts to control display mode
// Handle ctrl+r keypresses to control display mode
useKeypress(
(key) => {
if (key.ctrl && key.name === 'r') {
// ctrl+r toggles between compact and default
setDisplayMode((current) =>
current === 'compact' ? 'default' : 'compact',
);
} else if (key.ctrl && key.name === 'e') {
// ctrl+e toggles between default and verbose
setDisplayMode((current) =>
current === 'default' ? 'verbose' : 'default',
);
@@ -130,82 +121,6 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
{ isActive: true },
);
if (displayMode === 'compact') {
return (
<Box flexDirection="column">
{/* Header: Agent name and status */}
{!data.pendingConfirmation && (
<Box flexDirection="row">
<Text bold color={agentColor}>
{data.subagentName}
</Text>
<StatusDot status={data.status} />
<StatusIndicator status={data.status} />
</Box>
)}
{/* Running state: Show current tool call and progress */}
{data.status === 'running' && (
<>
{/* Current tool call */}
{data.toolCalls && data.toolCalls.length > 0 && (
<Box flexDirection="column">
<ToolCallItem
toolCall={data.toolCalls[data.toolCalls.length - 1]}
compact={true}
/>
{/* Show count of additional tool calls if there are more than 1 */}
{data.toolCalls.length > 1 && !data.pendingConfirmation && (
<Box flexDirection="row" paddingLeft={4}>
<Text color={Colors.Gray}>
+{data.toolCalls.length - 1} more tool calls (ctrl+r to
expand)
</Text>
</Box>
)}
</Box>
)}
{/* Inline approval prompt when awaiting confirmation */}
{data.pendingConfirmation && (
<Box flexDirection="column" marginTop={1} paddingLeft={1}>
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
isFocused={true}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
compactMode={true}
config={config}
/>
</Box>
)}
</>
)}
{/* Completed state: Show summary line */}
{data.status === 'completed' && data.executionSummary && (
<Box flexDirection="row" marginTop={1}>
<Text color={theme.text.secondary}>
Execution Summary: {data.executionSummary.totalToolCalls} tool
uses · {data.executionSummary.totalTokens.toLocaleString()} tokens
· {fmtDuration(data.executionSummary.totalDurationMs)}
</Text>
</Box>
)}
{/* Failed/Cancelled state: Show error reason */}
{data.status === 'failed' && (
<Box flexDirection="row" marginTop={1}>
<Text color={theme.status.error}>
Failed: {data.terminateReason}
</Text>
</Box>
)}
</Box>
);
}
// Default and verbose modes use normal layout
return (
<Box flexDirection="column" paddingX={1} gap={1}>
{/* Header with subagent name and status */}
@@ -243,8 +158,7 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
config={config}
isFocused={true}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
compactMode={true}
terminalWidth={childWidth ?? 80}
/>
</Box>
)}
@@ -366,8 +280,7 @@ const ToolCallItem: React.FC<{
resultDisplay?: string;
description?: string;
};
compact?: boolean;
}> = ({ toolCall, compact = false }) => {
}> = ({ toolCall }) => {
const STATUS_INDICATOR_WIDTH = 3;
// Map subagent status to ToolCallStatus-like display
@@ -422,8 +335,8 @@ const ToolCallItem: React.FC<{
</Text>
</Box>
{/* Second line: truncated returnDisplay output - hidden in compact mode */}
{!compact && truncatedOutput && (
{/* Second line: truncated returnDisplay output */}
{truncatedOutput && (
<Box flexDirection="row" paddingLeft={STATUS_INDICATOR_WIDTH}>
<Text color={Colors.Gray}>{truncatedOutput}</Text>
</Box>

View File

@@ -526,7 +526,7 @@ describe('KeypressContext - Kitty Protocol', () => {
});
await waitFor(() => {
expect(keyHandler).toHaveBeenCalledTimes(6); // 1 paste event + 5 individual chars for 'after'
expect(keyHandler).toHaveBeenCalledTimes(2); // 1 paste event + 1 paste event for 'after'
});
// Should emit paste event first
@@ -538,40 +538,12 @@ describe('KeypressContext - Kitty Protocol', () => {
}),
);
// Then process 'after' as individual characters (since it doesn't contain return)
// Then process 'after' as a paste event (since it's > 2 chars)
expect(keyHandler).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
name: 'a',
paste: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
name: 'f',
paste: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
4,
expect.objectContaining({
name: 't',
paste: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
5,
expect.objectContaining({
name: 'e',
paste: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
6,
expect.objectContaining({
name: 'r',
paste: false,
paste: true,
sequence: 'after',
}),
);
});
@@ -599,7 +571,7 @@ describe('KeypressContext - Kitty Protocol', () => {
});
await waitFor(() => {
expect(keyHandler).toHaveBeenCalledTimes(16); // 5 + 1 + 6 + 1 + 3 = 16 calls
expect(keyHandler).toHaveBeenCalledTimes(14); // Adjusted based on actual behavior
});
// Check the sequence: 'start' (5 chars) + paste1 + 'middle' (6 chars) + paste2 + 'end' (3 chars as paste)
@@ -671,18 +643,13 @@ describe('KeypressContext - Kitty Protocol', () => {
}),
);
// 'end' as individual characters (since it doesn't contain return)
// 'end' as paste event (since it's > 2 chars)
expect(keyHandler).toHaveBeenNthCalledWith(
callIndex++,
expect.objectContaining({ name: 'e' }),
);
expect(keyHandler).toHaveBeenNthCalledWith(
callIndex++,
expect.objectContaining({ name: 'n' }),
);
expect(keyHandler).toHaveBeenNthCalledWith(
callIndex++,
expect.objectContaining({ name: 'd' }),
expect.objectContaining({
paste: true,
sequence: 'end',
}),
);
});
@@ -771,18 +738,16 @@ describe('KeypressContext - Kitty Protocol', () => {
});
await waitFor(() => {
// With the current implementation, fragmented paste markers get reconstructed
// into a single paste event for 'content'
expect(keyHandler).toHaveBeenCalledTimes(1);
// With the current implementation, fragmented data gets processed differently
// The first fragment '\x1b[20' gets processed as individual characters
// The second fragment '0~content\x1b[2' gets processed as paste + individual chars
// The third fragment '01~' gets processed as individual characters
expect(keyHandler).toHaveBeenCalled();
});
// Should reconstruct the fragmented paste markers into a single paste event
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
paste: true,
sequence: 'content',
}),
);
// The current implementation processes fragmented paste markers as separate events
// rather than reconstructing them into a single paste event
expect(keyHandler.mock.calls.length).toBeGreaterThan(1);
});
});
@@ -886,47 +851,28 @@ describe('KeypressContext - Kitty Protocol', () => {
stdin.emit('data', Buffer.from('lo'));
});
// With the current implementation, data is processed as individual characters
// since 'hel' doesn't contain return (0x0d)
// With the current implementation, data is processed as it arrives
// First chunk 'hel' is treated as paste (multi-character)
expect(keyHandler).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
name: 'h',
sequence: 'h',
paste: false,
paste: true,
sequence: 'hel',
}),
);
// Second chunk 'lo' is processed as individual characters
expect(keyHandler).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
name: 'e',
sequence: 'e',
name: 'l',
sequence: 'l',
paste: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
name: 'l',
sequence: 'l',
paste: false,
}),
);
// Second chunk 'lo' is also processed as individual characters
expect(keyHandler).toHaveBeenNthCalledWith(
4,
expect.objectContaining({
name: 'l',
sequence: 'l',
paste: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
5,
expect.objectContaining({
name: 'o',
sequence: 'o',
@@ -934,7 +880,7 @@ describe('KeypressContext - Kitty Protocol', () => {
}),
);
expect(keyHandler).toHaveBeenCalledTimes(5);
expect(keyHandler).toHaveBeenCalledTimes(3);
} finally {
vi.useRealTimers();
}
@@ -961,20 +907,14 @@ describe('KeypressContext - Kitty Protocol', () => {
});
// Should flush immediately without waiting for timeout
// Large data without return gets treated as individual characters
expect(keyHandler).toHaveBeenCalledTimes(65);
// Each character should be processed individually
for (let i = 0; i < 65; i++) {
expect(keyHandler).toHaveBeenNthCalledWith(
i + 1,
expect.objectContaining({
name: 'x',
sequence: 'x',
paste: false,
}),
);
}
// Large data gets treated as paste event
expect(keyHandler).toHaveBeenCalledTimes(1);
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
paste: true,
sequence: largeData,
}),
);
// Advancing timer should not cause additional calls
const callCountBefore = keyHandler.mock.calls.length;

View File

@@ -407,11 +407,7 @@ export function KeypressProvider({
return;
}
if (
(rawDataBuffer.length <= 2 && rawDataBuffer.includes(0x0d)) ||
!rawDataBuffer.includes(0x0d) ||
isPaste
) {
if (rawDataBuffer.length <= 2 || isPaste) {
keypressStream.write(rawDataBuffer);
} else {
// Flush raw data buffer as a paste event

View File

@@ -89,7 +89,7 @@ export const useGeminiStream = (
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
onEditorClose: () => void,
onCancelSubmit: () => void,
visionModelPreviewEnabled: boolean,
visionModelPreviewEnabled: boolean = false,
onVisionSwitchRequired?: (query: PartListUnion) => Promise<{
modelOverride?: string;
persistSessionModel?: string;
@@ -949,13 +949,10 @@ export const useGeminiStream = (
],
);
const pendingHistoryItems = useMemo(
() =>
[pendingHistoryItemRef.current, pendingToolCallGroupDisplay].filter(
(i) => i !== undefined && i !== null,
),
[pendingHistoryItemRef, pendingToolCallGroupDisplay],
);
const pendingHistoryItems = [
pendingHistoryItemRef.current,
pendingToolCallGroupDisplay,
].filter((i) => i !== undefined && i !== null);
useEffect(() => {
const saveRestorableToolCalls = async () => {

View File

@@ -8,7 +8,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import type { Part, PartListUnion } from '@google/genai';
import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core';
import { AuthType, type Config } from '@qwen-code/qwen-code-core';
import {
shouldOfferVisionSwitch,
processVisionSwitchOutcome,
@@ -41,7 +41,7 @@ describe('useVisionAutoSwitch helpers', () => {
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'vision-model',
'qwen-vl-max-latest',
true,
);
expect(result).toBe(false);
@@ -108,56 +108,6 @@ describe('useVisionAutoSwitch helpers', () => {
);
expect(result).toBe(false);
});
it('returns true when image parts exist in YOLO mode context', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
true,
);
expect(result).toBe(true);
});
it('returns false when no image parts exist in YOLO mode context', () => {
const parts: PartListUnion = [{ text: 'just text' }];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
true,
);
expect(result).toBe(false);
});
it('returns false when already using vision model in YOLO mode context', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'vision-model',
true,
);
expect(result).toBe(false);
});
it('returns false when authType is not QWEN_OAUTH in YOLO mode context', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.USE_GEMINI,
'qwen3-coder-plus',
true,
);
expect(result).toBe(false);
});
});
describe('processVisionSwitchOutcome', () => {
@@ -175,11 +125,11 @@ describe('useVisionAutoSwitch helpers', () => {
expect(result).toEqual({ persistSessionModel: vl });
});
it('maps ContinueWithCurrentModel to empty result', () => {
it('maps DisallowWithGuidance to showGuidance', () => {
const result = processVisionSwitchOutcome(
VisionSwitchOutcome.ContinueWithCurrentModel,
VisionSwitchOutcome.DisallowWithGuidance,
);
expect(result).toEqual({});
expect(result).toEqual({ showGuidance: true });
});
});
@@ -201,20 +151,13 @@ describe('useVisionAutoSwitch hook', () => {
ts: number,
) => any;
const createMockConfig = (
authType: AuthType,
initialModel: string,
approvalMode: ApprovalMode = ApprovalMode.DEFAULT,
vlmSwitchMode?: string,
) => {
const createMockConfig = (authType: AuthType, initialModel: string) => {
let currentModel = initialModel;
const mockConfig: Partial<Config> = {
getModel: vi.fn(() => currentModel),
setModel: vi.fn((m: string) => {
currentModel = m;
}),
getApprovalMode: vi.fn(() => approvalMode),
getVlmSwitchMode: vi.fn(() => vlmSwitchMode),
getContentGeneratorConfig: vi.fn(() => ({
authType,
model: currentModel,
@@ -283,9 +226,11 @@ describe('useVisionAutoSwitch hook', () => {
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
it('continues with current model when dialog returns empty result', async () => {
it('shows guidance and blocks when dialog returns showGuidance', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi.fn().mockResolvedValue({}); // Empty result for ContinueWithCurrentModel
const onVisionSwitchRequired = vi
.fn()
.mockResolvedValue({ showGuidance: true });
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
@@ -300,12 +245,11 @@ describe('useVisionAutoSwitch hook', () => {
res = await result.current.handleVisionSwitch(parts, userTs, false);
});
// Should not add any guidance message
expect(addItem).not.toHaveBeenCalledWith(
expect(addItem).toHaveBeenCalledWith(
{ type: MessageType.INFO, text: getVisionSwitchGuidanceMessage() },
userTs,
);
expect(res).toEqual({ shouldProceed: true });
expect(res).toEqual({ shouldProceed: false });
expect(config.setModel).not.toHaveBeenCalled();
});
@@ -314,7 +258,7 @@ describe('useVisionAutoSwitch hook', () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, initialModel);
const onVisionSwitchRequired = vi
.fn()
.mockResolvedValue({ modelOverride: 'coder-model' });
.mockResolvedValue({ modelOverride: 'qwen-vl-max-latest' });
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
@@ -329,26 +273,20 @@ describe('useVisionAutoSwitch hook', () => {
});
expect(res).toEqual({ shouldProceed: true, originalModel: initialModel });
expect(config.setModel).toHaveBeenCalledWith('coder-model', {
reason: 'vision_auto_switch',
context: 'User-prompted vision switch (one-time override)',
});
expect(config.setModel).toHaveBeenCalledWith('qwen-vl-max-latest');
// Now restore
act(() => {
result.current.restoreOriginalModel();
});
expect(config.setModel).toHaveBeenLastCalledWith(initialModel, {
reason: 'vision_auto_switch',
context: 'Restoring original model after vision switch',
});
expect(config.setModel).toHaveBeenLastCalledWith(initialModel);
});
it('persists session model when dialog requests persistence', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi
.fn()
.mockResolvedValue({ persistSessionModel: 'coder-model' });
.mockResolvedValue({ persistSessionModel: 'qwen-vl-max-latest' });
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
@@ -363,17 +301,16 @@ describe('useVisionAutoSwitch hook', () => {
});
expect(res).toEqual({ shouldProceed: true });
expect(config.setModel).toHaveBeenCalledWith('coder-model', {
reason: 'vision_auto_switch',
context: 'User-prompted vision switch (session persistent)',
});
expect(config.setModel).toHaveBeenCalledWith('qwen-vl-max-latest');
// Restore should be a no-op since no one-time override was used
act(() => {
result.current.restoreOriginalModel();
});
// Last call should still be the persisted model set
expect((config.setModel as any).mock.calls.pop()?.[0]).toBe('coder-model');
expect((config.setModel as any).mock.calls.pop()?.[0]).toBe(
'qwen-vl-max-latest',
);
});
it('returns shouldProceed=true when dialog returns no special flags', async () => {
@@ -434,420 +371,4 @@ describe('useVisionAutoSwitch hook', () => {
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
describe('YOLO mode behavior', () => {
it('automatically switches to vision model in YOLO mode without showing dialog', async () => {
const initialModel = 'qwen3-coder-plus';
const config = createMockConfig(
AuthType.QWEN_OAUTH,
initialModel,
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn(); // Should not be called in YOLO mode
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 7070, false);
});
// Should automatically switch without calling the dialog
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(res).toEqual({
shouldProceed: true,
originalModel: initialModel,
});
expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), {
reason: 'vision_auto_switch',
context: 'YOLO mode auto-switch for image content',
});
});
it('does not switch in YOLO mode when no images are present', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [{ text: 'no images here' }];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 8080, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(config.setModel).not.toHaveBeenCalled();
});
it('does not switch in YOLO mode when already using vision model', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'vision-model',
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 9090, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(config.setModel).not.toHaveBeenCalled();
});
it('restores original model after YOLO mode auto-switch', async () => {
const initialModel = 'qwen3-coder-plus';
const config = createMockConfig(
AuthType.QWEN_OAUTH,
initialModel,
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
// First, trigger the auto-switch
await act(async () => {
await result.current.handleVisionSwitch(parts, 10100, false);
});
// Verify model was switched
expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), {
reason: 'vision_auto_switch',
context: 'YOLO mode auto-switch for image content',
});
// Now restore the original model
act(() => {
result.current.restoreOriginalModel();
});
// Verify model was restored
expect(config.setModel).toHaveBeenLastCalledWith(initialModel, {
reason: 'vision_auto_switch',
context: 'Restoring original model after vision switch',
});
});
it('does not switch in YOLO mode when authType is not QWEN_OAUTH', async () => {
const config = createMockConfig(
AuthType.USE_GEMINI,
'qwen3-coder-plus',
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 11110, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(config.setModel).not.toHaveBeenCalled();
});
it('does not switch in YOLO mode when visionModelPreviewEnabled is false', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
false,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 12120, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(config.setModel).not.toHaveBeenCalled();
});
it('handles multiple image formats in YOLO mode', async () => {
const initialModel = 'qwen3-coder-plus';
const config = createMockConfig(
AuthType.QWEN_OAUTH,
initialModel,
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ text: 'Here are some images:' },
{ inlineData: { mimeType: 'image/jpeg', data: '...' } },
{ fileData: { mimeType: 'image/png', fileUri: 'file://image.png' } },
{ text: 'Please analyze them.' },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 13130, false);
});
expect(res).toEqual({
shouldProceed: true,
originalModel: initialModel,
});
expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), {
reason: 'vision_auto_switch',
context: 'YOLO mode auto-switch for image content',
});
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
});
describe('VLM switch mode default behavior', () => {
it('should automatically switch once when vlmSwitchMode is "once"', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
'once',
);
const onVisionSwitchRequired = vi.fn(); // Should not be called
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(switchResult.originalModel).toBe('qwen3-coder-plus');
expect(config.setModel).toHaveBeenCalledWith('vision-model', {
reason: 'vision_auto_switch',
context: 'Default VLM switch mode: once (one-time override)',
});
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
it('should switch session when vlmSwitchMode is "session"', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
'session',
);
const onVisionSwitchRequired = vi.fn(); // Should not be called
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(switchResult.originalModel).toBeUndefined(); // No original model for session switch
expect(config.setModel).toHaveBeenCalledWith('vision-model', {
reason: 'vision_auto_switch',
context: 'Default VLM switch mode: session (session persistent)',
});
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
it('should continue with current model when vlmSwitchMode is "persist"', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
'persist',
);
const onVisionSwitchRequired = vi.fn(); // Should not be called
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(switchResult.originalModel).toBeUndefined();
expect(config.setModel).not.toHaveBeenCalled();
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
it('should fall back to user prompt when vlmSwitchMode is not set', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
undefined, // No default mode
);
const onVisionSwitchRequired = vi
.fn()
.mockResolvedValue({ modelOverride: 'vision-model' });
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(onVisionSwitchRequired).toHaveBeenCalledWith(parts);
});
it('should fall back to persist behavior when vlmSwitchMode has invalid value', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
'invalid-value',
);
const onVisionSwitchRequired = vi.fn(); // Should not be called
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(switchResult.originalModel).toBeUndefined();
// For invalid values, it should continue with current model (persist behavior)
expect(config.setModel).not.toHaveBeenCalled();
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
});
});

View File

@@ -5,7 +5,7 @@
*/
import { type PartListUnion, type Part } from '@google/genai';
import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core';
import { AuthType, type Config } from '@qwen-code/qwen-code-core';
import { useCallback, useRef } from 'react';
import { VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
import {
@@ -121,7 +121,7 @@ export function shouldOfferVisionSwitch(
parts: PartListUnion,
authType: AuthType,
currentModel: string,
visionModelPreviewEnabled: boolean = true,
visionModelPreviewEnabled: boolean = false,
): boolean {
// Only trigger for qwen-oauth
if (authType !== AuthType.QWEN_OAUTH) {
@@ -166,11 +166,11 @@ export function processVisionSwitchOutcome(
case VisionSwitchOutcome.SwitchSessionToVL:
return { persistSessionModel: vlModelId };
case VisionSwitchOutcome.ContinueWithCurrentModel:
return {}; // Continue with current model, no changes needed
case VisionSwitchOutcome.DisallowWithGuidance:
return { showGuidance: true };
default:
return {}; // Default to continuing with current model
return { showGuidance: true };
}
}
@@ -198,7 +198,7 @@ export interface VisionSwitchHandlingResult {
export function useVisionAutoSwitch(
config: Config,
addItem: UseHistoryManagerReturn['addItem'],
visionModelPreviewEnabled: boolean = true,
visionModelPreviewEnabled: boolean = false,
onVisionSwitchRequired?: (query: PartListUnion) => Promise<{
modelOverride?: string;
persistSessionModel?: string;
@@ -252,91 +252,35 @@ export function useVisionAutoSwitch(
return { shouldProceed: true };
}
// In YOLO mode, automatically switch to vision model without user interaction
if (config.getApprovalMode() === ApprovalMode.YOLO) {
const vlModelId = getDefaultVisionModel();
originalModelRef.current = config.getModel();
config.setModel(vlModelId, {
reason: 'vision_auto_switch',
context: 'YOLO mode auto-switch for image content',
});
return {
shouldProceed: true,
originalModel: originalModelRef.current,
};
}
// Check if there's a default VLM switch mode configured
const defaultVlmSwitchMode = config.getVlmSwitchMode();
if (defaultVlmSwitchMode) {
// Convert string value to VisionSwitchOutcome enum
let outcome: VisionSwitchOutcome;
switch (defaultVlmSwitchMode) {
case 'once':
outcome = VisionSwitchOutcome.SwitchOnce;
break;
case 'session':
outcome = VisionSwitchOutcome.SwitchSessionToVL;
break;
case 'persist':
outcome = VisionSwitchOutcome.ContinueWithCurrentModel;
break;
default:
// Invalid value, fall back to prompting user
outcome = VisionSwitchOutcome.ContinueWithCurrentModel;
}
// Process the default outcome
const visionSwitchResult = processVisionSwitchOutcome(outcome);
if (visionSwitchResult.modelOverride) {
// One-time model override
originalModelRef.current = config.getModel();
config.setModel(visionSwitchResult.modelOverride, {
reason: 'vision_auto_switch',
context: `Default VLM switch mode: ${defaultVlmSwitchMode} (one-time override)`,
});
return {
shouldProceed: true,
originalModel: originalModelRef.current,
};
} else if (visionSwitchResult.persistSessionModel) {
// Persistent session model change
config.setModel(visionSwitchResult.persistSessionModel, {
reason: 'vision_auto_switch',
context: `Default VLM switch mode: ${defaultVlmSwitchMode} (session persistent)`,
});
return { shouldProceed: true };
}
// For ContinueWithCurrentModel or any other case, proceed with current model
return { shouldProceed: true };
}
try {
const visionSwitchResult = await onVisionSwitchRequired(query);
if (visionSwitchResult.showGuidance) {
// Show guidance and don't proceed with the request
addItem(
{
type: MessageType.INFO,
text: getVisionSwitchGuidanceMessage(),
},
userMessageTimestamp,
);
return { shouldProceed: false };
}
if (visionSwitchResult.modelOverride) {
// One-time model override
originalModelRef.current = config.getModel();
config.setModel(visionSwitchResult.modelOverride, {
reason: 'vision_auto_switch',
context: 'User-prompted vision switch (one-time override)',
});
config.setModel(visionSwitchResult.modelOverride);
return {
shouldProceed: true,
originalModel: originalModelRef.current,
};
} else if (visionSwitchResult.persistSessionModel) {
// Persistent session model change
config.setModel(visionSwitchResult.persistSessionModel, {
reason: 'vision_auto_switch',
context: 'User-prompted vision switch (session persistent)',
});
config.setModel(visionSwitchResult.persistSessionModel);
return { shouldProceed: true };
}
// For ContinueWithCurrentModel or any other case, proceed with current model
return { shouldProceed: true };
} catch (_error) {
// If vision switch dialog was cancelled or errored, don't proceed
@@ -348,10 +292,7 @@ export function useVisionAutoSwitch(
const restoreOriginalModel = useCallback(() => {
if (originalModelRef.current) {
config.setModel(originalModelRef.current, {
reason: 'vision_auto_switch',
context: 'Restoring original model after vision switch',
});
config.setModel(originalModelRef.current);
originalModelRef.current = null;
}
}, [config]);

View File

@@ -10,12 +10,9 @@ export type AvailableModel = {
isVision?: boolean;
};
export const MAINLINE_VLM = 'vision-model';
export const MAINLINE_CODER = 'coder-model';
export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [
{ id: MAINLINE_CODER, label: MAINLINE_CODER },
{ id: MAINLINE_VLM, label: MAINLINE_VLM, isVision: true },
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
{ id: 'qwen-vl-max-latest', label: 'qwen-vl-max', isVision: true },
];
/**
@@ -45,7 +42,7 @@ export function getOpenAIAvailableModelFromEnv(): AvailableModel | null {
* until our coding model supports multimodal.
*/
export function getDefaultVisionModel(): string {
return MAINLINE_VLM;
return 'qwen-vl-max-latest';
}
export function isVisionModel(modelId: string): boolean {

View File

@@ -126,18 +126,6 @@ describe('validateNonInterActiveAuth', () => {
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_OPENAI);
});
it('uses configured QWEN_OAUTH if provided', async () => {
const nonInteractiveConfig: NonInteractiveConfig = {
refreshAuth: refreshAuthMock,
};
await validateNonInteractiveAuth(
AuthType.QWEN_OAUTH,
undefined,
nonInteractiveConfig,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.QWEN_OAUTH);
});
it('uses USE_VERTEX_AI if GOOGLE_GENAI_USE_VERTEXAI is true (with GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION)', async () => {
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';

View File

@@ -97,18 +97,6 @@ class GeminiAgent {
name: 'Vertex AI',
description: null,
},
{
id: AuthType.USE_OPENAI,
name: 'Use OpenAI API key',
description:
'Requires setting the `OPENAI_API_KEY` environment variable',
},
{
id: AuthType.QWEN_OAUTH,
name: 'Qwen OAuth',
description:
'OAuth authentication for Qwen models with 2000 daily requests',
},
];
return {

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.0.13-nightly.2",
"version": "0.0.11",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -737,85 +737,4 @@ describe('setApprovalMode with folder trust', () => {
expect(() => config.setApprovalMode(ApprovalMode.AUTO_EDIT)).not.toThrow();
expect(() => config.setApprovalMode(ApprovalMode.DEFAULT)).not.toThrow();
});
describe('Model Switch Logging', () => {
it('should log model switch when setModel is called with different model', async () => {
const config = new Config({
sessionId: 'test-model-switch',
targetDir: '.',
debugMode: false,
model: 'qwen3-coder-plus',
cwd: '.',
});
// Initialize the config to set up content generator
await config.initialize();
// Mock the logger's logModelSwitch method
const logModelSwitchSpy = vi.spyOn(config['logger']!, 'logModelSwitch');
// Change the model
config.setModel('qwen-vl-max-latest', {
reason: 'vision_auto_switch',
context: 'Test model switch',
});
// Verify that logModelSwitch was called with correct parameters
expect(logModelSwitchSpy).toHaveBeenCalledWith({
fromModel: 'qwen3-coder-plus',
toModel: 'qwen-vl-max-latest',
reason: 'vision_auto_switch',
context: 'Test model switch',
});
});
it('should not log when setModel is called with same model', async () => {
const config = new Config({
sessionId: 'test-same-model',
targetDir: '.',
debugMode: false,
model: 'qwen3-coder-plus',
cwd: '.',
});
// Initialize the config to set up content generator
await config.initialize();
// Mock the logger's logModelSwitch method
const logModelSwitchSpy = vi.spyOn(config['logger']!, 'logModelSwitch');
// Set the same model
config.setModel('qwen3-coder-plus');
// Verify that logModelSwitch was not called
expect(logModelSwitchSpy).not.toHaveBeenCalled();
});
it('should use default reason when no options provided', async () => {
const config = new Config({
sessionId: 'test-default-reason',
targetDir: '.',
debugMode: false,
model: 'qwen3-coder-plus',
cwd: '.',
});
// Initialize the config to set up content generator
await config.initialize();
// Mock the logger's logModelSwitch method
const logModelSwitchSpy = vi.spyOn(config['logger']!, 'logModelSwitch');
// Change the model without options
config.setModel('qwen-vl-max-latest');
// Verify that logModelSwitch was called with default reason
expect(logModelSwitchSpy).toHaveBeenCalledWith({
fromModel: 'qwen3-coder-plus',
toModel: 'qwen-vl-max-latest',
reason: 'manual',
context: undefined,
});
});
});
});

View File

@@ -56,7 +56,6 @@ import {
DEFAULT_GEMINI_FLASH_MODEL,
} from './models.js';
import { Storage } from './storage.js';
import { Logger, type ModelSwitchEvent } from '../core/logger.js';
// Re-export OAuth config type
export type { AnyToolInvocation, MCPOAuthConfig };
@@ -240,7 +239,6 @@ export interface ConfigParameters {
extensionManagement?: boolean;
enablePromptCompletion?: boolean;
skipLoopDetection?: boolean;
vlmSwitchMode?: string;
}
export class Config {
@@ -332,11 +330,9 @@ export class Config {
private readonly extensionManagement: boolean;
private readonly enablePromptCompletion: boolean = false;
private readonly skipLoopDetection: boolean;
private readonly vlmSwitchMode: string | undefined;
private initialized: boolean = false;
readonly storage: Storage;
private readonly fileExclusions: FileExclusions;
private logger: Logger | null = null;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
@@ -428,15 +424,8 @@ export class Config {
this.extensionManagement = params.extensionManagement ?? false;
this.storage = new Storage(this.targetDir);
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
this.vlmSwitchMode = params.vlmSwitchMode;
this.fileExclusions = new FileExclusions(this);
// Initialize logger asynchronously
this.logger = new Logger(this.sessionId, this.storage);
this.logger.initialize().catch((error) => {
console.debug('Failed to initialize logger:', error);
});
if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName);
}
@@ -528,45 +517,10 @@ export class Config {
return this.contentGeneratorConfig?.model || this.model;
}
setModel(
newModel: string,
options?: {
reason?: ModelSwitchEvent['reason'];
context?: string;
},
): void {
const oldModel = this.getModel();
setModel(newModel: string): void {
if (this.contentGeneratorConfig) {
this.contentGeneratorConfig.model = newModel;
}
// Log the model switch if the model actually changed
if (oldModel !== newModel && this.logger) {
const switchEvent: ModelSwitchEvent = {
fromModel: oldModel,
toModel: newModel,
reason: options?.reason || 'manual',
context: options?.context,
};
// Log asynchronously to avoid blocking
this.logger.logModelSwitch(switchEvent).catch((error) => {
console.debug('Failed to log model switch:', error);
});
}
// Reinitialize chat with updated configuration while preserving history
const geminiClient = this.getGeminiClient();
if (geminiClient && geminiClient.isInitialized()) {
// Use async operation but don't await to avoid blocking
geminiClient.reinitialize().catch((error) => {
console.error(
'Failed to reinitialize chat with updated config:',
error,
);
});
}
}
isInFallbackMode(): boolean {
@@ -972,10 +926,6 @@ export class Config {
return this.skipLoopDetection;
}
getVlmSwitchMode(): string | undefined {
return this.vlmSwitchMode;
}
async getGitService(): Promise<GitService> {
if (!this.gitService) {
this.gitService = new GitService(this.targetDir, this.storage);

View File

@@ -4,10 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
export const DEFAULT_QWEN_MODEL = 'coder-model';
export const DEFAULT_QWEN_FLASH_MODEL = 'coder-model';
export const DEFAULT_QWEN_MODEL = 'qwen3-coder-plus';
// We do not have a fallback model for now, but note it here anyway.
export const DEFAULT_QWEN_FLASH_MODEL = 'qwen3-coder-flash';
export const DEFAULT_GEMINI_MODEL = 'coder-model';
export const DEFAULT_GEMINI_MODEL = 'qwen3-coder-plus';
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';

File diff suppressed because it is too large Load Diff

View File

@@ -226,9 +226,6 @@ describe('Gemini Client (client.ts)', () => {
vertexai: false,
authType: AuthType.USE_GEMINI,
};
const mockSubagentManager = {
listSubagents: vi.fn().mockResolvedValue([]),
};
const mockConfigObject = {
getContentGeneratorConfig: vi
.fn()
@@ -263,7 +260,6 @@ describe('Gemini Client (client.ts)', () => {
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
getChatCompression: vi.fn().mockReturnValue(undefined),
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
getSubagentManager: vi.fn().mockReturnValue(mockSubagentManager),
getSkipLoopDetection: vi.fn().mockReturnValue(false),
};
const MockedConfig = vi.mocked(Config, true);
@@ -441,8 +437,7 @@ describe('Gemini Client (client.ts)', () => {
);
});
/* We now use model in contentGeneratorConfig in most cases. */
it.skip('should allow overriding model and config', async () => {
it('should allow overriding model and config', async () => {
const contents: Content[] = [
{ role: 'user', parts: [{ text: 'hello' }] },
];
@@ -2550,82 +2545,4 @@ ${JSON.stringify(
expect(mockChat.setHistory).toHaveBeenCalledWith(historyWithThoughts);
});
});
describe('initialize', () => {
it('should accept extraHistory parameter and pass it to startChat', async () => {
const mockStartChat = vi.fn().mockResolvedValue({});
client['startChat'] = mockStartChat;
const extraHistory = [
{ role: 'user', parts: [{ text: 'Previous message' }] },
{ role: 'model', parts: [{ text: 'Previous response' }] },
];
const contentGeneratorConfig = {
model: 'test-model',
apiKey: 'test-key',
vertexai: false,
authType: AuthType.USE_GEMINI,
};
await client.initialize(contentGeneratorConfig, extraHistory);
expect(mockStartChat).toHaveBeenCalledWith(extraHistory, 'test-model');
});
it('should use empty array when no extraHistory is provided', async () => {
const mockStartChat = vi.fn().mockResolvedValue({});
client['startChat'] = mockStartChat;
const contentGeneratorConfig = {
model: 'test-model',
apiKey: 'test-key',
vertexai: false,
authType: AuthType.USE_GEMINI,
};
await client.initialize(contentGeneratorConfig);
expect(mockStartChat).toHaveBeenCalledWith([], 'test-model');
});
});
describe('reinitialize', () => {
it('should reinitialize with preserved user history', async () => {
// Mock the initialize method
const mockInitialize = vi.fn().mockResolvedValue(undefined);
client['initialize'] = mockInitialize;
// Set up initial history with environment context + user messages
const mockHistory = [
{ role: 'user', parts: [{ text: 'Environment context' }] },
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
{ role: 'user', parts: [{ text: 'User message 1' }] },
{ role: 'model', parts: [{ text: 'Model response 1' }] },
];
const mockChat = {
getHistory: vi.fn().mockReturnValue(mockHistory),
};
client['chat'] = mockChat as unknown as GeminiChat;
client['getHistory'] = vi.fn().mockReturnValue(mockHistory);
await client.reinitialize();
// Should call initialize with preserved user history (excluding first 2 env messages)
expect(mockInitialize).toHaveBeenCalledWith(
expect.any(Object), // contentGeneratorConfig
[
{ role: 'user', parts: [{ text: 'User message 1' }] },
{ role: 'model', parts: [{ text: 'Model response 1' }] },
],
);
});
it('should not throw error when chat is not initialized', async () => {
client['chat'] = undefined;
await expect(client.reinitialize()).resolves.not.toThrow();
});
});
});

View File

@@ -29,7 +29,6 @@ import {
makeChatCompressionEvent,
NextSpeakerCheckEvent,
} from '../telemetry/types.js';
import { TaskTool } from '../tools/task.js';
import {
getDirectoryContextString,
getEnvironmentContext,
@@ -138,24 +137,13 @@ export class GeminiClient {
this.lastPromptId = this.config.getSessionId();
}
async initialize(
contentGeneratorConfig: ContentGeneratorConfig,
extraHistory?: Content[],
) {
async initialize(contentGeneratorConfig: ContentGeneratorConfig) {
this.contentGenerator = await createContentGenerator(
contentGeneratorConfig,
this.config,
this.config.getSessionId(),
);
/**
* Always take the model from contentGeneratorConfig to initialize,
* despite the `this.config.contentGeneratorConfig` is not updated yet because in
* `Config` it will not be updated until the initialization is successful.
*/
this.chat = await this.startChat(
extraHistory || [],
contentGeneratorConfig.model,
);
this.chat = await this.startChat();
}
getContentGenerator(): ContentGenerator {
@@ -228,28 +216,6 @@ export class GeminiClient {
this.chat = await this.startChat();
}
/**
* Reinitializes the chat with the current contentGeneratorConfig while preserving chat history.
* This creates a new chat object using the existing history and updated configuration.
* Should be called when configuration changes (model, auth, etc.) to ensure consistency.
*/
async reinitialize(): Promise<void> {
if (!this.chat) {
return;
}
// Preserve the current chat history (excluding environment context)
const currentHistory = this.getHistory();
// Remove the initial environment context (first 2 messages: user env + model acknowledgment)
const userHistory = currentHistory.slice(2);
// Get current content generator config and reinitialize with preserved history
const contentGeneratorConfig = this.config.getContentGeneratorConfig();
if (contentGeneratorConfig) {
await this.initialize(contentGeneratorConfig, userHistory);
}
}
async addDirectoryContext(): Promise<void> {
if (!this.chat) {
return;
@@ -261,10 +227,7 @@ export class GeminiClient {
});
}
async startChat(
extraHistory?: Content[],
model?: string,
): Promise<GeminiChat> {
async startChat(extraHistory?: Content[]): Promise<GeminiChat> {
this.forceFullIdeContext = true;
this.hasFailedCompressionAttempt = false;
const envParts = await getEnvironmentContext(this.config);
@@ -284,13 +247,9 @@ export class GeminiClient {
];
try {
const userMemory = this.config.getUserMemory();
const systemInstruction = getCoreSystemPrompt(
userMemory,
{},
model || this.config.getModel(),
);
const systemInstruction = getCoreSystemPrompt(userMemory);
const generateContentConfigWithThinking = isThinkingSupported(
model || this.config.getModel(),
this.config.getModel(),
)
? {
...this.generateContentConfig,
@@ -496,8 +455,7 @@ export class GeminiClient {
turns: number = MAX_TURNS,
originalModel?: string,
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
const isNewPrompt = this.lastPromptId !== prompt_id;
if (isNewPrompt) {
if (this.lastPromptId !== prompt_id) {
this.loopDetector.reset(prompt_id);
this.lastPromptId = prompt_id;
}
@@ -530,11 +488,7 @@ export class GeminiClient {
// Get all the content that would be sent in an API call
const currentHistory = this.getChat().getHistory(true);
const userMemory = this.config.getUserMemory();
const systemPrompt = getCoreSystemPrompt(
userMemory,
{},
this.config.getModel(),
);
const systemPrompt = getCoreSystemPrompt(userMemory);
const environment = await getEnvironmentContext(this.config);
// Create a mock request content to count total tokens
@@ -598,24 +552,6 @@ export class GeminiClient {
this.forceFullIdeContext = false;
}
if (isNewPrompt) {
const taskTool = this.config.getToolRegistry().getTool(TaskTool.Name);
const subagents = (
await this.config.getSubagentManager().listSubagents()
).filter((subagent) => subagent.level !== 'builtin');
if (taskTool && subagents.length > 0) {
this.getChat().addHistory({
role: 'user',
parts: [
{
text: `<system-reminder>You have powerful specialized agents at your disposal, available agent types are: ${subagents.map((subagent) => subagent.name).join(', ')}. PROACTIVELY use the ${TaskTool.Name} tool to delegate user's task to appropriate agent when user's task matches agent capabilities. Ignore this message if user's task is not relevant to any agent. This message is for internal use only. Do not mention this to user in your response.</system-reminder>`,
},
],
});
}
}
const turn = new Turn(this.getChat(), prompt_id);
if (!this.config.getSkipLoopDetection()) {
@@ -688,18 +624,14 @@ export class GeminiClient {
model?: string,
config: GenerateContentConfig = {},
): Promise<Record<string, unknown>> {
/**
* TODO: ensure `model` consistency among GeminiClient, GeminiChat, and ContentGenerator
* `model` passed to generateContent is not respected as we always use contentGenerator
* We should ignore model for now because some calls use `DEFAULT_GEMINI_FLASH_MODEL`
* which is not available as `qwen3-coder-flash`
*/
const modelToUse = this.config.getModel() || DEFAULT_GEMINI_FLASH_MODEL;
// Use current model from config instead of hardcoded Flash model
const modelToUse =
model || this.config.getModel() || DEFAULT_GEMINI_FLASH_MODEL;
try {
const userMemory = this.config.getUserMemory();
const finalSystemInstruction = config.systemInstruction
? getCustomSystemPrompt(config.systemInstruction, userMemory)
: getCoreSystemPrompt(userMemory, {}, modelToUse);
: getCoreSystemPrompt(userMemory);
const requestConfig = {
abortSignal,
@@ -790,7 +722,7 @@ export class GeminiClient {
const userMemory = this.config.getUserMemory();
const finalSystemInstruction = generationConfig.systemInstruction
? getCustomSystemPrompt(generationConfig.systemInstruction, userMemory)
: getCoreSystemPrompt(userMemory, {}, this.config.getModel());
: getCoreSystemPrompt(userMemory);
const requestConfig: GenerateContentConfig = {
abortSignal,

View File

@@ -755,84 +755,4 @@ describe('Logger', () => {
expect(logger['messageId']).toBe(0);
});
});
describe('Model Switch Logging', () => {
it('should log model switch events correctly', async () => {
const testSessionId = 'test-session-model-switch';
const logger = new Logger(testSessionId, new Storage(process.cwd()));
await logger.initialize();
const modelSwitchEvent = {
fromModel: 'qwen3-coder-plus',
toModel: 'qwen-vl-max-latest',
reason: 'vision_auto_switch' as const,
context: 'YOLO mode auto-switch for image content',
};
await logger.logModelSwitch(modelSwitchEvent);
// Read the log file to verify the entry was written
const logContent = await fs.readFile(TEST_LOG_FILE_PATH, 'utf-8');
const logs: LogEntry[] = JSON.parse(logContent);
const modelSwitchLog = logs.find(
(log) =>
log.sessionId === testSessionId &&
log.type === MessageSenderType.MODEL_SWITCH,
);
expect(modelSwitchLog).toBeDefined();
expect(modelSwitchLog!.type).toBe(MessageSenderType.MODEL_SWITCH);
const loggedEvent = JSON.parse(modelSwitchLog!.message);
expect(loggedEvent.fromModel).toBe('qwen3-coder-plus');
expect(loggedEvent.toModel).toBe('qwen-vl-max-latest');
expect(loggedEvent.reason).toBe('vision_auto_switch');
expect(loggedEvent.context).toBe(
'YOLO mode auto-switch for image content',
);
});
it('should handle multiple model switch events', async () => {
const testSessionId = 'test-session-multiple-switches';
const logger = new Logger(testSessionId, new Storage(process.cwd()));
await logger.initialize();
// Log first switch
await logger.logModelSwitch({
fromModel: 'qwen3-coder-plus',
toModel: 'qwen-vl-max-latest',
reason: 'vision_auto_switch',
context: 'Auto-switch for image',
});
// Log second switch (restore)
await logger.logModelSwitch({
fromModel: 'qwen-vl-max-latest',
toModel: 'qwen3-coder-plus',
reason: 'vision_auto_switch',
context: 'Restoring original model',
});
// Read the log file to verify both entries were written
const logContent = await fs.readFile(TEST_LOG_FILE_PATH, 'utf-8');
const logs: LogEntry[] = JSON.parse(logContent);
const modelSwitchLogs = logs.filter(
(log) =>
log.sessionId === testSessionId &&
log.type === MessageSenderType.MODEL_SWITCH,
);
expect(modelSwitchLogs).toHaveLength(2);
const firstSwitch = JSON.parse(modelSwitchLogs[0].message);
expect(firstSwitch.fromModel).toBe('qwen3-coder-plus');
expect(firstSwitch.toModel).toBe('qwen-vl-max-latest');
const secondSwitch = JSON.parse(modelSwitchLogs[1].message);
expect(secondSwitch.fromModel).toBe('qwen-vl-max-latest');
expect(secondSwitch.toModel).toBe('qwen3-coder-plus');
});
});
});

View File

@@ -13,7 +13,6 @@ const LOG_FILE_NAME = 'logs.json';
export enum MessageSenderType {
USER = 'user',
MODEL_SWITCH = 'model_switch',
}
export interface LogEntry {
@@ -24,13 +23,6 @@ export interface LogEntry {
message: string;
}
export interface ModelSwitchEvent {
fromModel: string;
toModel: string;
reason: 'vision_auto_switch' | 'manual' | 'fallback' | 'other';
context?: string;
}
// This regex matches any character that is NOT a letter (a-z, A-Z),
// a number (0-9), a hyphen (-), an underscore (_), or a dot (.).
@@ -278,17 +270,6 @@ export class Logger {
}
}
async logModelSwitch(event: ModelSwitchEvent): Promise<void> {
const message = JSON.stringify({
fromModel: event.fromModel,
toModel: event.toModel,
reason: event.reason,
context: event.context,
});
await this.logMessage(MessageSenderType.MODEL_SWITCH, message);
}
private _checkpointPath(tag: string): string {
if (!tag.length) {
throw new Error('No checkpoint tag specified.');

View File

@@ -1105,164 +1105,5 @@ describe('ContentGenerationPipeline', () => {
expect.any(Array),
);
});
it('should collect all OpenAI chunks for logging even when Gemini responses are filtered', async () => {
// Create chunks that would produce empty Gemini responses (partial tool calls)
const partialToolCallChunk1: OpenAI.Chat.ChatCompletionChunk = {
id: 'chunk-1',
object: 'chat.completion.chunk',
created: Date.now(),
model: 'test-model',
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: 'call_123',
type: 'function',
function: { name: 'test_function', arguments: '{"par' },
},
],
},
finish_reason: null,
},
],
};
const partialToolCallChunk2: OpenAI.Chat.ChatCompletionChunk = {
id: 'chunk-2',
object: 'chat.completion.chunk',
created: Date.now(),
model: 'test-model',
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
function: { arguments: 'am": "value"}' },
},
],
},
finish_reason: null,
},
],
};
const finishChunk: OpenAI.Chat.ChatCompletionChunk = {
id: 'chunk-3',
object: 'chat.completion.chunk',
created: Date.now(),
model: 'test-model',
choices: [
{
index: 0,
delta: {},
finish_reason: 'tool_calls',
},
],
};
// Mock empty Gemini responses for partial chunks (they get filtered)
const emptyGeminiResponse1 = new GenerateContentResponse();
emptyGeminiResponse1.candidates = [
{
content: { parts: [], role: 'model' },
index: 0,
safetyRatings: [],
},
];
const emptyGeminiResponse2 = new GenerateContentResponse();
emptyGeminiResponse2.candidates = [
{
content: { parts: [], role: 'model' },
index: 0,
safetyRatings: [],
},
];
// Mock final Gemini response with tool call
const finalGeminiResponse = new GenerateContentResponse();
finalGeminiResponse.candidates = [
{
content: {
parts: [
{
functionCall: {
id: 'call_123',
name: 'test_function',
args: { param: 'value' },
},
},
],
role: 'model',
},
finishReason: FinishReason.STOP,
index: 0,
safetyRatings: [],
},
];
// Setup converter mocks
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
{ role: 'user', content: 'test' },
]);
(mockConverter.convertOpenAIChunkToGemini as Mock)
.mockReturnValueOnce(emptyGeminiResponse1) // First partial chunk -> empty response
.mockReturnValueOnce(emptyGeminiResponse2) // Second partial chunk -> empty response
.mockReturnValueOnce(finalGeminiResponse); // Finish chunk -> complete response
// Mock stream
const mockStream = {
async *[Symbol.asyncIterator]() {
yield partialToolCallChunk1;
yield partialToolCallChunk2;
yield finishChunk;
},
};
(mockClient.chat.completions.create as Mock).mockResolvedValue(
mockStream,
);
const request: GenerateContentParameters = {
model: 'test-model',
contents: [{ role: 'user', parts: [{ text: 'test' }] }],
};
// Collect responses
const responses: GenerateContentResponse[] = [];
const resultGenerator = await pipeline.executeStream(
request,
'test-prompt-id',
);
for await (const response of resultGenerator) {
responses.push(response);
}
// Should only yield the final response (empty ones are filtered)
expect(responses).toHaveLength(1);
expect(responses[0]).toBe(finalGeminiResponse);
// Verify telemetry was called with ALL OpenAI chunks, including the filtered ones
expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith(
expect.objectContaining({
model: 'test-model',
duration: expect.any(Number),
userPromptId: 'test-prompt-id',
authType: 'openai',
}),
[finalGeminiResponse], // Only the non-empty Gemini response
expect.objectContaining({
model: 'test-model',
messages: [{ role: 'user', content: 'test' }],
}),
[partialToolCallChunk1, partialToolCallChunk2, finishChunk], // ALL OpenAI chunks
);
});
});
});

View File

@@ -118,9 +118,6 @@ export class ContentGenerationPipeline {
try {
// Stage 2a: Convert and yield each chunk while preserving original
for await (const chunk of stream) {
// Always collect OpenAI chunks for logging, regardless of Gemini conversion result
collectedOpenAIChunks.push(chunk);
const response = this.converter.convertOpenAIChunkToGemini(chunk);
// Stage 2b: Filter empty responses to avoid downstream issues
@@ -135,7 +132,9 @@ export class ContentGenerationPipeline {
// Stage 2c: Handle chunk merging for providers that send finishReason and usageMetadata separately
const shouldYield = this.handleChunkMerging(
response,
chunk,
collectedGeminiResponses,
collectedOpenAIChunks,
(mergedResponse) => {
pendingFinishResponse = mergedResponse;
},
@@ -183,13 +182,17 @@ export class ContentGenerationPipeline {
* finishReason and the most up-to-date usage information from any provider pattern.
*
* @param response Current Gemini response
* @param chunk Current OpenAI chunk
* @param collectedGeminiResponses Array to collect responses for logging
* @param collectedOpenAIChunks Array to collect chunks for logging
* @param setPendingFinish Callback to set pending finish response
* @returns true if the response should be yielded, false if it should be held for merging
*/
private handleChunkMerging(
response: GenerateContentResponse,
chunk: OpenAI.Chat.ChatCompletionChunk,
collectedGeminiResponses: GenerateContentResponse[],
collectedOpenAIChunks: OpenAI.Chat.ChatCompletionChunk[],
setPendingFinish: (response: GenerateContentResponse) => void,
): boolean {
const isFinishChunk = response.candidates?.[0]?.finishReason;
@@ -203,6 +206,7 @@ export class ContentGenerationPipeline {
if (isFinishChunk) {
// This is a finish reason chunk
collectedGeminiResponses.push(response);
collectedOpenAIChunks.push(chunk);
setPendingFinish(response);
return false; // Don't yield yet, wait for potential subsequent chunks to merge
} else if (hasPendingFinish) {
@@ -224,6 +228,7 @@ export class ContentGenerationPipeline {
// Update the collected responses with the merged response
collectedGeminiResponses[collectedGeminiResponses.length - 1] =
mergedResponse;
collectedOpenAIChunks.push(chunk);
setPendingFinish(mergedResponse);
return true; // Yield the merged response
@@ -231,6 +236,7 @@ export class ContentGenerationPipeline {
// Normal chunk - collect and yield
collectedGeminiResponses.push(response);
collectedOpenAIChunks.push(chunk);
return true;
}

View File

@@ -560,146 +560,4 @@ describe('DashScopeOpenAICompatibleProvider', () => {
]);
});
});
describe('output token limits', () => {
it('should limit max_tokens when it exceeds model limit for qwen3-coder-plus', () => {
const request: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'qwen3-coder-plus',
messages: [{ role: 'user', content: 'Hello' }],
max_tokens: 100000, // Exceeds the 65536 limit
};
const result = provider.buildRequest(request, 'test-prompt-id');
expect(result.max_tokens).toBe(65536); // Should be limited to model's output limit
});
it('should limit max_tokens when it exceeds model limit for qwen-vl-max-latest', () => {
const request: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'qwen-vl-max-latest',
messages: [{ role: 'user', content: 'Hello' }],
max_tokens: 20000, // Exceeds the 8192 limit
};
const result = provider.buildRequest(request, 'test-prompt-id');
expect(result.max_tokens).toBe(8192); // Should be limited to model's output limit
});
it('should not modify max_tokens when it is within model limit', () => {
const request: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'qwen3-coder-plus',
messages: [{ role: 'user', content: 'Hello' }],
max_tokens: 1000, // Within the 65536 limit
};
const result = provider.buildRequest(request, 'test-prompt-id');
expect(result.max_tokens).toBe(1000); // Should remain unchanged
});
it('should not add max_tokens when not present in request', () => {
const request: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'qwen3-coder-plus',
messages: [{ role: 'user', content: 'Hello' }],
// No max_tokens parameter
};
const result = provider.buildRequest(request, 'test-prompt-id');
expect(result.max_tokens).toBeUndefined(); // Should remain undefined
});
it('should handle null max_tokens parameter', () => {
const request: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'qwen3-coder-plus',
messages: [{ role: 'user', content: 'Hello' }],
max_tokens: null,
};
const result = provider.buildRequest(request, 'test-prompt-id');
expect(result.max_tokens).toBeNull(); // Should remain null
});
it('should use default output limit for unknown models', () => {
const request: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'unknown-model',
messages: [{ role: 'user', content: 'Hello' }],
max_tokens: 10000, // Exceeds the default 4096 limit
};
const result = provider.buildRequest(request, 'test-prompt-id');
expect(result.max_tokens).toBe(4096); // Should be limited to default output limit
});
it('should preserve other request parameters when limiting max_tokens', () => {
const request: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'qwen3-coder-plus',
messages: [{ role: 'user', content: 'Hello' }],
max_tokens: 100000, // Will be limited
temperature: 0.8,
top_p: 0.9,
frequency_penalty: 0.1,
presence_penalty: 0.2,
stop: ['END'],
user: 'test-user',
};
const result = provider.buildRequest(request, 'test-prompt-id');
// max_tokens should be limited
expect(result.max_tokens).toBe(65536);
// Other parameters should be preserved
expect(result.temperature).toBe(0.8);
expect(result.top_p).toBe(0.9);
expect(result.frequency_penalty).toBe(0.1);
expect(result.presence_penalty).toBe(0.2);
expect(result.stop).toEqual(['END']);
expect(result.user).toBe('test-user');
});
it('should work with vision models and output token limits', () => {
const request: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'qwen-vl-max-latest',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Look at this image:' },
{
type: 'image_url',
image_url: { url: 'https://example.com/image.jpg' },
},
],
},
],
max_tokens: 20000, // Exceeds the 8192 limit
};
const result = provider.buildRequest(request, 'test-prompt-id');
expect(result.max_tokens).toBe(8192); // Should be limited
expect(
(result as { vl_high_resolution_images?: boolean })
.vl_high_resolution_images,
).toBe(true); // Vision-specific parameter should be preserved
});
it('should handle streaming requests with output token limits', () => {
const request: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'qwen3-coder-plus',
messages: [{ role: 'user', content: 'Hello' }],
max_tokens: 100000, // Exceeds the 65536 limit
stream: true,
};
const result = provider.buildRequest(request, 'test-prompt-id');
expect(result.max_tokens).toBe(65536); // Should be limited
expect(result.stream).toBe(true); // Streaming should be preserved
});
});
});

View File

@@ -3,7 +3,6 @@ import type { Config } from '../../../config/config.js';
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
import { AuthType } from '../../contentGenerator.js';
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
import { tokenLimit } from '../../tokenLimits.js';
import type {
OpenAICompatibleProvider,
DashScopeRequestMetadata,
@@ -66,19 +65,6 @@ export class DashScopeOpenAICompatibleProvider
});
}
/**
* Build and configure the request for DashScope API.
*
* This method applies DashScope-specific configurations including:
* - Cache control for system and user messages
* - Output token limits based on model capabilities
* - Vision model specific parameters (vl_high_resolution_images)
* - Request metadata for session tracking
*
* @param request - The original chat completion request parameters
* @param userPromptId - Unique identifier for the user prompt for session tracking
* @returns Configured request with DashScope-specific parameters applied
*/
buildRequest(
request: OpenAI.Chat.ChatCompletionCreateParams,
userPromptId: string,
@@ -93,28 +79,21 @@ export class DashScopeOpenAICompatibleProvider
messages = this.addDashScopeCacheControl(messages, cacheTarget);
}
// Apply output token limits based on model capabilities
// This ensures max_tokens doesn't exceed the model's maximum output limit
const requestWithTokenLimits = this.applyOutputTokenLimit(
request,
request.model,
);
if (request.model.startsWith('qwen-vl')) {
return {
...requestWithTokenLimits,
...request,
messages,
...(this.buildMetadata(userPromptId) || {}),
/* @ts-expect-error dashscope exclusive */
vl_high_resolution_images: true,
} as OpenAI.Chat.ChatCompletionCreateParams;
};
}
return {
...requestWithTokenLimits, // Preserve all original parameters including sampling params and adjusted max_tokens
...request, // Preserve all original parameters including sampling params
messages,
...(this.buildMetadata(userPromptId) || {}),
} as OpenAI.Chat.ChatCompletionCreateParams;
};
}
buildMetadata(userPromptId: string): DashScopeRequestMetadata {
@@ -267,41 +246,6 @@ export class DashScopeOpenAICompatibleProvider
return contentArray;
}
/**
* Apply output token limit to a request's max_tokens parameter.
*
* Ensures that existing max_tokens parameters don't exceed the model's maximum output
* token limit. Only modifies max_tokens when already present in the request.
*
* @param request - The chat completion request parameters
* @param model - The model name to get the output token limit for
* @returns The request with max_tokens adjusted to respect the model's limits (if present)
*/
private applyOutputTokenLimit<T extends { max_tokens?: number | null }>(
request: T,
model: string,
): T {
const currentMaxTokens = request.max_tokens;
// Only process if max_tokens is already present in the request
if (currentMaxTokens === undefined || currentMaxTokens === null) {
return request; // No max_tokens parameter, return unchanged
}
const modelLimit = tokenLimit(model, 'output');
// If max_tokens exceeds the model limit, cap it to the model's limit
if (currentMaxTokens > modelLimit) {
return {
...request,
max_tokens: modelLimit,
};
}
// If max_tokens is within the limit, return the request unchanged
return request;
}
/**
* Check if cache control should be disabled based on configuration.
*

View File

@@ -364,120 +364,6 @@ describe('URL matching with trailing slash compatibility', () => {
});
});
describe('Model-specific tool call formats', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv('SANDBOX', undefined);
});
it('should use XML format for qwen3-coder model', () => {
vi.mocked(isGitRepository).mockReturnValue(false);
const prompt = getCoreSystemPrompt(undefined, undefined, 'qwen3-coder-7b');
// Should contain XML-style tool calls
expect(prompt).toContain('<tool_call>');
expect(prompt).toContain('<function=run_shell_command>');
expect(prompt).toContain('<parameter=command>');
expect(prompt).toContain('</function>');
expect(prompt).toContain('</tool_call>');
// Should NOT contain bracket-style tool calls
expect(prompt).not.toContain('[tool_call: run_shell_command for');
// Should NOT contain JSON-style tool calls
expect(prompt).not.toContain('{"name": "run_shell_command"');
expect(prompt).toMatchSnapshot();
});
it('should use JSON format for qwen-vl model', () => {
vi.mocked(isGitRepository).mockReturnValue(false);
const prompt = getCoreSystemPrompt(undefined, undefined, 'qwen-vl-max');
// Should contain JSON-style tool calls
expect(prompt).toContain('<tool_call>');
expect(prompt).toContain('{"name": "run_shell_command"');
expect(prompt).toContain('"arguments": {"command": "node server.js &"}');
expect(prompt).toContain('</tool_call>');
// Should NOT contain bracket-style tool calls
expect(prompt).not.toContain('[tool_call: run_shell_command for');
// Should NOT contain XML-style tool calls with parameters
expect(prompt).not.toContain('<function=run_shell_command>');
expect(prompt).not.toContain('<parameter=command>');
expect(prompt).toMatchSnapshot();
});
it('should use bracket format for generic models', () => {
vi.mocked(isGitRepository).mockReturnValue(false);
const prompt = getCoreSystemPrompt(undefined, undefined, 'gpt-4');
// Should contain bracket-style tool calls
expect(prompt).toContain('[tool_call: run_shell_command for');
expect(prompt).toContain('because it must run in the background]');
// Should NOT contain XML-style tool calls
expect(prompt).not.toContain('<function=run_shell_command>');
expect(prompt).not.toContain('<parameter=command>');
// Should NOT contain JSON-style tool calls
expect(prompt).not.toContain('{"name": "run_shell_command"');
expect(prompt).toMatchSnapshot();
});
it('should use bracket format when no model is specified', () => {
vi.mocked(isGitRepository).mockReturnValue(false);
const prompt = getCoreSystemPrompt();
// Should contain bracket-style tool calls (default behavior)
expect(prompt).toContain('[tool_call: run_shell_command for');
expect(prompt).toContain('because it must run in the background]');
// Should NOT contain XML or JSON formats
expect(prompt).not.toContain('<function=run_shell_command>');
expect(prompt).not.toContain('{"name": "run_shell_command"');
expect(prompt).toMatchSnapshot();
});
it('should preserve model-specific formats with user memory', () => {
vi.mocked(isGitRepository).mockReturnValue(false);
const userMemory = 'User prefers concise responses.';
const prompt = getCoreSystemPrompt(
userMemory,
undefined,
'qwen3-coder-14b',
);
// Should contain XML-style tool calls
expect(prompt).toContain('<tool_call>');
expect(prompt).toContain('<function=run_shell_command>');
// Should contain user memory with separator
expect(prompt).toContain('---');
expect(prompt).toContain('User prefers concise responses.');
expect(prompt).toMatchSnapshot();
});
it('should preserve model-specific formats with sandbox environment', () => {
vi.stubEnv('SANDBOX', 'true');
vi.mocked(isGitRepository).mockReturnValue(false);
const prompt = getCoreSystemPrompt(undefined, undefined, 'qwen-vl-plus');
// Should contain JSON-style tool calls
expect(prompt).toContain('{"name": "run_shell_command"');
// Should contain sandbox instructions
expect(prompt).toContain('# Sandbox');
expect(prompt).toMatchSnapshot();
});
});
describe('getCustomSystemPrompt', () => {
it('should handle string custom instruction without user memory', () => {
const customInstruction =

View File

@@ -7,10 +7,18 @@
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { ToolNames } from '../tools/tool-names.js';
import { EditTool } from '../tools/edit.js';
import { GlobTool } from '../tools/glob.js';
import { GrepTool } from '../tools/grep.js';
import { ReadFileTool } from '../tools/read-file.js';
import { ReadManyFilesTool } from '../tools/read-many-files.js';
import { ShellTool } from '../tools/shell.js';
import { WriteFileTool } from '../tools/write-file.js';
import process from 'node:process';
import { isGitRepository } from '../utils/gitUtils.js';
import { GEMINI_CONFIG_DIR } from '../tools/memoryTool.js';
import { MemoryTool, GEMINI_CONFIG_DIR } from '../tools/memoryTool.js';
import { TodoWriteTool } from '../tools/todoWrite.js';
import { TaskTool } from '../tools/task.js';
import type { GenerateContentConfig } from '@google/genai';
export interface ModelTemplateMapping {
@@ -83,7 +91,6 @@ export function getCustomSystemPrompt(
export function getCoreSystemPrompt(
userMemory?: string,
config?: SystemPromptConfig,
model?: string,
): string {
// if GEMINI_SYSTEM_MD is set (and not 0|false), override system prompt from file
// default path is .gemini/system.md but can be modified via custom path in GEMINI_SYSTEM_MD
@@ -170,11 +177,11 @@ You are Qwen Code, an interactive CLI agent developed by Alibaba Group, speciali
- **Proactiveness:** Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions.
- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked *how* to do something, explain first, don't just do it.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Path Construction:** Before using any file system tool (e.g., ${ToolNames.READ_FILE}' or '${ToolNames.WRITE_FILE}'), you must construct the full absolute path for the file_path argument. Always combine the absolute path of the project's root directory with the file's path relative to the root. For example, if the project root is /path/to/project/ and the file is foo/bar/baz.txt, the final path you must use is /path/to/project/foo/bar/baz.txt. If the user provides a relative path, you must resolve it against the root directory to create an absolute path.
- **Path Construction:** Before using any file system tool (e.g., ${ReadFileTool.Name}' or '${WriteFileTool.Name}'), you must construct the full absolute path for the file_path argument. Always combine the absolute path of the project's root directory with the file's path relative to the root. For example, if the project root is /path/to/project/ and the file is foo/bar/baz.txt, the final path you must use is /path/to/project/foo/bar/baz.txt. If the user provides a relative path, you must resolve it against the root directory to create an absolute path.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
# Task Management
You have access to the ${ToolNames.TODO_WRITE} tool to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
You have access to the ${TodoWriteTool.Name} tool to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
@@ -183,13 +190,13 @@ Examples:
<example>
user: Run the build and fix any type errors
assistant: I'm going to use the ${ToolNames.TODO_WRITE} tool to write the following items to the todo list:
assistant: I'm going to use the ${TodoWriteTool.Name} tool to write the following items to the todo list:
- Run the build
- Fix any type errors
I'm now going to run the build using Bash.
Looks like I found 10 type errors. I'm going to use the ${ToolNames.TODO_WRITE} tool to write 10 items to the todo list.
Looks like I found 10 type errors. I'm going to use the ${TodoWriteTool.Name} tool to write 10 items to the todo list.
marking the first todo as in_progress
@@ -204,7 +211,7 @@ In the above example, the assistant completes all the tasks, including the 10 er
<example>
user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
A: I'll help you implement a usage metrics tracking and export feature. Let me first use the ${ToolNames.TODO_WRITE} tool to plan this task.
A: I'll help you implement a usage metrics tracking and export feature. Let me first use the ${TodoWriteTool.Name} tool to plan this task.
Adding the following todos to the todo list:
1. Research existing metrics tracking in the codebase
2. Design the metrics collection system
@@ -225,8 +232,8 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the '${ToolNames.TODO_WRITE}' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use '${ToolNames.GREP}', '${ToolNames.GLOB}', '${ToolNames.READ_FILE}', and '${ToolNames.READ_MANY_FILES}' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., '${ToolNames.EDIT}', '${ToolNames.WRITE_FILE}' '${ToolNames.SHELL}' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the '${TodoWriteTool.Name}' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use '${GrepTool.Name}', '${GlobTool.Name}', '${ReadFileTool.Name}', and '${ReadManyFilesTool.Name}' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., '${EditTool.Name}', '${WriteFileTool.Name}' '${ShellTool.Name}' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -235,11 +242,11 @@ When requested to perform tasks like fixing bugs, adding features, refactoring,
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
IMPORTANT: Always use the ${ToolNames.TODO_WRITE} tool to plan and track tasks throughout the conversation.
IMPORTANT: Always use the ${TodoWriteTool.Name} tool to plan and track tasks throughout the conversation.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are '${ToolNames.WRITE_FILE}', '${ToolNames.EDIT}' and '${ToolNames.SHELL}'.
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are '${WriteFileTool.Name}', '${EditTool.Name}' and '${ShellTool.Name}'.
1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions.
2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner.
@@ -252,7 +259,7 @@ IMPORTANT: Always use the ${ToolNames.TODO_WRITE} tool to plan and track tasks t
- **3d Games:** HTML/CSS/JavaScript with Three.js.
- **2d Games:** HTML/CSS/JavaScript.
3. **User Approval:** Obtain user approval for the proposed plan.
4. **Implementation:** Use the '${ToolNames.TODO_WRITE}' tool to convert the approved plan into a structured todo list with specific, actionable tasks, then autonomously implement each task utilizing all available tools. When starting ensure you scaffold the application using '${ToolNames.SHELL}' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible.
4. **Implementation:** Use the '${TodoWriteTool.Name}' tool to convert the approved plan into a structured todo list with specific, actionable tasks, then autonomously implement each task utilizing all available tools. When starting ensure you scaffold the application using '${ShellTool.Name}' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible.
5. **Verify:** Review work against the original request, the approved plan. Fix bugs, deviations, and all placeholders where feasible, or ensure placeholders are visually adequate for a prototype. Ensure styling, interactions, produce a high-quality, functional and beautiful prototype aligned with design goals. Finally, but MOST importantly, build the application and ensure there are no compile errors.
6. **Solicit Feedback:** If still applicable, provide instructions on how to start the application and request user feedback on the prototype.
@@ -268,18 +275,18 @@ IMPORTANT: Always use the ${ToolNames.TODO_WRITE} tool to plan and track tasks t
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate.
## Security and Safety Rules
- **Explain Critical Commands:** Before executing commands with '${ToolNames.SHELL}' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
- **Explain Critical Commands:** Before executing commands with '${ShellTool.Name}' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **File Paths:** Always use absolute paths when referring to files with tools like '${ToolNames.READ_FILE}' or '${ToolNames.WRITE_FILE}'. Relative paths are not supported. You must provide an absolute path.
- **File Paths:** Always use absolute paths when referring to files with tools like '${ReadFileTool.Name}' or '${WriteFileTool.Name}'. Relative paths are not supported. You must provide an absolute path.
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the '${ToolNames.SHELL}' tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Command Execution:** Use the '${ShellTool.Name}' tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user.
- **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user.
- **Task Management:** Use the '${ToolNames.TODO_WRITE}' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed.
- **Subagent Delegation:** When doing file search, prefer to use the '${ToolNames.TASK}' tool in order to reduce context usage. You should proactively use the '${ToolNames.TASK}' tool with specialized agents when the task at hand matches the agent's description.
- **Remembering Facts:** Use the '${ToolNames.MEMORY}' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Task Management:** Use the '${TodoWriteTool.Name}' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed.
- **Subagent Delegation:** When doing file search, prefer to use the '${TaskTool.Name}' tool in order to reduce context usage. You should proactively use the '${TaskTool.Name}' tool with specialized agents when the task at hand matches the agent's description.
- **Remembering Facts:** Use the '${MemoryTool.Name}' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
## Interaction Details
@@ -331,10 +338,157 @@ ${(function () {
return '';
})()}
${getToolCallExamples(model || '')}
# Examples (Illustrating Tone and Workflow)
<example>
user: 1 + 2
model: 3
</example>
<example>
user: is 13 a prime number?
model: true
</example>
<example>
user: start the server implemented in server.js
model:
<tool_call>
<function=run_shell_command>
<parameter=command>
node server.js &
</parameter>
</function>
</tool_call>
</example>
<example>
user: Refactor the auth logic in src/auth.py to use the requests library instead of urllib.
model: Okay, I can refactor 'src/auth.py'.
First, I'll analyze the code and check for a test safety net before planning any changes.
<tool_call>
<function=glob>
<parameter=path>
tests/test_auth.py
</parameter>
</function>
</tool_call>
<tool_call>
<function=read_file>
<parameter=path>
/path/to/tests/test_auth.py
</parameter>
<parameter=offset>
0
</parameter>
<parameter=limit>
10
</parameter>
</function>
</tool_call>
(After analysis)
Great, 'tests/test_auth.py' exists and covers the core authentication logic. With this safety net in place, I can safely plan the refactoring.
I'll also confirm 'requests' is a dependency.
<tool_call>
<function=read_file>
<parameter=path>
/path/to/requirements.txt
</parameter>
</function>
</tool_call>
(After analysis)
Looks good, 'requests' is available.
Here's the plan:
1. Replace the 'urllib' calls with 'requests'.
2. Add proper 'try...except' error handling for the new network calls.
3. Remove the old 'urllib' import.
4. Run the project's linter and tests to verify the changes.
<tool_call>
<function=replace>
<parameter=path>
src/auth.py
</parameter>
<parameter=old_content>
(old code content)
</parameter>
<parameter=new_content>
(new code content)
</parameter>
</function>
</tool_call>
Refactoring complete. Running verification...
<tool_call>
<function=run_shell_command
<parameter=command>
ruff check src/auth.py && pytest
</parameter>
</function>
</tool_call>
(After verification passes)
All checks passed. This is a stable checkpoint.
Would you like me to write a commit message and commit these changes?
</example>
<example>
user: Delete the temp directory.
model: I can run \`rm -rf /path/to/project/temp\`. This will permanently delete the directory and all its contents.
</example>
<example>
user: Write tests for someFile.ts
model:
Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality.
<tool_call>
<function=read_file>
<parameter=path>
/path/to/someFile.ts
</parameter>
</function>
</tool_call>
Now I'll look for existing or related test files to understand current testing conventions and dependencies.
<tool_call>
<function>read_many_files for paths ['**/*.test.ts', 'src/**/*.spec.ts'] assuming someFile.ts is in the src directory]
</tool_call>
(After reviewing existing tests and the file content)
<tool_call>
<function=write_file>
<parameter=path>
/path/to/someFile.test.ts
</parameter>
</function>
</tool_call>
I've written the tests. Now I'll run the project's test command to verify them.
<tool_call>
<function=run_shell_command>
<parameter=command>
npm run test
</parameter>
</function>
</tool_call>
(After verification passes)
All checks passed. This is a stable checkpoint.
</example>
<example>
user: Where are all the 'app.config' files in this project? I need to check their settings.
model:
<tool_call>
<function=glob>
<parameter=pattern>
./**/app.config
</parameter>
</function>
</tool_call>
(Assuming GlobTool returns a list of paths like ['/path/to/moduleA/app.config', '/path/to/moduleB/app.config'])
I found the following 'app.config' files:
- /path/to/moduleA/app.config
- /path/to/moduleB/app.config
To help you check their settings, I can read their contents. Which one would you like to start with, or should I read all of them?
</example>
# Final Reminder
Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use '${ToolNames.READ_FILE}' or '${ToolNames.READ_MANY_FILES}' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved.
Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use '${ReadFileTool.Name}' or '${ReadManyFilesTool.Name}' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved.
`.trim();
// if GEMINI_WRITE_SYSTEM_MD is set (and not 0|false), write base system prompt to file
@@ -461,374 +615,3 @@ You are a specialized context summarizer that creates a comprehensive markdown s
`.trim();
}
const generalToolCallExamples = `
# Examples (Illustrating Tone and Workflow)
<example>
user: 1 + 2
model: 3
</example>
<example>
user: is 13 a prime number?
model: true
</example>
<example>
user: start the server implemented in server.js
model: [tool_call: ${ToolNames.SHELL} for 'node server.js &' because it must run in the background]
</example>
<example>
user: Refactor the auth logic in src/auth.py to use the requests library instead of urllib.
model: Okay, I can refactor 'src/auth.py'.
First, I'll analyze the code and check for a test safety net before planning any changes.
[tool_call: ${ToolNames.GLOB} for path 'tests/test_auth.py']
[tool_call: ${ToolNames.READ_FILE} for path '/path/to/tests/test_auth.py' with offset 0 and limit 10]
(After analysis)
Great, 'tests/test_auth.py' exists and covers the core authentication logic. With this safety net in place, I can safely plan the refactoring.
I'll also confirm 'requests' is a dependency.
[tool_call: ${ToolNames.READ_FILE} for path '/path/to/requirements.txt']
(After analysis)
Looks good, 'requests' is available.
Here's the plan:
1. Replace the 'urllib' calls with 'requests'.
2. Add proper 'try...except' error handling for the new network calls.
3. Remove the old 'urllib' import.
4. Run the project's linter and tests to verify the changes.
[tool_call: ${ToolNames.EDIT} for path 'src/auth.py' replacing old content with new content]
Refactoring complete. Running verification...
[tool_call: ${ToolNames.SHELL} for 'ruff check src/auth.py && pytest']
(After verification passes)
All checks passed. This is a stable checkpoint.
Would you like me to write a commit message and commit these changes?
</example>
<example>
user: Delete the temp directory.
model: I can run \`rm -rf /path/to/project/temp\`. This will permanently delete the directory and all its contents.
</example>
<example>
user: Write tests for someFile.ts
model:
Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality.
[tool_call: ${ToolNames.READ_FILE} for path '/path/to/someFile.ts']
Now I'll look for existing or related test files to understand current testing conventions and dependencies.
[tool_call: ${ToolNames.READ_MANY_FILES} for paths ['**/*.test.ts', 'src/**/*.spec.ts']]
(After reviewing existing tests and the file content)
[tool_call: ${ToolNames.WRITE_FILE} for path '/path/to/someFile.test.ts']
I've written the tests. Now I'll run the project's test command to verify them.
[tool_call: ${ToolNames.SHELL} for 'npm run test']
(After verification passes)
All checks passed. This is a stable checkpoint.
</example>
<example>
user: Where are all the 'app.config' files in this project? I need to check their settings.
model:
[tool_call: ${ToolNames.GLOB} for pattern './**/app.config']
(Assuming GlobTool returns a list of paths like ['/path/to/moduleA/app.config', '/path/to/moduleB/app.config'])
I found the following 'app.config' files:
- /path/to/moduleA/app.config
- /path/to/moduleB/app.config
To help you check their settings, I can read their contents. Which one would you like to start with, or should I read all of them?
</example>
`.trim();
const qwenCoderToolCallExamples = `
# Examples (Illustrating Tone and Workflow)
<example>
user: 1 + 2
model: 3
</example>
<example>
user: is 13 a prime number?
model: true
</example>
<example>
user: start the server implemented in server.js
model:
<tool_call>
<function=${ToolNames.SHELL}>
<parameter=command>
node server.js &
</parameter>
</function>
</tool_call>
</example>
<example>
user: Refactor the auth logic in src/auth.py to use the requests library instead of urllib.
model: Okay, I can refactor 'src/auth.py'.
First, I'll analyze the code and check for a test safety net before planning any changes.
<tool_call>
<function=${ToolNames.GLOB}>
<parameter=path>
tests/test_auth.py
</parameter>
</function>
</tool_call>
<tool_call>
<function=${ToolNames.READ_FILE}>
<parameter=path>
/path/to/tests/test_auth.py
</parameter>
<parameter=offset>
0
</parameter>
<parameter=limit>
10
</parameter>
</function>
</tool_call>
(After analysis)
Great, 'tests/test_auth.py' exists and covers the core authentication logic. With this safety net in place, I can safely plan the refactoring.
I'll also confirm 'requests' is a dependency.
<tool_call>
<function=${ToolNames.READ_FILE}>
<parameter=path>
/path/to/requirements.txt
</parameter>
</function>
</tool_call>
(After analysis)
Looks good, 'requests' is available.
Here's the plan:
1. Replace the 'urllib' calls with 'requests'.
2. Add proper 'try...except' error handling for the new network calls.
3. Remove the old 'urllib' import.
4. Run the project's linter and tests to verify the changes.
<tool_call>
<function=${ToolNames.EDIT}>
<parameter=path>
src/auth.py
</parameter>
<parameter=old_content>
(old code content)
</parameter>
<parameter=new_content>
(new code content)
</parameter>
</function>
</tool_call>
Refactoring complete. Running verification...
<tool_call>
<function=${ToolNames.SHELL}>
<parameter=command>
ruff check src/auth.py && pytest
</parameter>
</function>
</tool_call>
(After verification passes)
All checks passed. This is a stable checkpoint.
Would you like me to write a commit message and commit these changes?
</example>
<example>
user: Delete the temp directory.
model: I can run \`rm -rf /path/to/project/temp\`. This will permanently delete the directory and all its contents.
</example>
<example>
user: Write tests for someFile.ts
model:
Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality.
<tool_call>
<function=${ToolNames.READ_FILE}>
<parameter=path>
/path/to/someFile.ts
</parameter>
</function>
</tool_call>
Now I'll look for existing or related test files to understand current testing conventions and dependencies.
<tool_call>
<function=${ToolNames.READ_MANY_FILES}>
<parameter=paths>
['**/*.test.ts', 'src/**/*.spec.ts']
</parameter>
</function>
</tool_call>
(After reviewing existing tests and the file content)
<tool_call>
<function=${ToolNames.WRITE_FILE}>
<parameter=path>
/path/to/someFile.test.ts
</parameter>
</function>
</tool_call>
I've written the tests. Now I'll run the project's test command to verify them.
<tool_call>
<function=${ToolNames.SHELL}>
<parameter=command>
npm run test
</parameter>
</function>
</tool_call>
(After verification passes)
All checks passed. This is a stable checkpoint.
</example>
<example>
user: Where are all the 'app.config' files in this project? I need to check their settings.
model:
<tool_call>
<function=${ToolNames.GLOB}>
<parameter=pattern>
./**/app.config
</parameter>
</function>
</tool_call>
(Assuming GlobTool returns a list of paths like ['/path/to/moduleA/app.config', '/path/to/moduleB/app.config'])
I found the following 'app.config' files:
- /path/to/moduleA/app.config
- /path/to/moduleB/app.config
To help you check their settings, I can read their contents. Which one would you like to start with, or should I read all of them?
</example>
`.trim();
const qwenVlToolCallExamples = `
# Examples (Illustrating Tone and Workflow)
<example>
user: 1 + 2
model: 3
</example>
<example>
user: is 13 a prime number?
model: true
</example>
<example>
user: start the server implemented in server.js
model:
<tool_call>
{"name": "${ToolNames.SHELL}", "arguments": {"command": "node server.js &"}}
</tool_call>
</example>
<example>
user: Refactor the auth logic in src/auth.py to use the requests library instead of urllib.
model: Okay, I can refactor 'src/auth.py'.
First, I'll analyze the code and check for a test safety net before planning any changes.
<tool_call>
{"name": "${ToolNames.GLOB}", "arguments": {"path": "tests/test_auth.py"}}
</tool_call>
<tool_call>
{"name": "${ToolNames.READ_FILE}", "arguments": {"path": "/path/to/tests/test_auth.py", "offset": 0, "limit": 10}}
</tool_call>
(After analysis)
Great, 'tests/test_auth.py' exists and covers the core authentication logic. With this safety net in place, I can safely plan the refactoring.
I'll also confirm 'requests' is a dependency.
<tool_call>
{"name": "${ToolNames.READ_FILE}", "arguments": {"path": "/path/to/requirements.txt"}}
</tool_call>
(After analysis)
Looks good, 'requests' is available.
Here's the plan:
1. Replace the 'urllib' calls with 'requests'.
2. Add proper 'try...except' error handling for the new network calls.
3. Remove the old 'urllib' import.
4. Run the project's linter and tests to verify the changes.
<tool_call>
{"name": "${ToolNames.EDIT}", "arguments": {"path": "src/auth.py", "old_content": "(old code content)", "new_content": "(new code content)"}}
</tool_call>
Refactoring complete. Running verification...
<tool_call>
{"name": "${ToolNames.SHELL}", "arguments": {"command": "ruff check src/auth.py && pytest"}}
</tool_call>
(After verification passes)
All checks passed. This is a stable checkpoint.
Would you like me to write a commit message and commit these changes?
</example>
<example>
user: Delete the temp directory.
model: I can run \`rm -rf /path/to/project/temp\`. This will permanently delete the directory and all its contents.
</example>
<example>
user: Write tests for someFile.ts
model:
Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality.
<tool_call>
{"name": "${ToolNames.READ_FILE}", "arguments": {"path": "/path/to/someFile.ts"}}
</tool_call>
Now I'll look for existing or related test files to understand current testing conventions and dependencies.
<tool_call>
{"name": "${ToolNames.READ_MANY_FILES}", "arguments": {"paths": ["**/*.test.ts", "src/**/*.spec.ts"]}}
</tool_call>
(After reviewing existing tests and the file content)
<tool_call>
{"name": "${ToolNames.WRITE_FILE}", "arguments": {"path": "/path/to/someFile.test.ts"}}
</tool_call>
I've written the tests. Now I'll run the project's test command to verify them.
<tool_call>
{"name": "${ToolNames.SHELL}", "arguments": {"command": "npm run test"}}
</tool_call>
(After verification passes)
All checks passed. This is a stable checkpoint.
</example>
<example>
user: Where are all the 'app.config' files in this project? I need to check their settings.
model:
<tool_call>
{"name": "${ToolNames.GLOB}", "arguments": {"pattern": "./**/app.config"}}
</tool_call>
(Assuming GlobTool returns a list of paths like ['/path/to/moduleA/app.config', '/path/to/moduleB/app.config'])
I found the following 'app.config' files:
- /path/to/moduleA/app.config
- /path/to/moduleB/app.config
To help you check their settings, I can read their contents. Which one would you like to start with, or should I read all of them?
</example>
`.trim();
function getToolCallExamples(model?: string): string {
// Check for environment variable override first
const toolCallStyle = process.env['QWEN_CODE_TOOL_CALL_STYLE'];
if (toolCallStyle) {
switch (toolCallStyle.toLowerCase()) {
case 'qwen-coder':
return qwenCoderToolCallExamples;
case 'qwen-vl':
return qwenVlToolCallExamples;
case 'general':
return generalToolCallExamples;
default:
console.warn(
`Unknown QWEN_CODE_TOOL_CALL_STYLE value: ${toolCallStyle}. Using model-based detection.`,
);
break;
}
}
// Enhanced regex-based model detection
if (model && model.length < 100) {
// Match qwen*-coder patterns (e.g., qwen3-coder, qwen2.5-coder, qwen-coder)
if (/qwen[^-]*-coder/i.test(model)) {
return qwenCoderToolCallExamples;
}
// Match qwen*-vl patterns (e.g., qwen-vl, qwen2-vl, qwen3-vl)
if (/qwen[^-]*-vl/i.test(model)) {
return qwenVlToolCallExamples;
}
// Match coder-model pattern (same as qwen3-coder)
if (/coder-model/i.test(model)) {
return qwenCoderToolCallExamples;
}
// Match vision-model pattern (same as qwen3-vl)
if (/vision-model/i.test(model)) {
return qwenVlToolCallExamples;
}
}
return generalToolCallExamples;
}

View File

@@ -1,10 +1,5 @@
import { describe, it, expect } from 'vitest';
import {
normalize,
tokenLimit,
DEFAULT_TOKEN_LIMIT,
DEFAULT_OUTPUT_TOKEN_LIMIT,
} from './tokenLimits.js';
import { normalize, tokenLimit, DEFAULT_TOKEN_LIMIT } from './tokenLimits.js';
describe('normalize', () => {
it('should lowercase and trim the model string', () => {
@@ -230,96 +225,3 @@ describe('tokenLimit', () => {
expect(tokenLimit('CLAUDE-3.5-SONNET')).toBe(200000);
});
});
describe('tokenLimit with output type', () => {
describe('Qwen models with output limits', () => {
it('should return the correct output limit for qwen3-coder-plus', () => {
expect(tokenLimit('qwen3-coder-plus', 'output')).toBe(65536);
expect(tokenLimit('qwen3-coder-plus-20250601', 'output')).toBe(65536);
});
it('should return the correct output limit for qwen-vl-max-latest', () => {
expect(tokenLimit('qwen-vl-max-latest', 'output')).toBe(8192);
});
});
describe('Default output limits', () => {
it('should return the default output limit for unknown models', () => {
expect(tokenLimit('unknown-model', 'output')).toBe(
DEFAULT_OUTPUT_TOKEN_LIMIT,
);
expect(tokenLimit('gpt-4', 'output')).toBe(DEFAULT_OUTPUT_TOKEN_LIMIT);
expect(tokenLimit('claude-3.5-sonnet', 'output')).toBe(
DEFAULT_OUTPUT_TOKEN_LIMIT,
);
});
it('should return the default output limit for models without specific output patterns', () => {
expect(tokenLimit('qwen3-coder-7b', 'output')).toBe(
DEFAULT_OUTPUT_TOKEN_LIMIT,
);
expect(tokenLimit('qwen-plus', 'output')).toBe(
DEFAULT_OUTPUT_TOKEN_LIMIT,
);
expect(tokenLimit('qwen-vl-max', 'output')).toBe(
DEFAULT_OUTPUT_TOKEN_LIMIT,
);
});
});
describe('Input vs Output limits comparison', () => {
it('should return different limits for input vs output for qwen3-coder-plus', () => {
expect(tokenLimit('qwen3-coder-plus', 'input')).toBe(1048576); // 1M input
expect(tokenLimit('qwen3-coder-plus', 'output')).toBe(65536); // 64K output
});
it('should return different limits for input vs output for qwen-vl-max-latest', () => {
expect(tokenLimit('qwen-vl-max-latest', 'input')).toBe(131072); // 128K input
expect(tokenLimit('qwen-vl-max-latest', 'output')).toBe(8192); // 8K output
});
it('should return same default limits for unknown models', () => {
expect(tokenLimit('unknown-model', 'input')).toBe(DEFAULT_TOKEN_LIMIT); // 128K input
expect(tokenLimit('unknown-model', 'output')).toBe(
DEFAULT_OUTPUT_TOKEN_LIMIT,
); // 4K output
});
});
describe('Backward compatibility', () => {
it('should default to input type when no type is specified', () => {
expect(tokenLimit('qwen3-coder-plus')).toBe(1048576); // Should be input limit
expect(tokenLimit('qwen-vl-max-latest')).toBe(131072); // Should be input limit
expect(tokenLimit('unknown-model')).toBe(DEFAULT_TOKEN_LIMIT); // Should be input default
});
it('should work with explicit input type', () => {
expect(tokenLimit('qwen3-coder-plus', 'input')).toBe(1048576);
expect(tokenLimit('qwen-vl-max-latest', 'input')).toBe(131072);
expect(tokenLimit('unknown-model', 'input')).toBe(DEFAULT_TOKEN_LIMIT);
});
});
describe('Model normalization with output limits', () => {
it('should handle normalized model names for output limits', () => {
expect(tokenLimit('QWEN3-CODER-PLUS', 'output')).toBe(65536);
expect(tokenLimit('qwen3-coder-plus-20250601', 'output')).toBe(65536);
expect(tokenLimit('QWEN-VL-MAX-LATEST', 'output')).toBe(8192);
});
it('should handle complex model strings for output limits', () => {
expect(
tokenLimit(
' a/b/c|QWEN3-CODER-PLUS:qwen3-coder-plus-2024-05-13 ',
'output',
),
).toBe(65536);
expect(
tokenLimit(
'provider/qwen-vl-max-latest:qwen-vl-max-latest-v1',
'output',
),
).toBe(8192);
});
});
});

View File

@@ -1,15 +1,7 @@
type Model = string;
type TokenCount = number;
/**
* Token limit types for different use cases.
* - 'input': Maximum input context window size
* - 'output': Maximum output tokens that can be generated in a single response
*/
export type TokenLimitType = 'input' | 'output';
export const DEFAULT_TOKEN_LIMIT: TokenCount = 131_072; // 128K (power-of-two)
export const DEFAULT_OUTPUT_TOKEN_LIMIT: TokenCount = 4_096; // 4K tokens
/**
* Accurate numeric limits:
@@ -26,10 +18,6 @@ const LIMITS = {
'1m': 1_048_576,
'2m': 2_097_152,
'10m': 10_485_760, // 10 million tokens
// Output token limits (typically much smaller than input limits)
'4k': 4_096,
'8k': 8_192,
'16k': 16_384,
} as const;
/** Robust normalizer: strips provider prefixes, pipes/colons, date/version suffixes, etc. */
@@ -48,7 +36,7 @@ export function normalize(model: string): string {
// - dates (e.g., -20250219), -v1, version numbers, 'latest', 'preview' etc.
s = s.replace(/-preview/g, '');
// Special handling for Qwen model names that include "-latest" as part of the model name
if (!s.match(/^qwen-(?:plus|flash|vl-max)-latest$/)) {
if (!s.match(/^qwen-(?:plus|flash)-latest$/)) {
// \d{6,} - Match 6 or more digits (dates) like -20250219 (6+ digit dates)
// \d+x\d+b - Match patterns like 4x8b, -7b, -70b
// v\d+(?:\.\d+)* - Match version patterns starting with 'v' like -v1, -v1.2, -v2.1.3
@@ -111,12 +99,6 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [
// Commercial Qwen3-Coder-Flash: 1M token context
[/^qwen3-coder-flash(-.*)?$/, LIMITS['1m']], // catches "qwen3-coder-flash" and date variants
// Generic coder-model: same as qwen3-coder-plus (1M token context)
[/^coder-model$/, LIMITS['1m']],
// Commercial Qwen3-Max-Preview: 256K token context
[/^qwen3-max-preview(-.*)?$/, LIMITS['256k']], // catches "qwen3-max-preview" and date variants
// Open-source Qwen3-Coder variants: 256K native
[/^qwen3-coder-.*$/, LIMITS['256k']],
// Open-source Qwen3 2507 variants: 256K native
@@ -137,9 +119,6 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [
// Qwen Vision Models
[/^qwen-vl-max.*$/, LIMITS['128k']],
// Generic vision-model: same as qwen-vl-max (128K token context)
[/^vision-model$/, LIMITS['128k']],
// -------------------
// ByteDance Seed-OSS (512K)
// -------------------
@@ -163,60 +142,16 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [
[/^mistral-large-2.*$/, LIMITS['128k']],
];
/**
* Output token limit patterns for specific model families.
* These patterns define the maximum number of tokens that can be generated
* in a single response for specific models.
*/
const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [
// -------------------
// Alibaba / Qwen - DashScope Models
// -------------------
// Qwen3-Coder-Plus: 65,536 max output tokens
[/^qwen3-coder-plus(-.*)?$/, LIMITS['64k']],
// Generic coder-model: same as qwen3-coder-plus (64K max output tokens)
[/^coder-model$/, LIMITS['64k']],
// Qwen3-Max-Preview: 65,536 max output tokens
[/^qwen3-max-preview(-.*)?$/, LIMITS['64k']],
// Qwen-VL-Max-Latest: 8,192 max output tokens
[/^qwen-vl-max-latest$/, LIMITS['8k']],
// Generic vision-model: same as qwen-vl-max-latest (8K max output tokens)
[/^vision-model$/, LIMITS['8k']],
// Qwen3-VL-Plus: 8,192 max output tokens
[/^qwen3-vl-plus$/, LIMITS['8k']],
];
/**
* Return the token limit for a model string based on the specified type.
*
* This function determines the maximum number of tokens for either input context
* or output generation based on the model and token type. It uses the same
* normalization logic for consistency across both input and output limits.
*
* @param model - The model name to get the token limit for
* @param type - The type of token limit ('input' for context window, 'output' for generation)
* @returns The maximum number of tokens allowed for this model and type
*/
export function tokenLimit(
model: Model,
type: TokenLimitType = 'input',
): TokenCount {
/** Return the token limit for a model string (uses normalize + ordered regex list). */
export function tokenLimit(model: Model): TokenCount {
const norm = normalize(model);
// Choose the appropriate patterns based on token type
const patterns = type === 'output' ? OUTPUT_PATTERNS : PATTERNS;
for (const [regex, limit] of patterns) {
for (const [regex, limit] of PATTERNS) {
if (regex.test(norm)) {
return limit;
}
}
// Return appropriate default based on token type
return type === 'output' ? DEFAULT_OUTPUT_TOKEN_LIMIT : DEFAULT_TOKEN_LIMIT;
// final fallback: DEFAULT_TOKEN_LIMIT (power-of-two 128K)
return DEFAULT_TOKEN_LIMIT;
}

View File

@@ -712,6 +712,8 @@ async function authWithQwenDeviceFlow(
`Polling... (attempt ${attempt + 1}/${maxAttempts})`,
);
process.stdout.write('.');
// Wait with cancellation check every 100ms
await new Promise<void>((resolve) => {
const checkInterval = 100; // Check every 100ms

View File

@@ -901,37 +901,5 @@ describe('SharedTokenManager', () => {
);
}
});
it('should properly clean up timeout when file operation completes before timeout', async () => {
const tokenManager = SharedTokenManager.getInstance();
tokenManager.clearCache();
const mockClient = {
getCredentials: vi.fn().mockReturnValue(null),
setCredentials: vi.fn(),
getAccessToken: vi.fn(),
requestDeviceAuthorization: vi.fn(),
pollDeviceToken: vi.fn(),
refreshAccessToken: vi.fn(),
};
// Mock clearTimeout to verify it's called
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
// Mock file stat to resolve quickly (before timeout)
mockFs.stat.mockResolvedValue({ mtimeMs: 12345 } as Stats);
// Call checkAndReloadIfNeeded which uses withTimeout internally
const checkMethod = getPrivateProperty(
tokenManager,
'checkAndReloadIfNeeded',
) as (client?: IQwenOAuth2Client) => Promise<void>;
await checkMethod.call(tokenManager, mockClient);
// Verify that clearTimeout was called to clean up the timer
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
});
});

View File

@@ -290,36 +290,6 @@ export class SharedTokenManager {
}
}
/**
* Utility method to add timeout to any promise operation
* Properly cleans up the timeout when the promise completes
*/
private withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
operationType = 'Operation',
): Promise<T> {
let timeoutId: NodeJS.Timeout;
return Promise.race([
promise.finally(() => {
// Clear timeout when main promise completes (success or failure)
if (timeoutId) {
clearTimeout(timeoutId);
}
}),
new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() =>
reject(
new Error(`${operationType} timed out after ${timeoutMs}ms`),
),
timeoutMs,
);
}),
]);
}
/**
* Perform the actual file check and reload operation
* This is separated to enable proper promise-based synchronization
@@ -333,12 +303,25 @@ export class SharedTokenManager {
try {
const filePath = this.getCredentialFilePath();
// Add timeout to file stat operation
const withTimeout = async <T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> =>
Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error(`File operation timed out after ${timeoutMs}ms`),
),
timeoutMs,
),
),
]);
const stats = await this.withTimeout(
fs.stat(filePath),
3000,
'File operation',
);
const stats = await withTimeout(fs.stat(filePath), 3000);
const fileModTime = stats.mtimeMs;
// Reload credentials if file has been modified since last cache
@@ -468,7 +451,7 @@ export class SharedTokenManager {
// Check if we have a refresh token before attempting refresh
const currentCredentials = qwenClient.getCredentials();
if (!currentCredentials.refresh_token) {
// console.debug('create a NO_REFRESH_TOKEN error');
console.debug('create a NO_REFRESH_TOKEN error');
throw new TokenManagerError(
TokenError.NO_REFRESH_TOKEN,
'No refresh token available for token refresh',
@@ -606,12 +589,26 @@ export class SharedTokenManager {
const dirPath = path.dirname(filePath);
const tempPath = `${filePath}.tmp.${randomUUID()}`;
// Add timeout wrapper for file operations
const withTimeout = async <T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> =>
Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`Operation timed out after ${timeoutMs}ms`)),
timeoutMs,
),
),
]);
// Create directory with restricted permissions
try {
await this.withTimeout(
await withTimeout(
fs.mkdir(dirPath, { recursive: true, mode: 0o700 }),
5000,
'File operation',
);
} catch (error) {
throw new TokenManagerError(
@@ -625,30 +622,21 @@ export class SharedTokenManager {
try {
// Write to temporary file first with restricted permissions
await this.withTimeout(
await withTimeout(
fs.writeFile(tempPath, credString, { mode: 0o600 }),
5000,
'File operation',
);
// Atomic move to final location
await this.withTimeout(
fs.rename(tempPath, filePath),
5000,
'File operation',
);
await withTimeout(fs.rename(tempPath, filePath), 5000);
// Update cached file modification time atomically after successful write
const stats = await this.withTimeout(
fs.stat(filePath),
5000,
'File operation',
);
const stats = await withTimeout(fs.stat(filePath), 5000);
this.memoryCache.fileModTime = stats.mtimeMs;
} catch (error) {
// Clean up temp file if it exists
try {
await this.withTimeout(fs.unlink(tempPath), 1000, 'File operation');
await withTimeout(fs.unlink(tempPath), 1000);
} catch (_cleanupError) {
// Ignore cleanup errors - temp file might not exist
}

View File

@@ -185,7 +185,6 @@ You are a helpful assistant.
const config = manager.parseSubagentContent(
validMarkdown,
validConfig.filePath,
'project',
);
expect(config.name).toBe('test-agent');
@@ -210,7 +209,6 @@ You are a helpful assistant.
const config = manager.parseSubagentContent(
markdownWithTools,
validConfig.filePath,
'project',
);
expect(config.tools).toEqual(['read_file', 'write_file']);
@@ -231,7 +229,6 @@ You are a helpful assistant.
const config = manager.parseSubagentContent(
markdownWithModel,
validConfig.filePath,
'project',
);
expect(config.modelConfig).toEqual({ model: 'custom-model', temp: 0.5 });
@@ -252,7 +249,6 @@ You are a helpful assistant.
const config = manager.parseSubagentContent(
markdownWithRun,
validConfig.filePath,
'project',
);
expect(config.runConfig).toEqual({ max_time_minutes: 5, max_turns: 10 });
@@ -270,7 +266,6 @@ You are a helpful assistant.
const config = manager.parseSubagentContent(
markdownWithNumeric,
validConfig.filePath,
'project',
);
expect(config.name).toBe('11');
@@ -291,7 +286,6 @@ You are a helpful assistant.
const config = manager.parseSubagentContent(
markdownWithBoolean,
validConfig.filePath,
'project',
);
expect(config.name).toBe('true');
@@ -307,13 +301,8 @@ You are a helpful assistant.
const projectConfig = manager.parseSubagentContent(
validMarkdown,
projectPath,
'project',
);
const userConfig = manager.parseSubagentContent(
validMarkdown,
userPath,
'user',
);
const userConfig = manager.parseSubagentContent(validMarkdown, userPath);
expect(projectConfig.level).toBe('project');
expect(userConfig.level).toBe('user');
@@ -324,11 +313,7 @@ You are a helpful assistant.
Just content`;
expect(() =>
manager.parseSubagentContent(
invalidMarkdown,
validConfig.filePath,
'project',
),
manager.parseSubagentContent(invalidMarkdown, validConfig.filePath),
).toThrow(SubagentError);
});
@@ -341,11 +326,7 @@ You are a helpful assistant.
`;
expect(() =>
manager.parseSubagentContent(
markdownWithoutName,
validConfig.filePath,
'project',
),
manager.parseSubagentContent(markdownWithoutName, validConfig.filePath),
).toThrow(SubagentError);
});
@@ -361,20 +342,39 @@ You are a helpful assistant.
manager.parseSubagentContent(
markdownWithoutDescription,
validConfig.filePath,
'project',
),
).toThrow(SubagentError);
});
it('should warn when filename does not match subagent name', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const mismatchedPath = '/test/project/.qwen/agents/wrong-filename.md';
const config = manager.parseSubagentContent(
validMarkdown,
mismatchedPath,
);
expect(config.name).toBe('test-agent');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Warning: Subagent file "wrong-filename.md" contains name "test-agent"',
),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Consider renaming the file to "test-agent.md"',
),
);
consoleSpy.mockRestore();
});
it('should not warn when filename matches subagent name', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const matchingPath = '/test/project/.qwen/agents/test-agent.md';
const config = manager.parseSubagentContent(
validMarkdown,
matchingPath,
'project',
);
const config = manager.parseSubagentContent(validMarkdown, matchingPath);
expect(config.name).toBe('test-agent');
expect(consoleSpy).not.toHaveBeenCalled();

View File

@@ -39,7 +39,6 @@ const AGENT_CONFIG_DIR = 'agents';
*/
export class SubagentManager {
private readonly validator: SubagentValidator;
private subagentsCache: Map<SubagentLevel, SubagentConfig[]> | null = null;
constructor(private readonly config: Config) {
this.validator = new SubagentValidator();
@@ -93,8 +92,6 @@ export class SubagentManager {
try {
await fs.writeFile(filePath, content, 'utf8');
// Clear cache after successful creation
this.clearCache();
} catch (error) {
throw new SubagentError(
`Failed to write subagent file: ${error instanceof Error ? error.message : 'Unknown error'}`,
@@ -183,8 +180,6 @@ export class SubagentManager {
try {
await fs.writeFile(existing.filePath, content, 'utf8');
// Clear cache after successful update
this.clearCache();
} catch (error) {
throw new SubagentError(
`Failed to update subagent file: ${error instanceof Error ? error.message : 'Unknown error'}`,
@@ -241,9 +236,6 @@ export class SubagentManager {
name,
);
}
// Clear cache after successful deletion
this.clearCache();
}
/**
@@ -262,17 +254,9 @@ export class SubagentManager {
? [options.level]
: ['project', 'user', 'builtin'];
// Check if we should use cache or force refresh
const shouldUseCache = !options.force && this.subagentsCache !== null;
// Initialize cache if it doesn't exist or we're forcing a refresh
if (!shouldUseCache) {
await this.refreshCache();
}
// Collect subagents from each level (project takes precedence over user, user takes precedence over builtin)
for (const level of levelsToCheck) {
const levelSubagents = this.subagentsCache?.get(level) || [];
const levelSubagents = await this.listSubagentsAtLevel(level);
for (const subagent of levelSubagents) {
// Skip if we've already seen this name (precedence: project > user > builtin)
@@ -320,30 +304,6 @@ export class SubagentManager {
return subagents;
}
/**
* Refreshes the subagents cache by loading all subagents from disk.
* This method is called automatically when cache is null or when force=true.
*
* @private
*/
private async refreshCache(): Promise<void> {
this.subagentsCache = new Map();
const levels: SubagentLevel[] = ['project', 'user', 'builtin'];
for (const level of levels) {
const levelSubagents = await this.listSubagentsAtLevel(level);
this.subagentsCache.set(level, levelSubagents);
}
}
/**
* Clears the subagents cache, forcing the next listSubagents call to reload from disk.
*/
clearCache(): void {
this.subagentsCache = null;
}
/**
* Finds a subagent by name and returns its metadata.
*
@@ -369,10 +329,7 @@ export class SubagentManager {
* @returns SubagentConfig
* @throws SubagentError if parsing fails
*/
async parseSubagentFile(
filePath: string,
level: SubagentLevel,
): Promise<SubagentConfig> {
async parseSubagentFile(filePath: string): Promise<SubagentConfig> {
let content: string;
try {
@@ -384,7 +341,7 @@ export class SubagentManager {
);
}
return this.parseSubagentContent(content, filePath, level);
return this.parseSubagentContent(content, filePath);
}
/**
@@ -395,11 +352,7 @@ export class SubagentManager {
* @returns SubagentConfig
* @throws SubagentError if parsing fails
*/
parseSubagentContent(
content: string,
filePath: string,
level: SubagentLevel,
): SubagentConfig {
parseSubagentContent(content: string, filePath: string): SubagentConfig {
try {
// Split frontmatter and content
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
@@ -440,16 +393,31 @@ export class SubagentManager {
| undefined;
const color = frontmatter['color'] as string | undefined;
// Determine level from file path using robust, cross-platform check
// A project-level agent lives under <projectRoot>/.qwen/agents
const projectAgentsDir = path.join(
this.config.getProjectRoot(),
QWEN_CONFIG_DIR,
AGENT_CONFIG_DIR,
);
const rel = path.relative(
path.normalize(projectAgentsDir),
path.normalize(filePath),
);
const isProjectLevel =
rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
const level: SubagentLevel = isProjectLevel ? 'project' : 'user';
const config: SubagentConfig = {
name,
description,
tools,
systemPrompt: systemPrompt.trim(),
level,
filePath,
modelConfig: modelConfig as Partial<ModelConfig>,
runConfig: runConfig as Partial<RunConfig>,
color,
level,
};
// Validate the parsed configuration
@@ -458,6 +426,16 @@ export class SubagentManager {
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
}
// Warn if filename doesn't match subagent name (potential issue)
const expectedFilename = `${config.name}.md`;
const actualFilename = path.basename(filePath);
if (actualFilename !== expectedFilename) {
console.warn(
`Warning: Subagent file "${actualFilename}" contains name "${config.name}" but filename suggests "${path.basename(actualFilename, '.md')}". ` +
`Consider renaming the file to "${expectedFilename}" for consistency.`,
);
}
return config;
} catch (error) {
throw new SubagentError(
@@ -700,18 +678,14 @@ export class SubagentManager {
return BuiltinAgentRegistry.getBuiltinAgents();
}
const projectRoot = this.config.getProjectRoot();
const homeDir = os.homedir();
const isHomeDirectory = path.resolve(projectRoot) === path.resolve(homeDir);
// If project level is requested but project root is same as home directory,
// return empty array to avoid conflicts between project and global agents
if (level === 'project' && isHomeDirectory) {
return [];
}
let baseDir = level === 'project' ? projectRoot : homeDir;
baseDir = path.join(baseDir, QWEN_CONFIG_DIR, AGENT_CONFIG_DIR);
const baseDir =
level === 'project'
? path.join(
this.config.getProjectRoot(),
QWEN_CONFIG_DIR,
AGENT_CONFIG_DIR,
)
: path.join(os.homedir(), QWEN_CONFIG_DIR, AGENT_CONFIG_DIR);
try {
const files = await fs.readdir(baseDir);
@@ -723,7 +697,7 @@ export class SubagentManager {
const filePath = path.join(baseDir, file);
try {
const config = await this.parseSubagentFile(filePath, level);
const config = await this.parseSubagentFile(filePath);
subagents.push(config);
} catch (_error) {
// Ignore invalid files

View File

@@ -23,11 +23,7 @@ import {
} from 'vitest';
import { Config, type ConfigParameters } from '../config/config.js';
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
import {
createContentGenerator,
createContentGeneratorConfig,
AuthType,
} from '../core/contentGenerator.js';
import { createContentGenerator } from '../core/contentGenerator.js';
import { GeminiChat } from '../core/geminiChat.js';
import { executeToolCall } from '../core/nonInteractiveToolExecutor.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
@@ -60,7 +56,8 @@ async function createMockConfig(
};
const config = new Config(configParams);
await config.initialize();
await config.refreshAuth(AuthType.USE_GEMINI);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await config.refreshAuth('test-auth' as any);
// Mock ToolRegistry
const mockToolRegistry = {
@@ -167,10 +164,6 @@ describe('subagent.ts', () => {
getGenerativeModel: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
vi.mocked(createContentGeneratorConfig).mockReturnValue({
model: DEFAULT_GEMINI_MODEL,
authType: undefined,
});
mockSendMessageStream = vi.fn();
// We mock the implementation of the constructor.

View File

@@ -116,9 +116,6 @@ export interface ListSubagentsOptions {
/** Sort direction */
sortOrder?: 'asc' | 'desc';
/** Force refresh from disk, bypassing cache. Defaults to false. */
force?: boolean;
}
/**

View File

@@ -24,7 +24,6 @@ import { ApprovalMode } from '../config/config.js';
import { ensureCorrectEdit } from '../utils/editCorrector.js';
import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js';
import { ReadFileTool } from './read-file.js';
import { ToolNames } from './tool-names.js';
import type {
ModifiableDeclarativeTool,
ModifyContext,
@@ -462,7 +461,7 @@ export class EditTool
extends BaseDeclarativeTool<EditToolParams, ToolResult>
implements ModifiableDeclarativeTool<EditToolParams>
{
static readonly Name = ToolNames.EDIT;
static readonly Name = 'edit';
constructor(private readonly config: Config) {
super(
EditTool.Name,

View File

@@ -62,9 +62,6 @@ describe('GlobTool', () => {
// Ensure a noticeable difference in modification time
await new Promise((resolve) => setTimeout(resolve, 50));
await fs.writeFile(path.join(tempRootDir, 'newer.sortme'), 'newer_content');
// For type coercion testing
await fs.mkdir(path.join(tempRootDir, '123'));
});
afterEach(async () => {
@@ -282,20 +279,26 @@ describe('GlobTool', () => {
);
});
it('should pass if path is provided but is not a string (type coercion)', () => {
it('should return error if path is provided but is not a string (schema validation)', () => {
const params = {
pattern: '*.ts',
path: 123,
} as unknown as GlobToolParams; // Force incorrect type
expect(globTool.validateToolParams(params)).toBeNull();
};
// @ts-expect-error - We're intentionally creating invalid params for testing
expect(globTool.validateToolParams(params)).toBe(
'params/path must be string',
);
});
it('should pass if case_sensitive is provided but is not a boolean (type coercion)', () => {
it('should return error if case_sensitive is provided but is not a boolean (schema validation)', () => {
const params = {
pattern: '*.ts',
case_sensitive: 'true',
} as unknown as GlobToolParams; // Force incorrect type
expect(globTool.validateToolParams(params)).toBeNull();
};
// @ts-expect-error - We're intentionally creating invalid params for testing
expect(globTool.validateToolParams(params)).toBe(
'params/case_sensitive must be boolean',
);
});
it("should return error if search path resolves outside the tool's root directory", () => {

View File

@@ -9,7 +9,6 @@ import path from 'node:path';
import { glob, escape } from 'glob';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames } from './tool-names.js';
import { shortenPath, makeRelative } from '../utils/paths.js';
import type { Config } from '../config/config.js';
import { ToolErrorType } from './tool-error.js';
@@ -253,7 +252,7 @@ class GlobToolInvocation extends BaseToolInvocation<
* Implementation of the Glob tool logic
*/
export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
static readonly Name = ToolNames.GLOB;
static readonly Name = 'glob';
constructor(private config: Config) {
super(

View File

@@ -12,7 +12,6 @@ import { spawn } from 'node:child_process';
import { globStream } from 'glob';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames } from './tool-names.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import { isGitRepository } from '../utils/gitUtils.js';
@@ -598,7 +597,7 @@ class GrepToolInvocation extends BaseToolInvocation<
* Implementation of the Grep tool logic (moved from CLI)
*/
export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
static readonly Name = ToolNames.GREP;
static readonly Name = 'search_file_content'; // Keep static name
constructor(private readonly config: Config) {
super(

View File

@@ -8,7 +8,6 @@ import path from 'node:path';
import { makeRelative, shortenPath } from '../utils/paths.js';
import type { ToolInvocation, ToolLocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames } from './tool-names.js';
import type { PartUnion } from '@google/genai';
import {
@@ -137,7 +136,7 @@ export class ReadFileTool extends BaseDeclarativeTool<
ReadFileToolParams,
ToolResult
> {
static readonly Name: string = ToolNames.READ_FILE;
static readonly Name: string = 'read_file';
constructor(private config: Config) {
super(

View File

@@ -191,12 +191,14 @@ describe('ReadManyFilesTool', () => {
);
});
it('should coerce non-string elements in include array', () => {
it('should throw error if include array contains non-string elements', () => {
const params = {
paths: ['file1.txt'],
include: ['*.ts', 123] as string[],
};
expect(() => tool.build(params)).toBeDefined();
expect(() => tool.build(params)).toThrow(
'params/include/1 must be string',
);
});
it('should throw error if exclude array contains non-string elements', () => {

View File

@@ -6,7 +6,6 @@
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames } from './tool-names.js';
import { getErrorMessage } from '../utils/errors.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -527,7 +526,7 @@ export class ReadManyFilesTool extends BaseDeclarativeTool<
ReadManyFilesParams,
ToolResult
> {
static readonly Name: string = ToolNames.READ_MANY_FILES;
static readonly Name: string = 'read_many_files';
constructor(private config: Config) {
const parameterSchema = {

View File

@@ -8,6 +8,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { EOL } from 'node:os';
import { spawn } from 'node:child_process';
import { rgPath } from '@lvce-editor/ripgrep';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
@@ -17,14 +18,6 @@ import type { Config } from '../config/config.js';
const DEFAULT_TOTAL_MAX_MATCHES = 20000;
/**
* Lazy loads the ripgrep binary path to avoid loading the library until needed
*/
async function getRipgrepPath(): Promise<string> {
const { rgPath } = await import('@lvce-editor/ripgrep');
return rgPath;
}
/**
* Parameters for the GrepTool
*/
@@ -299,9 +292,8 @@ class GrepToolInvocation extends BaseToolInvocation<
rgArgs.push(absolutePath);
try {
const ripgrepPath = await getRipgrepPath();
const output = await new Promise<string>((resolve, reject) => {
const child = spawn(ripgrepPath, rgArgs, {
const child = spawn(rgPath, rgArgs, {
windowsHide: true,
});

View File

@@ -9,7 +9,6 @@ import path from 'node:path';
import os, { EOL } from 'node:os';
import crypto from 'node:crypto';
import type { Config } from '../config/config.js';
import { ToolNames } from './tool-names.js';
import { ToolErrorType } from './tool-error.js';
import type {
ToolInvocation,
@@ -404,7 +403,7 @@ export class ShellTool extends BaseDeclarativeTool<
ShellToolParams,
ToolResult
> {
static Name: string = ToolNames.SHELL;
static Name: string = 'run_shell_command';
private allowlist: Set<string> = new Set();
constructor(private readonly config: Config) {
@@ -420,11 +419,6 @@ export class ShellTool extends BaseDeclarativeTool<
type: 'string',
description: getCommandDescription(),
},
is_background: {
type: 'boolean',
description:
'Whether to run the command in background. Default is false. Set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands.',
},
description: {
type: 'string',
description:

View File

@@ -5,7 +5,6 @@
*/
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames } from './tool-names.js';
import type {
ToolResult,
ToolResultDisplay,
@@ -47,7 +46,7 @@ export interface TaskParams {
* for the model to choose from.
*/
export class TaskTool extends BaseDeclarativeTool<TaskParams, ToolResult> {
static readonly Name: string = ToolNames.TASK;
static readonly Name: string = 'task';
private subagentManager: SubagentManager;
private availableSubagents: SubagentConfig[] = [];

View File

@@ -1,23 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Tool name constants to avoid circular dependencies.
* These constants are used across multiple files and should be kept in sync
* with the actual tool class names.
*/
export const ToolNames = {
EDIT: 'edit',
WRITE_FILE: 'write_file',
READ_FILE: 'read_file',
READ_MANY_FILES: 'read_many_files',
GREP: 'search_file_content',
GLOB: 'glob',
SHELL: 'run_shell_command',
TODO_WRITE: 'todo_write',
MEMORY: 'save_memory',
TASK: 'task',
} as const;

View File

@@ -220,12 +220,14 @@ describe('WriteFileTool', () => {
);
});
it('should coerce null content into an empty string', () => {
it('should throw an error if the content is null', () => {
const dirAsFilePath = path.join(rootDir, 'a_directory');
fs.mkdirSync(dirAsFilePath);
const params = {
file_path: path.join(rootDir, 'test.txt'),
file_path: dirAsFilePath,
content: null,
} as unknown as WriteFileToolParams; // Intentionally non-conforming
expect(() => tool.build(params)).toBeDefined();
expect(() => tool.build(params)).toThrow('params/content must be string');
});
it('should throw error if the file_path is empty', () => {

View File

@@ -31,7 +31,6 @@ import {
ensureCorrectFileContent,
} from '../utils/editCorrector.js';
import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js';
import { ToolNames } from './tool-names.js';
import type {
ModifiableDeclarativeTool,
ModifyContext,
@@ -404,7 +403,7 @@ export class WriteFileTool
extends BaseDeclarativeTool<WriteFileToolParams, ToolResult>
implements ModifiableDeclarativeTool<WriteFileToolParams>
{
static readonly Name: string = ToolNames.WRITE_FILE;
static readonly Name: string = 'write_file';
constructor(private readonly config: Config) {
super(

View File

@@ -7,7 +7,11 @@
import type { Content, GenerateContentConfig } from '@google/genai';
import type { GeminiClient } from '../core/client.js';
import type { EditToolParams } from '../tools/edit.js';
import { ToolNames } from '../tools/tool-names.js';
import { EditTool } from '../tools/edit.js';
import { WriteFileTool } from '../tools/write-file.js';
import { ReadFileTool } from '../tools/read-file.js';
import { ReadManyFilesTool } from '../tools/read-many-files.js';
import { GrepTool } from '../tools/grep.js';
import { LruCache } from './LruCache.js';
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
import {
@@ -81,14 +85,14 @@ async function findLastEditTimestamp(
const history = (await client.getHistory()) ?? [];
// Tools that may reference the file path in their FunctionResponse `output`.
const toolsInResp = new Set<string>([
ToolNames.WRITE_FILE,
ToolNames.EDIT,
ToolNames.READ_MANY_FILES,
ToolNames.GREP,
const toolsInResp = new Set([
WriteFileTool.Name,
EditTool.Name,
ReadManyFilesTool.Name,
GrepTool.Name,
]);
// Tools that may reference the file path in their FunctionCall `args`.
const toolsInCall = new Set<string>([...toolsInResp, ToolNames.READ_FILE]);
const toolsInCall = new Set([...toolsInResp, ReadFileTool.Name]);
// Iterate backwards to find the most recent relevant action.
for (const entry of history.slice().reverse()) {

View File

@@ -9,7 +9,7 @@ import * as addFormats from 'ajv-formats';
// Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const AjvClass = (AjvPkg as any).default || AjvPkg;
const ajValidator = new AjvClass({ coerceTypes: true });
const ajValidator = new AjvClass();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const addFormatsFunc = (addFormats as any).default || addFormats;
addFormatsFunc(ajValidator);
@@ -32,27 +32,8 @@ export class SchemaValidator {
const validate = ajValidator.compile(schema);
const valid = validate(data);
if (!valid && validate.errors) {
// Find any True or False values and lowercase them
fixBooleanCasing(data as Record<string, unknown>);
const validate = ajValidator.compile(schema);
const valid = validate(data);
if (!valid && validate.errors) {
return ajValidator.errorsText(validate.errors, { dataVar: 'params' });
}
return ajValidator.errorsText(validate.errors, { dataVar: 'params' });
}
return null;
}
}
function fixBooleanCasing(data: Record<string, unknown>) {
for (const key of Object.keys(data)) {
if (!(key in data)) continue;
if (typeof data[key] === 'object') {
fixBooleanCasing(data[key] as Record<string, unknown>);
} else if (data[key] === 'True') data[key] = 'true';
else if (data[key] === 'False') data[key] = 'false';
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.0.13-nightly.2",
"version": "0.0.11",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View File

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