mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-26 03:29:16 +00:00
Compare commits
260 Commits
feat/suppo
...
release/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cbb57a793 | ||
|
|
9280739a85 | ||
|
|
b6128ef2d2 | ||
|
|
f7ef720e3b | ||
|
|
0e6ebe85e4 | ||
|
|
3138aa1fe3 | ||
|
|
0f2f1faee5 | ||
|
|
641dd03689 | ||
|
|
44fef93399 | ||
|
|
b073b2db79 | ||
|
|
ccc6192164 | ||
|
|
a5f8c66c35 | ||
|
|
c8d18591b0 | ||
|
|
e9036daa8d | ||
|
|
8e29cc88f9 | ||
|
|
61ce586117 | ||
|
|
90fc4c33f0 | ||
|
|
389d8dd9c4 | ||
|
|
4590138a1e | ||
|
|
0ac191e2db | ||
|
|
9e392b3035 | ||
|
|
65796e2799 | ||
|
|
8fdcd53f53 | ||
|
|
693a58d517 | ||
|
|
c7f4c462f4 | ||
|
|
c94708e448 | ||
|
|
0b854e494f | ||
|
|
f6f4b24356 | ||
|
|
bc5dd87eb4 | ||
|
|
f8aeb06823 | ||
|
|
bca288e742 | ||
|
|
5841370b1a | ||
|
|
0d90d5c118 | ||
|
|
cc0d688c8b | ||
|
|
4eb7aa5448 | ||
|
|
9978fe107b | ||
|
|
b95bb2cd95 | ||
|
|
e895c49f5c | ||
|
|
3191cf73b3 | ||
|
|
f5306339f6 | ||
|
|
026fd468b1 | ||
|
|
d25af87eaf | ||
|
|
a5039d15bf | ||
|
|
3ff916a5f1 | ||
|
|
7bb9bc1e5e | ||
|
|
8b29dd130e | ||
|
|
d0be8b43d7 | ||
|
|
3095442eb3 | ||
|
|
2ceecab503 | ||
|
|
e5ed0334ab | ||
|
|
2b62b1e8bc | ||
|
|
89be6edb5e | ||
|
|
d812c9dcf2 | ||
|
|
d754767e73 | ||
|
|
bb8447edd7 | ||
|
|
02234f5434 | ||
|
|
25261ab88d | ||
|
|
60a58ad8e5 | ||
|
|
c20df192a8 | ||
|
|
b34894c8ea | ||
|
|
ba3b576906 | ||
|
|
b67ee32481 | ||
|
|
5b8ce440ea | ||
|
|
58d3a9c253 | ||
|
|
d06a6d7ef9 | ||
|
|
ae9753a326 | ||
|
|
a02c4b2765 | ||
|
|
0055399cba | ||
|
|
5f78909040 | ||
|
|
5ef3d32f16 | ||
|
|
49c032492a | ||
|
|
4345b9370e | ||
|
|
d2e2a07327 | ||
|
|
5b74422be6 | ||
|
|
06c398a015 | ||
|
|
aec5d6463a | ||
|
|
29032d2c6a | ||
|
|
e91ea3ac1a | ||
|
|
f2a74c74b6 | ||
|
|
21651410c8 | ||
|
|
09cefbcf67 | ||
|
|
5fddcd509c | ||
|
|
d7b9466516 | ||
|
|
fcd4bb9c03 | ||
|
|
828b760820 | ||
|
|
ef3d7b92d0 | ||
|
|
58b9e477bc | ||
|
|
f4edcc5cd2 | ||
|
|
7adb9ed7ff | ||
|
|
f146f062cb | ||
|
|
111234eb24 | ||
|
|
a6a572336c | ||
|
|
96cd685b1b | ||
|
|
e8b4ee111c | ||
|
|
ac0d5206ba | ||
|
|
e5e1e6a3da | ||
|
|
6269415e7b | ||
|
|
efccd44cb4 | ||
|
|
efbf50554d | ||
|
|
63e4794633 | ||
|
|
be71976a1f | ||
|
|
e47263f7c9 | ||
|
|
51b4de0c23 | ||
|
|
67eee14ca9 | ||
|
|
ed44520e51 | ||
|
|
7cd26f728d | ||
|
|
ad79b9bcab | ||
|
|
ad301963a6 | ||
|
|
e538a3d1bf | ||
|
|
413c143004 | ||
|
|
b4be2c6c7f | ||
|
|
8b5b8d2b90 | ||
|
|
6e826b815e | ||
|
|
86b166bb1d | ||
|
|
57a684ad97 | ||
|
|
bf6abf7752 | ||
|
|
541d0b22e5 | ||
|
|
96b275a756 | ||
|
|
ab228c682f | ||
|
|
22943b888d | ||
|
|
96d458fa8c | ||
|
|
0e9255b122 | ||
|
|
3ed0a34b5e | ||
|
|
2949b33a4e | ||
|
|
c218048551 | ||
|
|
be44e7af56 | ||
|
|
ac9cb3a6d3 | ||
|
|
13aa4b03c7 | ||
|
|
75fd2a5dcc | ||
|
|
811b332bc3 | ||
|
|
bf4673b00b | ||
|
|
645a5b181a | ||
|
|
2957058521 | ||
|
|
e7b92622ce | ||
|
|
82f97fe56d | ||
|
|
2c1a836f18 | ||
|
|
3a7b1159ae | ||
|
|
46478e5dd3 | ||
|
|
64de3520b3 | ||
|
|
322ce80e2c | ||
|
|
c6f5a4585e | ||
|
|
b1a439e38f | ||
|
|
a6467e7f9b | ||
|
|
5ed60348d6 | ||
|
|
0851ab572d | ||
|
|
8203f6582f | ||
|
|
2d844d11df | ||
|
|
4145f45c7c | ||
|
|
d56923b657 | ||
|
|
32258f2f04 | ||
|
|
5dec3e653c | ||
|
|
3053e6c41f | ||
|
|
86cd06ef43 | ||
|
|
7270983821 | ||
|
|
b1901f103f | ||
|
|
5701a3c897 | ||
|
|
2145b28f8b | ||
|
|
e3c456a430 | ||
|
|
35f98723ca | ||
|
|
b9b3b6d62e | ||
|
|
cec6b8691a | ||
|
|
05f5189bb4 | ||
|
|
c6299bf135 | ||
|
|
2e449f4d45 | ||
|
|
90fc53a9df | ||
|
|
ed0d5f67db | ||
|
|
1b37d729cb | ||
|
|
1acc24bc17 | ||
|
|
b1e74e5732 | ||
|
|
82205034cc | ||
|
|
c038745897 | ||
|
|
6885138cf0 | ||
|
|
9ae45c01a6 | ||
|
|
5ce40085d5 | ||
|
|
627f5fb43a | ||
|
|
9cc48f12da | ||
|
|
dc340daf8b | ||
|
|
f78b1eff93 | ||
|
|
8bc9bea5a1 | ||
|
|
b986692f94 | ||
|
|
4f63d92bb1 | ||
|
|
3c09ad46ca | ||
|
|
d5ede56e62 | ||
|
|
530039c517 | ||
|
|
0cbf95d6b3 | ||
|
|
579772197a | ||
|
|
934365c41f | ||
|
|
f623bfbb34 | ||
|
|
f503eb2520 | ||
|
|
3cf22c065f | ||
|
|
a1ec1227cc | ||
|
|
36af718616 | ||
|
|
795e7fa2c5 | ||
|
|
b6914c6b33 | ||
|
|
f11d054a47 | ||
|
|
4ad377b0d8 | ||
|
|
b7f9acf0ff | ||
|
|
4dfbdcddca | ||
|
|
826516581b | ||
|
|
4f964b5281 | ||
|
|
de8ea0678d | ||
|
|
c4bcd178a4 | ||
|
|
e5729b0420 | ||
|
|
aceb857436 | ||
|
|
e15dd2f5c9 | ||
|
|
8ac38aad92 | ||
|
|
38fd303b07 | ||
|
|
9899d872a2 | ||
|
|
36a96a7b5c | ||
|
|
951f6b2829 | ||
|
|
eff01819a8 | ||
|
|
31f8ca07b6 | ||
|
|
39adaaff11 | ||
|
|
fd2e5b0933 | ||
|
|
49a2be195d | ||
|
|
ce07fb2b3f | ||
|
|
e2beecb9c4 | ||
|
|
ecc6e22002 | ||
|
|
99f93b457c | ||
|
|
748ad8f4dd | ||
|
|
a33187ed7a | ||
|
|
088c766c22 | ||
|
|
b82ef5b73f | ||
|
|
328924f578 | ||
|
|
1eedd36542 | ||
|
|
9ba99177b9 | ||
|
|
7d2411e72f | ||
|
|
5a9f5e3432 | ||
|
|
95b67bbebd | ||
|
|
492c56a780 | ||
|
|
06a8580361 | ||
|
|
dcc10eb0a9 | ||
|
|
805e5f92c1 | ||
|
|
8cb7ea0d3d | ||
|
|
b534bd2b18 | ||
|
|
6286b8b6e8 | ||
|
|
e81255e589 | ||
|
|
018990b7f6 | ||
|
|
bc2b503e8d | ||
|
|
454cbfdde4 | ||
|
|
04dfad7ab5 | ||
|
|
e02866d06f | ||
|
|
9fcdd3fa77 | ||
|
|
754ae30939 | ||
|
|
0577fe6f36 | ||
|
|
732220e651 | ||
|
|
729a3d0ab3 | ||
|
|
0e3759fbd2 | ||
|
|
f8db157a5d | ||
|
|
f827aadd76 | ||
|
|
39426be9a1 | ||
|
|
f95f6e63bb | ||
|
|
91af599823 | ||
|
|
ad8d7aae8a | ||
|
|
d22d07a840 | ||
|
|
28892996b3 | ||
|
|
eeeb1d490a | ||
|
|
247c237647 | ||
|
|
c423e12aa7 | ||
|
|
dc40995e70 |
20
.github/workflows/release-sdk.yml
vendored
20
.github/workflows/release-sdk.yml
vendored
@@ -132,6 +132,24 @@ jobs:
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
|
||||
- name: 'Build CLI for Integration Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
run: |
|
||||
npm run build
|
||||
npm run bundle
|
||||
|
||||
- name: 'Run SDK Integration Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
run: |
|
||||
npm run test:integration:sdk:sandbox:none
|
||||
npm run test:integration:sdk:sandbox:docker
|
||||
env:
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
|
||||
- name: 'Configure Git User'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
@@ -184,7 +202,7 @@ jobs:
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
scope: '@qwen-code'
|
||||
|
||||
- name: 'Publish @qwen-code/sdk-typescript'
|
||||
- name: 'Publish @qwen-code/sdk'
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
run: |-
|
||||
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
|
||||
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -27,7 +27,7 @@
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "npm: build: vscode-ide-companion"
|
||||
"preLaunchTask": "launch: vscode-ide-companion (copy+build)"
|
||||
},
|
||||
{
|
||||
"name": "Attach",
|
||||
|
||||
16
.vscode/tasks.json
vendored
16
.vscode/tasks.json
vendored
@@ -20,6 +20,22 @@
|
||||
"problemMatcher": [],
|
||||
"label": "npm: build: vscode-ide-companion",
|
||||
"detail": "npm run build -w packages/vscode-ide-companion"
|
||||
},
|
||||
{
|
||||
"label": "copy: bundled-cli (dev)",
|
||||
"type": "shell",
|
||||
"command": "node",
|
||||
"args": ["packages/vscode-ide-companion/scripts/copy-bundled-cli.js"],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "launch: vscode-ide-companion (copy+build)",
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"copy: bundled-cli (dev)",
|
||||
"npm: build: vscode-ide-companion"
|
||||
],
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -88,6 +88,12 @@ npm install -g .
|
||||
brew install qwen-code
|
||||
```
|
||||
|
||||
## VS Code Extension
|
||||
|
||||
In addition to the CLI tool, Qwen Code also provides a **VS Code extension** that brings AI-powered coding assistance directly into your editor with features like file system operations, native diffing, interactive chat, and more.
|
||||
|
||||
> 📦 The extension is currently in development. For installation, features, and development guide, see the [VS Code Extension README](./packages/vscode-ide-companion/README.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -10,19 +10,21 @@ The `/language` command allows you to customize the language settings for both t
|
||||
To change the UI language of Qwen Code, use the `ui` subcommand:
|
||||
|
||||
```
|
||||
/language ui [zh-CN|en-US]
|
||||
/language ui [zh-CN|en-US|ru-RU]
|
||||
```
|
||||
|
||||
### Available UI Languages
|
||||
|
||||
- **zh-CN**: Simplified Chinese (简体中文)
|
||||
- **en-US**: English
|
||||
- **ru-RU**: Russian (Русский)
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
/language ui zh-CN # Set UI language to Simplified Chinese
|
||||
/language ui en-US # Set UI language to English
|
||||
/language ui ru-RU # Set UI language to Russian
|
||||
```
|
||||
|
||||
### UI Language Subcommands
|
||||
@@ -31,6 +33,7 @@ You can also use direct subcommands for convenience:
|
||||
|
||||
- `/language ui zh-CN` or `/language ui zh` or `/language ui 中文`
|
||||
- `/language ui en-US` or `/language ui en` or `/language ui english`
|
||||
- `/language ui ru-RU` or `/language ui ru` or `/language ui русский`
|
||||
|
||||
## LLM Output Language Settings
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// We use TypeScript for React components; prop-types are unnecessary
|
||||
'react/prop-types': 'off',
|
||||
// General Best Practice Rules (subset adapted for flat config)
|
||||
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
|
||||
'arrow-body-style': ['error', 'as-needed'],
|
||||
@@ -111,10 +113,14 @@ export default tseslint.config(
|
||||
{
|
||||
allow: [
|
||||
'react-dom/test-utils',
|
||||
'react-dom/client',
|
||||
'memfs/lib/volume.js',
|
||||
'yargs/**',
|
||||
'msw/node',
|
||||
'**/generated/**'
|
||||
'**/generated/**',
|
||||
'./styles/tailwind.css',
|
||||
'./styles/App.css',
|
||||
'./styles/style.css'
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -25,6 +25,14 @@ type PendingRequest = {
|
||||
timeout: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
type UsageMetadata = {
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
thoughtsTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
cachedTokens?: number | null;
|
||||
};
|
||||
|
||||
type SessionUpdateNotification = {
|
||||
sessionId?: string;
|
||||
update?: {
|
||||
@@ -39,6 +47,9 @@ type SessionUpdateNotification = {
|
||||
text?: string;
|
||||
};
|
||||
modeId?: string;
|
||||
_meta?: {
|
||||
usage?: UsageMetadata;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -587,4 +598,52 @@ function setupAcpTest(
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('receives usage metadata in agent_message_chunk updates', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp usage metadata');
|
||||
|
||||
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
|
||||
|
||||
try {
|
||||
await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
||||
});
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
|
||||
await sendRequest('session/prompt', {
|
||||
sessionId: newSession.sessionId,
|
||||
prompt: [{ type: 'text', text: 'Say "hello".' }],
|
||||
});
|
||||
|
||||
await delay(500);
|
||||
|
||||
// Find updates with usage metadata
|
||||
const updatesWithUsage = sessionUpdates.filter(
|
||||
(u) =>
|
||||
u.update?.sessionUpdate === 'agent_message_chunk' &&
|
||||
u.update?._meta?.usage,
|
||||
);
|
||||
|
||||
expect(updatesWithUsage.length).toBeGreaterThan(0);
|
||||
|
||||
const usage = updatesWithUsage[0].update?._meta?.usage;
|
||||
expect(usage).toBeDefined();
|
||||
expect(
|
||||
typeof usage?.promptTokens === 'number' ||
|
||||
typeof usage?.totalTokens === 'number',
|
||||
).toBe(true);
|
||||
} catch (e) {
|
||||
if (stderr.length) console.error('Agent stderr:', stderr.join(''));
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
isSDKAssistantMessage,
|
||||
type TextBlock,
|
||||
type ContentBlock,
|
||||
} from '@qwen-code/sdk-typescript';
|
||||
} from '@qwen-code/sdk';
|
||||
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
isSDKAssistantMessage,
|
||||
isSDKSystemMessage,
|
||||
type SDKMessage,
|
||||
} from '@qwen-code/sdk-typescript';
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
extractText,
|
||||
@@ -532,7 +532,6 @@ describe('Configuration Options (E2E)', () => {
|
||||
cwd: testDir,
|
||||
authType: 'openai',
|
||||
debug: true,
|
||||
logLevel: 'debug',
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type SDKMessage,
|
||||
type ToolUseBlock,
|
||||
type SDKSystemMessage,
|
||||
} from '@qwen-code/sdk-typescript';
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
createMCPServer,
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type SDKMessage,
|
||||
type ControlMessage,
|
||||
type ToolUseBlock,
|
||||
} from '@qwen-code/sdk-typescript';
|
||||
} from '@qwen-code/sdk';
|
||||
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type SDKUserMessage,
|
||||
type ToolUseBlock,
|
||||
type ContentBlock,
|
||||
} from '@qwen-code/sdk-typescript';
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
createSharedTestOptions,
|
||||
@@ -555,6 +555,15 @@ describe('Permission Control (E2E)', () => {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'default',
|
||||
timeout: {
|
||||
/**
|
||||
* We use a short control request timeout and
|
||||
* wait till the time exceeded to test if
|
||||
* an immediate close() will raise an query close
|
||||
* error and no other uncaught timeout error
|
||||
*/
|
||||
controlRequest: 5000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -563,7 +572,9 @@ describe('Permission Control (E2E)', () => {
|
||||
await expect(q.setPermissionMode('yolo')).rejects.toThrow(
|
||||
'Query is closed',
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 8000));
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe('canUseTool and setPermissionMode integration', () => {
|
||||
@@ -1184,7 +1195,7 @@ describe('Permission Control (E2E)', () => {
|
||||
});
|
||||
|
||||
describe('mode comparison tests', () => {
|
||||
it(
|
||||
it.skip(
|
||||
'should demonstrate different behaviors across all modes for write operations',
|
||||
async () => {
|
||||
const modes: Array<'default' | 'auto-edit' | 'yolo'> = [
|
||||
|
||||
456
integration-tests/sdk-typescript/sdk-mcp-server.test.ts
Normal file
456
integration-tests/sdk-typescript/sdk-mcp-server.test.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E tests for SDK-embedded MCP servers
|
||||
*
|
||||
* Tests that the SDK can create and manage MCP servers running in the SDK process
|
||||
* using the tool() and createSdkMcpServer() APIs.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
query,
|
||||
tool,
|
||||
createSdkMcpServer,
|
||||
isSDKAssistantMessage,
|
||||
isSDKResultMessage,
|
||||
isSDKSystemMessage,
|
||||
type SDKMessage,
|
||||
type SDKSystemMessage,
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
extractText,
|
||||
findToolUseBlocks,
|
||||
createSharedTestOptions,
|
||||
} from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = {
|
||||
...createSharedTestOptions(),
|
||||
permissionMode: 'yolo' as const,
|
||||
};
|
||||
|
||||
describe('SDK MCP Server Integration (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
helper = new SDKTestHelper();
|
||||
testDir = await helper.setup('sdk-mcp-server-integration');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await helper.cleanup();
|
||||
});
|
||||
|
||||
describe('Basic SDK MCP Tool Usage', () => {
|
||||
it('should use SDK MCP tool to perform a simple calculation', async () => {
|
||||
// Define a simple calculator tool using the tool() API with Zod schema
|
||||
const calculatorTool = tool(
|
||||
'calculate_sum',
|
||||
'Calculate the sum of two numbers',
|
||||
z.object({
|
||||
a: z.number().describe('First number'),
|
||||
b: z.number().describe('Second number'),
|
||||
}).shape,
|
||||
async (args) => ({
|
||||
content: [{ type: 'text', text: String(args.a + args.b) }],
|
||||
}),
|
||||
);
|
||||
|
||||
// Create SDK MCP server with the tool
|
||||
const serverConfig = createSdkMcpServer({
|
||||
name: 'sdk-calculator',
|
||||
version: '1.0.0',
|
||||
tools: [calculatorTool],
|
||||
});
|
||||
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the calculate_sum tool to add 25 and 17. Output the result of tool only.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
mcpServers: {
|
||||
'sdk-calculator': serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
let foundToolUse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message, 'calculate_sum');
|
||||
if (toolUseBlocks.length > 0) {
|
||||
foundToolUse = true;
|
||||
}
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool was called
|
||||
expect(foundToolUse).toBe(true);
|
||||
|
||||
// Validate result contains expected answer: 25 + 17 = 42
|
||||
expect(assistantText).toMatch(/42/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
if (isSDKResultMessage(lastMessage)) {
|
||||
expect(lastMessage.subtype).toBe('success');
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should use SDK MCP tool with string operations', async () => {
|
||||
// Define a string manipulation tool with Zod schema
|
||||
const stringTool = tool(
|
||||
'reverse_string',
|
||||
'Reverse a string',
|
||||
{
|
||||
text: z.string().describe('The text to reverse'),
|
||||
},
|
||||
async (args) => ({
|
||||
content: [
|
||||
{ type: 'text', text: args.text.split('').reverse().join('') },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const serverConfig = createSdkMcpServer({
|
||||
name: 'sdk-string-utils',
|
||||
version: '1.0.0',
|
||||
tools: [stringTool],
|
||||
});
|
||||
|
||||
const q = query({
|
||||
prompt: `Use the 'reverse_string' tool to process the word "hello world". Output the tool result only.`,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
mcpServers: {
|
||||
'sdk-string-utils': serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
let foundToolUse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message, 'reverse_string');
|
||||
if (toolUseBlocks.length > 0) {
|
||||
foundToolUse = true;
|
||||
}
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool was called
|
||||
expect(foundToolUse).toBe(true);
|
||||
|
||||
// Validate result contains reversed string: "olleh"
|
||||
expect(assistantText.toLowerCase()).toMatch(/olleh/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple SDK MCP Tools', () => {
|
||||
it('should use multiple tools from the same SDK MCP server', async () => {
|
||||
// Define the Zod schema shape for two numbers
|
||||
const twoNumbersSchema = {
|
||||
a: z.number().describe('First number'),
|
||||
b: z.number().describe('Second number'),
|
||||
};
|
||||
|
||||
// Define multiple tools
|
||||
const addTool = tool(
|
||||
'sdk_add',
|
||||
'Add two numbers',
|
||||
twoNumbersSchema,
|
||||
async (args) => ({
|
||||
content: [{ type: 'text', text: String(args.a + args.b) }],
|
||||
}),
|
||||
);
|
||||
|
||||
const multiplyTool = tool(
|
||||
'sdk_multiply',
|
||||
'Multiply two numbers',
|
||||
twoNumbersSchema,
|
||||
async (args) => ({
|
||||
content: [{ type: 'text', text: String(args.a * args.b) }],
|
||||
}),
|
||||
);
|
||||
|
||||
const serverConfig = createSdkMcpServer({
|
||||
name: 'sdk-math',
|
||||
version: '1.0.0',
|
||||
tools: [addTool, multiplyTool],
|
||||
});
|
||||
|
||||
const q = query({
|
||||
prompt:
|
||||
'First use sdk_add to calculate 10 + 5, then use sdk_multiply to multiply the result by 3. Give me the final answer.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'sdk-math': serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
const toolCalls: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message);
|
||||
toolUseBlocks.forEach((block) => {
|
||||
toolCalls.push(block.name);
|
||||
});
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate both tools were called
|
||||
expect(toolCalls).toContain('sdk_add');
|
||||
expect(toolCalls).toContain('sdk_multiply');
|
||||
|
||||
// Validate result: (10 + 5) * 3 = 45
|
||||
expect(assistantText).toMatch(/45/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SDK MCP Server Discovery', () => {
|
||||
it('should list SDK MCP servers in system init message', async () => {
|
||||
// Define echo tool with Zod schema
|
||||
const echoTool = tool(
|
||||
'echo',
|
||||
'Echo a message',
|
||||
{
|
||||
message: z.string().describe('Message to echo'),
|
||||
},
|
||||
async (args) => ({
|
||||
content: [{ type: 'text', text: args.message }],
|
||||
}),
|
||||
);
|
||||
|
||||
const serverConfig = createSdkMcpServer({
|
||||
name: 'sdk-echo',
|
||||
version: '1.0.0',
|
||||
tools: [echoTool],
|
||||
});
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'sdk-echo': serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let systemMessage: SDKSystemMessage | null = null;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKSystemMessage(message) && message.subtype === 'init') {
|
||||
systemMessage = message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate MCP server is listed
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.mcp_servers).toBeDefined();
|
||||
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
|
||||
|
||||
// Find our SDK MCP server
|
||||
const sdkServer = systemMessage!.mcp_servers?.find(
|
||||
(server) => server.name === 'sdk-echo',
|
||||
);
|
||||
expect(sdkServer).toBeDefined();
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SDK MCP Tool Error Handling', () => {
|
||||
it('should handle tool errors gracefully', async () => {
|
||||
// Define a tool that throws an error with Zod schema
|
||||
const errorTool = tool(
|
||||
'maybe_fail',
|
||||
'A tool that may fail based on input',
|
||||
{
|
||||
shouldFail: z.boolean().describe('If true, the tool will fail'),
|
||||
},
|
||||
async (args) => {
|
||||
if (args.shouldFail) {
|
||||
throw new Error('Tool intentionally failed');
|
||||
}
|
||||
return { content: [{ type: 'text', text: 'Success!' }] };
|
||||
},
|
||||
);
|
||||
|
||||
const serverConfig = createSdkMcpServer({
|
||||
name: 'sdk-error-test',
|
||||
version: '1.0.0',
|
||||
tools: [errorTool],
|
||||
});
|
||||
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the maybe_fail tool with shouldFail set to true. Tell me what happens.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'sdk-error-test': serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let foundToolUse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message, 'maybe_fail');
|
||||
if (toolUseBlocks.length > 0) {
|
||||
foundToolUse = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool should be called
|
||||
expect(foundToolUse).toBe(true);
|
||||
|
||||
// Query should complete (even with tool error)
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Async Tool Handlers', () => {
|
||||
it('should handle async tool handlers with delays', async () => {
|
||||
// Define a tool with async delay using Zod schema
|
||||
const delayedTool = tool(
|
||||
'delayed_response',
|
||||
'Returns a value after a delay',
|
||||
{
|
||||
delay: z.number().describe('Delay in milliseconds (max 100)'),
|
||||
value: z.string().describe('Value to return'),
|
||||
},
|
||||
async (args) => {
|
||||
// Cap delay at 100ms for test performance
|
||||
const actualDelay = Math.min(args.delay, 100);
|
||||
await new Promise((resolve) => setTimeout(resolve, actualDelay));
|
||||
return {
|
||||
content: [{ type: 'text', text: `Delayed result: ${args.value}` }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const serverConfig = createSdkMcpServer({
|
||||
name: 'sdk-async',
|
||||
version: '1.0.0',
|
||||
tools: [delayedTool],
|
||||
});
|
||||
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the delayed_response tool with delay=50 and value="test_async". Tell me the result.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'sdk-async': serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
let foundToolUse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(
|
||||
message,
|
||||
'delayed_response',
|
||||
);
|
||||
if (toolUseBlocks.length > 0) {
|
||||
foundToolUse = true;
|
||||
}
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool was called
|
||||
expect(foundToolUse).toBe(true);
|
||||
|
||||
// Validate result contains the delayed response
|
||||
expect(assistantText.toLowerCase()).toMatch(/test_async/i);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type SDKMessage,
|
||||
type SDKSystemMessage,
|
||||
type SDKAssistantMessage,
|
||||
} from '@qwen-code/sdk-typescript';
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
extractText,
|
||||
@@ -44,7 +44,6 @@ describe('Single-Turn Query (E2E)', () => {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: true,
|
||||
logLevel: 'debug',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type SubagentConfig,
|
||||
type ContentBlock,
|
||||
type ToolUseBlock,
|
||||
} from '@qwen-code/sdk-typescript';
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
extractText,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
isSDKAssistantMessage,
|
||||
isSDKSystemMessage,
|
||||
type SDKUserMessage,
|
||||
} from '@qwen-code/sdk-typescript';
|
||||
} from '@qwen-code/sdk';
|
||||
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
@@ -21,12 +21,12 @@ import type {
|
||||
ContentBlock,
|
||||
TextBlock,
|
||||
ToolUseBlock,
|
||||
} from '@qwen-code/sdk-typescript';
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
isSDKAssistantMessage,
|
||||
isSDKSystemMessage,
|
||||
isSDKResultMessage,
|
||||
} from '@qwen-code/sdk-typescript';
|
||||
} from '@qwen-code/sdk';
|
||||
|
||||
// ============================================================================
|
||||
// Core Test Helper Class
|
||||
|
||||
@@ -12,11 +12,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
query,
|
||||
isSDKAssistantMessage,
|
||||
type SDKMessage,
|
||||
} from '@qwen-code/sdk-typescript';
|
||||
import { query, isSDKAssistantMessage, type SDKMessage } from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
extractText,
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
"allowJs": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@qwen-code/sdk-typescript": [
|
||||
"../packages/sdk-typescript/dist/index.d.ts"
|
||||
]
|
||||
"@qwen-code/sdk": ["../packages/sdk-typescript/dist/index.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
|
||||
@@ -31,7 +31,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
// Use built SDK bundle for e2e tests
|
||||
'@qwen-code/sdk-typescript': resolve(
|
||||
'@qwen-code/sdk': resolve(
|
||||
__dirname,
|
||||
'../packages/sdk-typescript/dist/index.mjs',
|
||||
),
|
||||
|
||||
1256
package-lock.json
generated
1256
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
@@ -37,6 +37,10 @@
|
||||
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
|
||||
"test:integration:sdk:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests sdk-typescript",
|
||||
"test:integration:sdk:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests sdk-typescript",
|
||||
"test:integration:cli:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
|
||||
"test:integration:cli:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
|
||||
"test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests",
|
||||
"test:terminal-bench:oracle": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'oracle'",
|
||||
"test:terminal-bench:qwen": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'qwen'",
|
||||
@@ -89,7 +93,7 @@
|
||||
"eslint-plugin-license-header": "^0.8.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"glob": "^10.4.5",
|
||||
"glob": "^10.5.0",
|
||||
"globals": "^16.0.0",
|
||||
"google-artifactregistry-auth": "^3.4.0",
|
||||
"husky": "^9.1.7",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,7 +33,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -47,7 +47,7 @@
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.4.5",
|
||||
"glob": "^10.5.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "^6.2.3",
|
||||
"ink-gradient": "^3.0.0",
|
||||
@@ -63,7 +63,7 @@
|
||||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^7.10.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"update-notifier": "^7.3.1",
|
||||
|
||||
@@ -88,6 +88,16 @@ export class AgentSideConnection implements Client {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams authentication updates (e.g. Qwen OAuth authUri) to the client.
|
||||
*/
|
||||
async authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void> {
|
||||
return await this.#connection.sendNotification(
|
||||
schema.CLIENT_METHODS.authenticate_update,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request permission before running a tool
|
||||
*
|
||||
@@ -241,9 +251,11 @@ class Connection {
|
||||
).toResult();
|
||||
}
|
||||
|
||||
let errorName;
|
||||
let details;
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorName = error.name;
|
||||
details = error.message;
|
||||
} else if (
|
||||
typeof error === 'object' &&
|
||||
@@ -254,6 +266,10 @@ class Connection {
|
||||
details = error.message;
|
||||
}
|
||||
|
||||
if (errorName === 'TokenManagerError') {
|
||||
return RequestError.authRequired(details).toResult();
|
||||
}
|
||||
|
||||
return RequestError.internalError(details).toResult();
|
||||
}
|
||||
}
|
||||
@@ -357,6 +373,7 @@ export interface Client {
|
||||
params: schema.RequestPermissionRequest,
|
||||
): Promise<schema.RequestPermissionResponse>;
|
||||
sessionUpdate(params: schema.SessionNotification): Promise<void>;
|
||||
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
|
||||
writeTextFile(
|
||||
params: schema.WriteTextFileRequest,
|
||||
): Promise<schema.WriteTextFileResponse>;
|
||||
|
||||
@@ -6,15 +6,19 @@
|
||||
|
||||
import type { ReadableStream, WritableStream } from 'node:stream/web';
|
||||
|
||||
import type { Config, ConversationRecord } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
APPROVAL_MODE_INFO,
|
||||
APPROVAL_MODES,
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
QwenOAuth2Event,
|
||||
qwenOAuth2Events,
|
||||
MCPServerConfig,
|
||||
SessionService,
|
||||
buildApiHistoryFromConversation,
|
||||
type Config,
|
||||
type ConversationRecord,
|
||||
type DeviceAuthorizationData,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { ApprovalModeValue } from './schema.js';
|
||||
import * as acp from './acp.js';
|
||||
@@ -123,13 +127,33 @@ class GeminiAgent {
|
||||
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
|
||||
const method = z.nativeEnum(AuthType).parse(methodId);
|
||||
|
||||
let authUri: string | undefined;
|
||||
const authUriHandler = (deviceAuth: DeviceAuthorizationData) => {
|
||||
authUri = deviceAuth.verification_uri_complete;
|
||||
// Send the auth URL to ACP client as soon as it's available (refreshAuth is blocking).
|
||||
void this.client.authenticateUpdate({ _meta: { authUri } });
|
||||
};
|
||||
|
||||
if (method === AuthType.QWEN_OAUTH) {
|
||||
qwenOAuth2Events.once(QwenOAuth2Event.AuthUri, authUriHandler);
|
||||
}
|
||||
|
||||
await clearCachedCredentialFile();
|
||||
await this.config.refreshAuth(method);
|
||||
this.settings.setValue(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
method,
|
||||
);
|
||||
try {
|
||||
await this.config.refreshAuth(method);
|
||||
this.settings.setValue(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
method,
|
||||
);
|
||||
} finally {
|
||||
// Ensure we don't leak listeners if auth fails early.
|
||||
if (method === AuthType.QWEN_OAUTH) {
|
||||
qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, authUriHandler);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async newSession({
|
||||
@@ -268,14 +292,17 @@ class GeminiAgent {
|
||||
private async ensureAuthenticated(config: Config): Promise<void> {
|
||||
const selectedType = this.settings.merged.security?.auth?.selectedType;
|
||||
if (!selectedType) {
|
||||
throw acp.RequestError.authRequired();
|
||||
throw acp.RequestError.authRequired('No Selected Type');
|
||||
}
|
||||
|
||||
try {
|
||||
await config.refreshAuth(selectedType);
|
||||
// Use true for the second argument to ensure only cached credentials are used
|
||||
await config.refreshAuth(selectedType, true);
|
||||
} catch (e) {
|
||||
console.error(`Authentication failed: ${e}`);
|
||||
throw acp.RequestError.authRequired();
|
||||
throw acp.RequestError.authRequired(
|
||||
'Authentication failed: ' + (e as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export const AGENT_METHODS = {
|
||||
export const CLIENT_METHODS = {
|
||||
fs_read_text_file: 'fs/read_text_file',
|
||||
fs_write_text_file: 'fs/write_text_file',
|
||||
authenticate_update: 'authenticate/update',
|
||||
session_request_permission: 'session/request_permission',
|
||||
session_update: 'session/update',
|
||||
};
|
||||
@@ -57,8 +58,6 @@ export type CancelNotification = z.infer<typeof cancelNotificationSchema>;
|
||||
|
||||
export type AuthenticateRequest = z.infer<typeof authenticateRequestSchema>;
|
||||
|
||||
export type AuthenticateResponse = z.infer<typeof authenticateResponseSchema>;
|
||||
|
||||
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
|
||||
|
||||
export type LoadSessionResponse = z.infer<typeof loadSessionResponseSchema>;
|
||||
@@ -247,7 +246,13 @@ export const authenticateRequestSchema = z.object({
|
||||
methodId: z.string(),
|
||||
});
|
||||
|
||||
export const authenticateResponseSchema = z.null();
|
||||
export const authenticateUpdateSchema = z.object({
|
||||
_meta: z.object({
|
||||
authUri: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
|
||||
|
||||
export const newSessionResponseSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
@@ -316,6 +321,23 @@ export const annotationsSchema = z.object({
|
||||
priority: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export const usageSchema = z.object({
|
||||
promptTokens: z.number().optional().nullable(),
|
||||
completionTokens: z.number().optional().nullable(),
|
||||
thoughtsTokens: z.number().optional().nullable(),
|
||||
totalTokens: z.number().optional().nullable(),
|
||||
cachedTokens: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type Usage = z.infer<typeof usageSchema>;
|
||||
|
||||
export const sessionUpdateMetaSchema = z.object({
|
||||
usage: usageSchema.optional().nullable(),
|
||||
durationMs: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
|
||||
|
||||
export const requestPermissionResponseSchema = z.object({
|
||||
outcome: requestPermissionOutcomeSchema,
|
||||
});
|
||||
@@ -500,10 +522,12 @@ export const sessionUpdateSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_message_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_thought_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: z.array(toolCallContentSchema).optional(),
|
||||
@@ -536,7 +560,6 @@ export const sessionUpdateSchema = z.union([
|
||||
|
||||
export const agentResponseSchema = z.union([
|
||||
initializeResponseSchema,
|
||||
authenticateResponseSchema,
|
||||
newSessionResponseSchema,
|
||||
loadSessionResponseSchema,
|
||||
promptResponseSchema,
|
||||
|
||||
59
packages/cli/src/acp-integration/service/filesystem.test.ts
Normal file
59
packages/cli/src/acp-integration/service/filesystem.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import { AcpFileSystemService } from './filesystem.js';
|
||||
|
||||
const createFallback = (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
findFiles: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
describe('AcpFileSystemService', () => {
|
||||
describe('readTextFile ENOENT handling', () => {
|
||||
it('parses path from ACP ENOENT message (quoted)', async () => {
|
||||
const client = {
|
||||
readTextFile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-1',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/remote/file.txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to requested path when none provided', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-2',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
svc.readTextFile('/fallback/path.txt'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/fallback/path.txt',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,20 @@ export class AcpFileSystemService implements FileSystemService {
|
||||
limit: null,
|
||||
});
|
||||
|
||||
if (response.content.startsWith('ERROR: ENOENT:')) {
|
||||
// Treat ACP error strings as structured ENOENT errors without
|
||||
// assuming a specific platform format.
|
||||
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
|
||||
const err = new Error(response.content) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
err.errno = -2;
|
||||
const rawPath = match?.groups?.['path']?.trim();
|
||||
err['path'] = rawPath
|
||||
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
|
||||
: filePath;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return response.content;
|
||||
}
|
||||
|
||||
|
||||
@@ -411,4 +411,48 @@ describe('HistoryReplayer', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usage metadata replay', () => {
|
||||
it('should emit usage metadata after assistant message content', async () => {
|
||||
const record: ChatRecord = {
|
||||
uuid: 'assistant-uuid',
|
||||
parentUuid: 'user-uuid',
|
||||
sessionId: 'test-session',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'assistant',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hello!' }],
|
||||
},
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 150,
|
||||
},
|
||||
};
|
||||
|
||||
await replayer.replay([record]);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Hello!' },
|
||||
});
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: undefined,
|
||||
totalTokens: 150,
|
||||
cachedTokens: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { Content } from '@google/genai';
|
||||
import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
Content,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
} from '@google/genai';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
@@ -52,6 +55,9 @@ export class HistoryReplayer {
|
||||
if (record.message) {
|
||||
await this.replayContent(record.message, 'assistant');
|
||||
}
|
||||
if (record.usageMetadata) {
|
||||
await this.replayUsageMetadata(record.usageMetadata);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
@@ -88,11 +94,22 @@ export class HistoryReplayer {
|
||||
toolName: functionName,
|
||||
callId,
|
||||
args: part.functionCall.args as Record<string, unknown>,
|
||||
status: 'in_progress',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays usage metadata.
|
||||
* @param usageMetadata - The usage metadata to replay
|
||||
*/
|
||||
private async replayUsageMetadata(
|
||||
usageMetadata: GenerateContentResponseUsageMetadata,
|
||||
): Promise<void> {
|
||||
await this.messageEmitter.emitUsageMetadata(usageMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a tool result record.
|
||||
*/
|
||||
@@ -118,6 +135,54 @@ export class HistoryReplayer {
|
||||
// Note: args aren't stored in tool_result records by default
|
||||
args: undefined,
|
||||
});
|
||||
|
||||
// Special handling: Task tool execution summary contains token usage
|
||||
const { resultDisplay } = result ?? {};
|
||||
if (
|
||||
!!resultDisplay &&
|
||||
typeof resultDisplay === 'object' &&
|
||||
'type' in resultDisplay &&
|
||||
(resultDisplay as { type?: unknown }).type === 'task_execution'
|
||||
) {
|
||||
await this.emitTaskUsageFromResultDisplay(
|
||||
resultDisplay as TaskResultDisplay,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits token usage from a TaskResultDisplay execution summary, if present.
|
||||
*/
|
||||
private async emitTaskUsageFromResultDisplay(
|
||||
resultDisplay: TaskResultDisplay,
|
||||
): Promise<void> {
|
||||
const summary = resultDisplay.executionSummary;
|
||||
if (!summary) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usageMetadata: GenerateContentResponseUsageMetadata = {};
|
||||
|
||||
if (Number.isFinite(summary.inputTokens)) {
|
||||
usageMetadata.promptTokenCount = summary.inputTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.outputTokens)) {
|
||||
usageMetadata.candidatesTokenCount = summary.outputTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.thoughtTokens)) {
|
||||
usageMetadata.thoughtsTokenCount = summary.thoughtTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.cachedTokens)) {
|
||||
usageMetadata.cachedContentTokenCount = summary.cachedTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.totalTokens)) {
|
||||
usageMetadata.totalTokenCount = summary.totalTokens;
|
||||
}
|
||||
|
||||
// Only emit if we captured at least one token metric
|
||||
if (Object.keys(usageMetadata).length > 0) {
|
||||
await this.messageEmitter.emitUsageMetadata(usageMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content, FunctionCall, Part } from '@google/genai';
|
||||
import type {
|
||||
Content,
|
||||
FunctionCall,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
Part,
|
||||
} from '@google/genai';
|
||||
import type {
|
||||
Config,
|
||||
GeminiChat,
|
||||
@@ -55,6 +60,7 @@ import type { SessionContext, ToolCallStartParams } from './types.js';
|
||||
import { HistoryReplayer } from './HistoryReplayer.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { PlanEmitter } from './emitters/PlanEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { SubAgentTracker } from './SubAgentTracker.js';
|
||||
|
||||
/**
|
||||
@@ -79,6 +85,7 @@ export class Session implements SessionContext {
|
||||
private readonly historyReplayer: HistoryReplayer;
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly planEmitter: PlanEmitter;
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
|
||||
// Implement SessionContext interface
|
||||
readonly sessionId: string;
|
||||
@@ -96,6 +103,7 @@ export class Session implements SessionContext {
|
||||
this.toolCallEmitter = new ToolCallEmitter(this);
|
||||
this.planEmitter = new PlanEmitter(this);
|
||||
this.historyReplayer = new HistoryReplayer(this);
|
||||
this.messageEmitter = new MessageEmitter(this);
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
@@ -192,6 +200,8 @@ export class Session implements SessionContext {
|
||||
}
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
|
||||
const streamStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
@@ -222,20 +232,18 @@ export class Session implements SessionContext {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content: acp.ContentBlock = {
|
||||
type: 'text',
|
||||
text: part.text,
|
||||
};
|
||||
|
||||
this.sendUpdate({
|
||||
sessionUpdate: part.thought
|
||||
? 'agent_thought_chunk'
|
||||
: 'agent_message_chunk',
|
||||
content,
|
||||
});
|
||||
this.messageEmitter.emitMessage(
|
||||
part.text,
|
||||
'assistant',
|
||||
part.thought,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
|
||||
usageMetadata = resp.value.usageMetadata;
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
|
||||
functionCalls.push(...resp.value.functionCalls);
|
||||
}
|
||||
@@ -251,6 +259,15 @@ export class Session implements SessionContext {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (usageMetadata) {
|
||||
const durationMs = Date.now() - streamStartTime;
|
||||
await this.messageEmitter.emitUsageMetadata(
|
||||
usageMetadata,
|
||||
'',
|
||||
durationMs,
|
||||
);
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
@@ -444,7 +461,9 @@ export class Session implements SessionContext {
|
||||
}
|
||||
|
||||
const confirmationDetails =
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
this.config.getApprovalMode() !== ApprovalMode.YOLO
|
||||
? await invocation.shouldConfirmExecute(abortSignal)
|
||||
: false;
|
||||
|
||||
if (confirmationDetails) {
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
@@ -522,6 +541,7 @@ export class Session implements SessionContext {
|
||||
callId,
|
||||
toolName: fc.name,
|
||||
args,
|
||||
status: 'in_progress',
|
||||
};
|
||||
await this.toolCallEmitter.emitStart(startParams);
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('SubAgentTracker', () => {
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'read_file',
|
||||
content: [],
|
||||
locations: [],
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentApprovalRequestEvent,
|
||||
SubAgentUsageEvent,
|
||||
ToolCallConfirmationDetails,
|
||||
AnyDeclarativeTool,
|
||||
AnyToolInvocation,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { z } from 'zod';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import type * as acp from '../acp.js';
|
||||
|
||||
/**
|
||||
@@ -62,6 +64,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [
|
||||
*/
|
||||
export class SubAgentTracker {
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
private readonly toolStates = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -76,6 +79,7 @@ export class SubAgentTracker {
|
||||
private readonly client: acp.Client,
|
||||
) {
|
||||
this.toolCallEmitter = new ToolCallEmitter(ctx);
|
||||
this.messageEmitter = new MessageEmitter(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,16 +96,19 @@ export class SubAgentTracker {
|
||||
const onToolCall = this.createToolCallHandler(abortSignal);
|
||||
const onToolResult = this.createToolResultHandler(abortSignal);
|
||||
const onApproval = this.createApprovalHandler(abortSignal);
|
||||
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
|
||||
|
||||
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||
|
||||
return [
|
||||
() => {
|
||||
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||
// Clean up any remaining states
|
||||
this.toolStates.clear();
|
||||
},
|
||||
@@ -252,6 +259,20 @@ export class SubAgentTracker {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a handler for usage metadata events.
|
||||
*/
|
||||
private createUsageMetadataHandler(
|
||||
abortSignal: AbortSignal,
|
||||
): (...args: unknown[]) => void {
|
||||
return (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentUsageEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts confirmation details to permission options for the client.
|
||||
*/
|
||||
|
||||
@@ -148,4 +148,59 @@ describe('MessageEmitter', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitUsageMetadata', () => {
|
||||
it('should emit agent_message_chunk with _meta.usage containing token counts', async () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
thoughtsTokenCount: 25,
|
||||
totalTokenCount: 175,
|
||||
cachedContentTokenCount: 10,
|
||||
};
|
||||
|
||||
await emitter.emitUsageMetadata(usageMetadata);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: 25,
|
||||
totalTokens: 175,
|
||||
cachedTokens: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include durationMs in _meta when provided', async () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
thoughtsTokenCount: 2,
|
||||
totalTokenCount: 17,
|
||||
cachedContentTokenCount: 1,
|
||||
};
|
||||
|
||||
await emitter.emitUsageMetadata(usageMetadata, 'done', 1234);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'done' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
thoughtsTokens: 2,
|
||||
totalTokens: 17,
|
||||
cachedTokens: 1,
|
||||
},
|
||||
durationMs: 1234,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
import type { Usage } from '../../schema.js';
|
||||
import { BaseEmitter } from './BaseEmitter.js';
|
||||
|
||||
/**
|
||||
@@ -24,6 +26,16 @@ export class MessageEmitter extends BaseEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent thought chunk.
|
||||
*/
|
||||
async emitAgentThought(text: string): Promise<void> {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent message chunk.
|
||||
*/
|
||||
@@ -35,12 +47,28 @@ export class MessageEmitter extends BaseEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent thought chunk.
|
||||
* Emits usage metadata.
|
||||
*/
|
||||
async emitAgentThought(text: string): Promise<void> {
|
||||
async emitUsageMetadata(
|
||||
usageMetadata: GenerateContentResponseUsageMetadata,
|
||||
text: string = '',
|
||||
durationMs?: number,
|
||||
): Promise<void> {
|
||||
const usage: Usage = {
|
||||
promptTokens: usageMetadata.promptTokenCount,
|
||||
completionTokens: usageMetadata.candidatesTokenCount,
|
||||
thoughtsTokens: usageMetadata.thoughtsTokenCount,
|
||||
totalTokens: usageMetadata.totalTokenCount,
|
||||
cachedTokens: usageMetadata.cachedContentTokenCount,
|
||||
};
|
||||
|
||||
const meta =
|
||||
typeof durationMs === 'number' ? { usage, durationMs } : { usage };
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text },
|
||||
_meta: meta,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'unknown_tool', // Falls back to tool name
|
||||
content: [],
|
||||
locations: [],
|
||||
@@ -94,7 +94,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-456',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'edit_file: Test tool description',
|
||||
content: [],
|
||||
locations: [{ path: '/test/file.ts', line: 10 }],
|
||||
@@ -144,7 +144,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-fail',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'failing_tool', // Fallback to tool name
|
||||
content: [],
|
||||
locations: [], // Fallback to empty
|
||||
@@ -493,7 +493,7 @@ describe('ToolCallEmitter', () => {
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: '{"output":"test output"}',
|
||||
text: 'test output',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -650,7 +650,7 @@ describe('ToolCallEmitter', () => {
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: '{"output":"Function output"}' },
|
||||
content: { type: 'text', text: 'Function output' },
|
||||
},
|
||||
],
|
||||
rawOutput: 'raw result',
|
||||
|
||||
@@ -59,7 +59,7 @@ export class ToolCallEmitter extends BaseEmitter {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: params.callId,
|
||||
status: 'in_progress',
|
||||
status: params.status || 'pending',
|
||||
title,
|
||||
content: [],
|
||||
locations,
|
||||
@@ -275,7 +275,18 @@ export class ToolCallEmitter extends BaseEmitter {
|
||||
// Handle functionResponse parts - stringify the response
|
||||
if ('functionResponse' in part && part.functionResponse) {
|
||||
try {
|
||||
const responseText = JSON.stringify(part.functionResponse.response);
|
||||
const resp = part.functionResponse.response as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const outputField = resp['output'];
|
||||
const errorField = resp['error'];
|
||||
const responseText =
|
||||
typeof outputField === 'string'
|
||||
? outputField
|
||||
: typeof errorField === 'string'
|
||||
? errorField
|
||||
: JSON.stringify(resp);
|
||||
result.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: responseText },
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface ToolCallStartParams {
|
||||
callId: string;
|
||||
/** Arguments passed to the tool */
|
||||
args?: Record<string, unknown>;
|
||||
/** Status of the tool call */
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,6 +138,7 @@ export interface CliArgs {
|
||||
coreTools: string[] | undefined;
|
||||
excludeTools: string[] | undefined;
|
||||
authType: string | undefined;
|
||||
channel: string | undefined;
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(
|
||||
@@ -297,6 +298,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
|
||||
description: 'Channel identifier (VSCode, ACP, SDK, CI)',
|
||||
})
|
||||
.option('allowed-mcp-server-names', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
@@ -559,6 +565,12 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
|
||||
// Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
|
||||
if (result['experimentalAcp'] && !result['channel']) {
|
||||
(result as Record<string, unknown>)['channel'] = 'ACP';
|
||||
}
|
||||
|
||||
return result as unknown as CliArgs;
|
||||
}
|
||||
|
||||
@@ -983,6 +995,7 @@ export async function loadCliConfig(
|
||||
output: {
|
||||
format: outputSettingsFormat,
|
||||
},
|
||||
channel: argv.channel,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -191,8 +191,19 @@ const SETTINGS_SCHEMA = {
|
||||
{ value: 'auto', label: 'Auto (detect from system)' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'zh', label: '中文 (Chinese)' },
|
||||
{ value: 'ru', label: 'Русский (Russian)' },
|
||||
],
|
||||
},
|
||||
terminalBell: {
|
||||
type: 'boolean',
|
||||
label: 'Terminal Bell',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Play terminal bell sound when response completes or needs approval.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
|
||||
@@ -485,6 +485,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
excludeTools: undefined,
|
||||
authType: undefined,
|
||||
maxSessionTurns: undefined,
|
||||
channel: undefined,
|
||||
});
|
||||
|
||||
await main();
|
||||
|
||||
@@ -276,8 +276,11 @@ export async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
// For stream-json mode, don't read stdin here - it should be forwarded to the sandbox
|
||||
// and consumed by StreamJsonInputReader inside the container
|
||||
const inputFormat = argv.inputFormat as string | undefined;
|
||||
let stdinData = '';
|
||||
if (!process.stdin.isTTY) {
|
||||
if (!process.stdin.isTTY && inputFormat !== 'stream-json') {
|
||||
stdinData = await readStdin();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
export type SupportedLanguage = 'en' | 'zh' | string; // Allow custom language codes
|
||||
export type SupportedLanguage = 'en' | 'zh' | 'ru' | string; // Allow custom language codes
|
||||
|
||||
// State
|
||||
let currentLanguage: SupportedLanguage = 'en';
|
||||
@@ -51,10 +51,12 @@ export function detectSystemLanguage(): SupportedLanguage {
|
||||
const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG'];
|
||||
if (envLang?.startsWith('zh')) return 'zh';
|
||||
if (envLang?.startsWith('en')) return 'en';
|
||||
if (envLang?.startsWith('ru')) return 'ru';
|
||||
|
||||
try {
|
||||
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
|
||||
if (locale.startsWith('zh')) return 'zh';
|
||||
if (locale.startsWith('ru')) return 'ru';
|
||||
} catch {
|
||||
// Fallback to default
|
||||
}
|
||||
|
||||
@@ -867,6 +867,7 @@ export default {
|
||||
// Exit Screen / Stats
|
||||
// ============================================================================
|
||||
'Agent powering down. Goodbye!': 'Agent powering down. Goodbye!',
|
||||
'To continue this session, run': 'To continue this session, run',
|
||||
'Interaction Summary': 'Interaction Summary',
|
||||
'Session ID:': 'Session ID:',
|
||||
'Tool Calls:': 'Tool Calls:',
|
||||
|
||||
1121
packages/cli/src/i18n/locales/ru.js
Normal file
1121
packages/cli/src/i18n/locales/ru.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -820,6 +820,7 @@ export default {
|
||||
// Exit Screen / Stats
|
||||
// ============================================================================
|
||||
'Agent powering down. Goodbye!': 'Qwen Code 正在关闭,再见!',
|
||||
'To continue this session, run': '要继续此会话,请运行',
|
||||
'Interaction Summary': '交互摘要',
|
||||
'Session ID:': '会话 ID:',
|
||||
'Tool Calls:': '工具调用:',
|
||||
|
||||
@@ -16,9 +16,12 @@
|
||||
* Controllers:
|
||||
* - SystemController: initialize, interrupt, set_model, supported_commands
|
||||
* - PermissionController: can_use_tool, set_permission_mode
|
||||
* - MCPController: mcp_message, mcp_server_status
|
||||
* - SdkMcpController: mcp_server_status (mcp_message handled via callback)
|
||||
* - HookController: hook_callback
|
||||
*
|
||||
* Note: mcp_message requests are NOT routed through the dispatcher. CLI MCP
|
||||
* clients send messages via SdkMcpController.createSendSdkMcpMessage() callback.
|
||||
*
|
||||
* Note: Control request types are centrally defined in the ControlRequestType
|
||||
* enum in packages/sdk/typescript/src/types/controlRequests.ts
|
||||
*/
|
||||
@@ -27,7 +30,7 @@ import type { IControlContext } from './ControlContext.js';
|
||||
import type { IPendingRequestRegistry } from './controllers/baseController.js';
|
||||
import { SystemController } from './controllers/systemController.js';
|
||||
import { PermissionController } from './controllers/permissionController.js';
|
||||
// import { MCPController } from './controllers/mcpController.js';
|
||||
import { SdkMcpController } from './controllers/sdkMcpController.js';
|
||||
// import { HookController } from './controllers/hookController.js';
|
||||
import type {
|
||||
CLIControlRequest,
|
||||
@@ -65,7 +68,7 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
// Make controllers publicly accessible
|
||||
readonly systemController: SystemController;
|
||||
readonly permissionController: PermissionController;
|
||||
// readonly mcpController: MCPController;
|
||||
readonly sdkMcpController: SdkMcpController;
|
||||
// readonly hookController: HookController;
|
||||
|
||||
// Central pending request registries
|
||||
@@ -88,7 +91,11 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
this,
|
||||
'PermissionController',
|
||||
);
|
||||
// this.mcpController = new MCPController(context, this, 'MCPController');
|
||||
this.sdkMcpController = new SdkMcpController(
|
||||
context,
|
||||
this,
|
||||
'SdkMcpController',
|
||||
);
|
||||
// this.hookController = new HookController(context, this, 'HookController');
|
||||
|
||||
// Listen for main abort signal
|
||||
@@ -228,10 +235,10 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
}
|
||||
this.pendingOutgoingRequests.clear();
|
||||
|
||||
// Cleanup controllers (MCP controller will close all clients)
|
||||
// Cleanup controllers
|
||||
this.systemController.cleanup();
|
||||
this.permissionController.cleanup();
|
||||
// this.mcpController.cleanup();
|
||||
this.sdkMcpController.cleanup();
|
||||
// this.hookController.cleanup();
|
||||
}
|
||||
|
||||
@@ -291,6 +298,47 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending incoming requests (for debugging)
|
||||
*/
|
||||
getPendingIncomingRequestCount(): number {
|
||||
return this.pendingIncomingRequests.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all incoming request handlers to complete.
|
||||
*
|
||||
* Uses polling since we don't have direct Promise references to handlers.
|
||||
* The pendingIncomingRequests map is managed by BaseController:
|
||||
* - Registered when handler starts (in handleRequest)
|
||||
* - Deregistered when handler completes (success or error)
|
||||
*
|
||||
* @param pollIntervalMs - How often to check (default 50ms)
|
||||
* @param timeoutMs - Maximum wait time (default 30s)
|
||||
*/
|
||||
async waitForPendingIncomingRequests(
|
||||
pollIntervalMs: number = 50,
|
||||
timeoutMs: number = 30000,
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (this.pendingIncomingRequests.size > 0) {
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[ControlDispatcher] Timeout waiting for ${this.pendingIncomingRequests.size} pending incoming requests`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
if (this.context.debugMode && this.pendingIncomingRequests.size === 0) {
|
||||
console.error('[ControlDispatcher] All incoming requests completed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the controller that handles the given request subtype
|
||||
*/
|
||||
@@ -306,9 +354,8 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
case 'set_permission_mode':
|
||||
return this.permissionController;
|
||||
|
||||
// case 'mcp_message':
|
||||
// case 'mcp_server_status':
|
||||
// return this.mcpController;
|
||||
case 'mcp_server_status':
|
||||
return this.sdkMcpController;
|
||||
|
||||
// case 'hook_callback':
|
||||
// return this.hookController;
|
||||
|
||||
@@ -117,16 +117,41 @@ export abstract class BaseController {
|
||||
* Send an outgoing control request to SDK
|
||||
*
|
||||
* Manages lifecycle: register -> send -> wait for response -> deregister
|
||||
* Respects the provided AbortSignal for cancellation.
|
||||
*/
|
||||
async sendControlRequest(
|
||||
payload: ControlRequestPayload,
|
||||
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ControlResponse> {
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
|
||||
return new Promise<ControlResponse>((resolve, reject) => {
|
||||
// Setup abort handler
|
||||
const abortHandler = () => {
|
||||
this.registry.deregisterOutgoingRequest(requestId);
|
||||
reject(new Error('Request aborted'));
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[${this.controllerName}] Outgoing request aborted: ${requestId}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', abortHandler, { once: true });
|
||||
}
|
||||
|
||||
// Setup timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
this.registry.deregisterOutgoingRequest(requestId);
|
||||
reject(new Error('Control request timeout'));
|
||||
if (this.context.debugMode) {
|
||||
@@ -136,12 +161,27 @@ export abstract class BaseController {
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
// Wrap resolve/reject to clean up abort listener
|
||||
const wrappedResolve = (response: ControlResponse) => {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
resolve(response);
|
||||
};
|
||||
|
||||
const wrappedReject = (error: Error) => {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
|
||||
// Register with central registry
|
||||
this.registry.registerOutgoingRequest(
|
||||
requestId,
|
||||
this.controllerName,
|
||||
resolve,
|
||||
reject,
|
||||
wrappedResolve,
|
||||
wrappedReject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
@@ -155,6 +195,9 @@ export abstract class BaseController {
|
||||
try {
|
||||
this.context.streamJson.send(request);
|
||||
} catch (error) {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
this.registry.deregisterOutgoingRequest(requestId);
|
||||
reject(error);
|
||||
}
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* MCP Controller
|
||||
*
|
||||
* Handles MCP-related control requests:
|
||||
* - mcp_message: Route MCP messages
|
||||
* - mcp_server_status: Return MCP server status
|
||||
*/
|
||||
|
||||
import { BaseController } from './baseController.js';
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type {
|
||||
ControlRequestPayload,
|
||||
CLIControlMcpMessageRequest,
|
||||
} from '../../types.js';
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
WorkspaceContext,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
connectToMcpServer,
|
||||
MCP_DEFAULT_TIMEOUT_MSEC,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export class MCPController extends BaseController {
|
||||
/**
|
||||
* Handle MCP control requests
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (payload.subtype) {
|
||||
case 'mcp_message':
|
||||
return this.handleMcpMessage(payload as CLIControlMcpMessageRequest);
|
||||
|
||||
case 'mcp_server_status':
|
||||
return this.handleMcpStatus();
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in MCPController`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mcp_message request
|
||||
*
|
||||
* Routes JSON-RPC messages to MCP servers
|
||||
*/
|
||||
private async handleMcpMessage(
|
||||
payload: CLIControlMcpMessageRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const serverNameRaw = payload.server_name;
|
||||
if (
|
||||
typeof serverNameRaw !== 'string' ||
|
||||
serverNameRaw.trim().length === 0
|
||||
) {
|
||||
throw new Error('Missing server_name in mcp_message request');
|
||||
}
|
||||
|
||||
const message = payload.message;
|
||||
if (!message || typeof message !== 'object') {
|
||||
throw new Error(
|
||||
'Missing or invalid message payload for mcp_message request',
|
||||
);
|
||||
}
|
||||
|
||||
// Get or create MCP client
|
||||
let clientEntry: { client: Client; config: MCPServerConfig };
|
||||
try {
|
||||
clientEntry = await this.getOrCreateMcpClient(serverNameRaw.trim());
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to connect to MCP server',
|
||||
);
|
||||
}
|
||||
|
||||
const method = message.method;
|
||||
if (typeof method !== 'string' || method.trim().length === 0) {
|
||||
throw new Error('Invalid MCP message: missing method');
|
||||
}
|
||||
|
||||
const jsonrpcVersion =
|
||||
typeof message.jsonrpc === 'string' ? message.jsonrpc : '2.0';
|
||||
const messageId = message.id;
|
||||
const params = message.params;
|
||||
const timeout =
|
||||
typeof clientEntry.config.timeout === 'number'
|
||||
? clientEntry.config.timeout
|
||||
: MCP_DEFAULT_TIMEOUT_MSEC;
|
||||
|
||||
try {
|
||||
// Handle notification (no id)
|
||||
if (messageId === undefined) {
|
||||
await clientEntry.client.notification({
|
||||
method,
|
||||
params,
|
||||
});
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: null,
|
||||
result: { success: true, acknowledged: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Handle request (with id)
|
||||
const result = await clientEntry.client.request(
|
||||
{
|
||||
method,
|
||||
params,
|
||||
},
|
||||
ResultSchema,
|
||||
{ timeout },
|
||||
);
|
||||
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: messageId,
|
||||
result,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// If connection closed, remove from cache
|
||||
if (error instanceof Error && /closed/i.test(error.message)) {
|
||||
this.context.mcpClients.delete(serverNameRaw.trim());
|
||||
}
|
||||
|
||||
const errorCode =
|
||||
typeof (error as { code?: unknown })?.code === 'number'
|
||||
? ((error as { code: number }).code as number)
|
||||
: -32603;
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to execute MCP request';
|
||||
const errorData = (error as { data?: unknown })?.data;
|
||||
|
||||
const errorBody: Record<string, unknown> = {
|
||||
code: errorCode,
|
||||
message: errorMessage,
|
||||
};
|
||||
if (errorData !== undefined) {
|
||||
errorBody['data'] = errorData;
|
||||
}
|
||||
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: messageId ?? null,
|
||||
error: errorBody,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mcp_server_status request
|
||||
*
|
||||
* Returns status of registered MCP servers
|
||||
*/
|
||||
private async handleMcpStatus(): Promise<Record<string, unknown>> {
|
||||
const status: Record<string, string> = {};
|
||||
|
||||
// Include SDK MCP servers
|
||||
for (const serverName of this.context.sdkMcpServers) {
|
||||
status[serverName] = 'connected';
|
||||
}
|
||||
|
||||
// Include CLI-managed MCP clients
|
||||
for (const serverName of this.context.mcpClients.keys()) {
|
||||
status[serverName] = 'connected';
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] MCP status: ${Object.keys(status).length} servers`,
|
||||
);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create MCP client for a server
|
||||
*
|
||||
* Implements lazy connection and caching
|
||||
*/
|
||||
private async getOrCreateMcpClient(
|
||||
serverName: string,
|
||||
): Promise<{ client: Client; config: MCPServerConfig }> {
|
||||
// Check cache first
|
||||
const cached = this.context.mcpClients.get(serverName);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Get server configuration
|
||||
const provider = this.context.config as unknown as {
|
||||
getMcpServers?: () => Record<string, MCPServerConfig> | undefined;
|
||||
getDebugMode?: () => boolean;
|
||||
getWorkspaceContext?: () => unknown;
|
||||
};
|
||||
|
||||
if (typeof provider.getMcpServers !== 'function') {
|
||||
throw new Error(`MCP server "${serverName}" is not configured`);
|
||||
}
|
||||
|
||||
const servers = provider.getMcpServers() ?? {};
|
||||
const serverConfig = servers[serverName];
|
||||
if (!serverConfig) {
|
||||
throw new Error(`MCP server "${serverName}" is not configured`);
|
||||
}
|
||||
|
||||
const debugMode =
|
||||
typeof provider.getDebugMode === 'function'
|
||||
? provider.getDebugMode()
|
||||
: false;
|
||||
|
||||
const workspaceContext =
|
||||
typeof provider.getWorkspaceContext === 'function'
|
||||
? provider.getWorkspaceContext()
|
||||
: undefined;
|
||||
|
||||
if (!workspaceContext) {
|
||||
throw new Error('Workspace context is not available for MCP connection');
|
||||
}
|
||||
|
||||
// Connect to MCP server
|
||||
const client = await connectToMcpServer(
|
||||
serverName,
|
||||
serverConfig,
|
||||
debugMode,
|
||||
workspaceContext as WorkspaceContext,
|
||||
);
|
||||
|
||||
// Cache the client
|
||||
const entry = { client, config: serverConfig };
|
||||
this.context.mcpClients.set(serverName, entry);
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(`[MCPController] Connected to MCP server: ${serverName}`);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup MCP clients
|
||||
*/
|
||||
override cleanup(): void {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] Cleaning up ${this.context.mcpClients.size} MCP clients`,
|
||||
);
|
||||
}
|
||||
|
||||
// Close all MCP clients
|
||||
for (const [serverName, { client }] of this.context.mcpClients.entries()) {
|
||||
try {
|
||||
client.close();
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] Failed to close MCP client ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.context.mcpClients.clear();
|
||||
}
|
||||
}
|
||||
@@ -44,15 +44,23 @@ export class PermissionController extends BaseController {
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
switch (payload.subtype) {
|
||||
case 'can_use_tool':
|
||||
return this.handleCanUseTool(payload as CLIControlPermissionRequest);
|
||||
return this.handleCanUseTool(
|
||||
payload as CLIControlPermissionRequest,
|
||||
signal,
|
||||
);
|
||||
|
||||
case 'set_permission_mode':
|
||||
return this.handleSetPermissionMode(
|
||||
payload as CLIControlSetPermissionModeRequest,
|
||||
signal,
|
||||
);
|
||||
|
||||
default:
|
||||
@@ -70,7 +78,12 @@ export class PermissionController extends BaseController {
|
||||
*/
|
||||
private async handleCanUseTool(
|
||||
payload: CLIControlPermissionRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const toolName = payload.tool_name;
|
||||
if (
|
||||
!toolName ||
|
||||
@@ -192,7 +205,12 @@ export class PermissionController extends BaseController {
|
||||
*/
|
||||
private async handleSetPermissionMode(
|
||||
payload: CLIControlSetPermissionModeRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const mode = payload.mode;
|
||||
const validModes: PermissionMode[] = [
|
||||
'default',
|
||||
@@ -373,6 +391,14 @@ export class PermissionController extends BaseController {
|
||||
toolCall: WaitingToolCall,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if already aborted
|
||||
if (this.context.abortSignal?.aborted) {
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const inputFormat = this.context.config.getInputFormat?.();
|
||||
const isStreamJsonMode = inputFormat === InputFormat.STREAM_JSON;
|
||||
|
||||
@@ -392,14 +418,18 @@ export class PermissionController extends BaseController {
|
||||
toolCall.confirmationDetails,
|
||||
);
|
||||
|
||||
const response = await this.sendControlRequest({
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: toolCall.request.name,
|
||||
tool_use_id: toolCall.request.callId,
|
||||
input: toolCall.request.args,
|
||||
permission_suggestions: permissionSuggestions,
|
||||
blocked_path: null,
|
||||
} as CLIControlPermissionRequest);
|
||||
const response = await this.sendControlRequest(
|
||||
{
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: toolCall.request.name,
|
||||
tool_use_id: toolCall.request.callId,
|
||||
input: toolCall.request.args,
|
||||
permission_suggestions: permissionSuggestions,
|
||||
blocked_path: null,
|
||||
} as CLIControlPermissionRequest,
|
||||
undefined, // use default timeout
|
||||
this.context.abortSignal,
|
||||
);
|
||||
|
||||
if (response.subtype !== 'success') {
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* SDK MCP Controller
|
||||
*
|
||||
* Handles MCP communication between CLI MCP clients and SDK MCP servers:
|
||||
* - Provides sendSdkMcpMessage callback for CLI → SDK MCP message routing
|
||||
* - mcp_server_status: Returns status of SDK MCP servers
|
||||
*
|
||||
* Message Flow (CLI MCP Client → SDK MCP Server):
|
||||
* CLI MCP Client → SdkControlClientTransport.send() →
|
||||
* sendSdkMcpMessage callback → control_request (mcp_message) → SDK →
|
||||
* SDK MCP Server processes → control_response → CLI MCP Client
|
||||
*/
|
||||
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { BaseController } from './baseController.js';
|
||||
import type {
|
||||
ControlRequestPayload,
|
||||
CLIControlMcpMessageRequest,
|
||||
} from '../../types.js';
|
||||
|
||||
const MCP_REQUEST_TIMEOUT = 30_000; // 30 seconds
|
||||
|
||||
export class SdkMcpController extends BaseController {
|
||||
/**
|
||||
* Handle SDK MCP control requests from ControlDispatcher
|
||||
*
|
||||
* Note: mcp_message requests are NOT handled here. CLI MCP clients
|
||||
* send messages via the sendSdkMcpMessage callback directly, not
|
||||
* through the control dispatcher.
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
switch (payload.subtype) {
|
||||
case 'mcp_server_status':
|
||||
return this.handleMcpStatus();
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in SdkMcpController`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mcp_server_status request
|
||||
*
|
||||
* Returns status of all registered SDK MCP servers.
|
||||
* SDK servers are considered "connected" if they are registered.
|
||||
*/
|
||||
private async handleMcpStatus(): Promise<Record<string, unknown>> {
|
||||
const status: Record<string, string> = {};
|
||||
|
||||
for (const serverName of this.context.sdkMcpServers) {
|
||||
// SDK MCP servers are "connected" once registered since they run in SDK process
|
||||
status[serverName] = 'connected';
|
||||
}
|
||||
|
||||
return {
|
||||
subtype: 'mcp_server_status',
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send MCP message to SDK server via control plane
|
||||
*
|
||||
* @param serverName - Name of the SDK MCP server
|
||||
* @param message - MCP JSON-RPC message to send
|
||||
* @returns MCP JSON-RPC response from SDK server
|
||||
*/
|
||||
private async sendMcpMessageToSdk(
|
||||
serverName: string,
|
||||
message: JSONRPCMessage,
|
||||
): Promise<JSONRPCMessage> {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SdkMcpController] Sending MCP message to SDK server '${serverName}':`,
|
||||
JSON.stringify(message),
|
||||
);
|
||||
}
|
||||
|
||||
// Send control request to SDK with the MCP message
|
||||
const response = await this.sendControlRequest(
|
||||
{
|
||||
subtype: 'mcp_message',
|
||||
server_name: serverName,
|
||||
message: message as CLIControlMcpMessageRequest['message'],
|
||||
},
|
||||
MCP_REQUEST_TIMEOUT,
|
||||
this.context.abortSignal,
|
||||
);
|
||||
|
||||
// Extract MCP response from control response
|
||||
const responsePayload = response.response as Record<string, unknown>;
|
||||
const mcpResponse = responsePayload?.['mcp_response'] as JSONRPCMessage;
|
||||
|
||||
if (!mcpResponse) {
|
||||
throw new Error(
|
||||
`Invalid MCP response from SDK for server '${serverName}'`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SdkMcpController] Received MCP response from SDK server '${serverName}':`,
|
||||
JSON.stringify(mcpResponse),
|
||||
);
|
||||
}
|
||||
|
||||
return mcpResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a callback function for sending MCP messages to SDK servers.
|
||||
*
|
||||
* This callback is used by McpClientManager/SdkControlClientTransport to send
|
||||
* MCP messages from CLI MCP clients to SDK MCP servers via the control plane.
|
||||
*
|
||||
* @returns A function that sends MCP messages to SDK and returns the response
|
||||
*/
|
||||
createSendSdkMcpMessage(): (
|
||||
serverName: string,
|
||||
message: JSONRPCMessage,
|
||||
) => Promise<JSONRPCMessage> {
|
||||
return (serverName: string, message: JSONRPCMessage) =>
|
||||
this.sendMcpMessageToSdk(serverName, message);
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,15 @@ import type {
|
||||
ControlRequestPayload,
|
||||
CLIControlInitializeRequest,
|
||||
CLIControlSetModelRequest,
|
||||
CLIMcpServerConfig,
|
||||
} from '../../types.js';
|
||||
import { CommandService } from '../../../services/CommandService.js';
|
||||
import { BuiltinCommandLoader } from '../../../services/BuiltinCommandLoader.js';
|
||||
import {
|
||||
MCPServerConfig,
|
||||
AuthProviderType,
|
||||
type MCPOAuthConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export class SystemController extends BaseController {
|
||||
/**
|
||||
@@ -28,20 +34,30 @@ export class SystemController extends BaseController {
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
switch (payload.subtype) {
|
||||
case 'initialize':
|
||||
return this.handleInitialize(payload as CLIControlInitializeRequest);
|
||||
return this.handleInitialize(
|
||||
payload as CLIControlInitializeRequest,
|
||||
signal,
|
||||
);
|
||||
|
||||
case 'interrupt':
|
||||
return this.handleInterrupt();
|
||||
|
||||
case 'set_model':
|
||||
return this.handleSetModel(payload as CLIControlSetModelRequest);
|
||||
return this.handleSetModel(
|
||||
payload as CLIControlSetModelRequest,
|
||||
signal,
|
||||
);
|
||||
|
||||
case 'supported_commands':
|
||||
return this.handleSupportedCommands();
|
||||
return this.handleSupportedCommands(signal);
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in SystemController`);
|
||||
@@ -51,46 +67,110 @@ export class SystemController extends BaseController {
|
||||
/**
|
||||
* Handle initialize request
|
||||
*
|
||||
* Registers SDK MCP servers and returns capabilities
|
||||
* Processes SDK MCP servers config.
|
||||
* SDK servers are registered in context.sdkMcpServers
|
||||
* and added to config.mcpServers with the sdk type flag.
|
||||
* External MCP servers are configured separately in settings.
|
||||
*/
|
||||
private async handleInitialize(
|
||||
payload: CLIControlInitializeRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
this.context.config.setSdkMode(true);
|
||||
|
||||
if (payload.sdkMcpServers && typeof payload.sdkMcpServers === 'object') {
|
||||
for (const serverName of Object.keys(payload.sdkMcpServers)) {
|
||||
this.context.sdkMcpServers.add(serverName);
|
||||
// Process SDK MCP servers
|
||||
if (
|
||||
payload.sdkMcpServers &&
|
||||
typeof payload.sdkMcpServers === 'object' &&
|
||||
payload.sdkMcpServers !== null
|
||||
) {
|
||||
const sdkServers: Record<string, MCPServerConfig> = {};
|
||||
for (const [key, wireConfig] of Object.entries(payload.sdkMcpServers)) {
|
||||
const name =
|
||||
typeof wireConfig?.name === 'string' && wireConfig.name.trim().length
|
||||
? wireConfig.name
|
||||
: key;
|
||||
|
||||
this.context.sdkMcpServers.add(name);
|
||||
sdkServers[name] = new MCPServerConfig(
|
||||
undefined, // command
|
||||
undefined, // args
|
||||
undefined, // env
|
||||
undefined, // cwd
|
||||
undefined, // url
|
||||
undefined, // httpUrl
|
||||
undefined, // headers
|
||||
undefined, // tcp
|
||||
undefined, // timeout
|
||||
true, // trust - SDK servers are trusted
|
||||
undefined, // description
|
||||
undefined, // includeTools
|
||||
undefined, // excludeTools
|
||||
undefined, // extensionName
|
||||
undefined, // oauth
|
||||
undefined, // authProviderType
|
||||
undefined, // targetAudience
|
||||
undefined, // targetServiceAccount
|
||||
'sdk', // type
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
this.context.config.addMcpServers(payload.sdkMcpServers);
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Added ${Object.keys(payload.sdkMcpServers).length} SDK MCP servers to config`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to add SDK MCP servers:',
|
||||
error,
|
||||
);
|
||||
const sdkServerCount = Object.keys(sdkServers).length;
|
||||
if (sdkServerCount > 0) {
|
||||
try {
|
||||
this.context.config.addMcpServers(sdkServers);
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Added ${sdkServerCount} SDK MCP servers to config`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to add SDK MCP servers:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.mcpServers && typeof payload.mcpServers === 'object') {
|
||||
try {
|
||||
this.context.config.addMcpServers(payload.mcpServers);
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Added ${Object.keys(payload.mcpServers).length} MCP servers to config`,
|
||||
);
|
||||
if (
|
||||
payload.mcpServers &&
|
||||
typeof payload.mcpServers === 'object' &&
|
||||
payload.mcpServers !== null
|
||||
) {
|
||||
const externalServers: Record<string, MCPServerConfig> = {};
|
||||
for (const [name, serverConfig] of Object.entries(payload.mcpServers)) {
|
||||
const normalized = this.normalizeMcpServerConfig(
|
||||
name,
|
||||
serverConfig as CLIMcpServerConfig | undefined,
|
||||
);
|
||||
if (normalized) {
|
||||
externalServers[name] = normalized;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error('[SystemController] Failed to add MCP servers:', error);
|
||||
}
|
||||
|
||||
const externalCount = Object.keys(externalServers).length;
|
||||
if (externalCount > 0) {
|
||||
try {
|
||||
this.context.config.addMcpServers(externalServers);
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Added ${externalCount} external MCP servers to config`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to add external MCP servers:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,13 +223,96 @@ export class SystemController extends BaseController {
|
||||
can_set_permission_mode:
|
||||
typeof this.context.config.setApprovalMode === 'function',
|
||||
can_set_model: typeof this.context.config.setModel === 'function',
|
||||
/* TODO: sdkMcpServers support */
|
||||
can_handle_mcp_message: false,
|
||||
// SDK MCP servers are supported - messages routed through control plane
|
||||
can_handle_mcp_message: true,
|
||||
};
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
private normalizeMcpServerConfig(
|
||||
serverName: string,
|
||||
config?: CLIMcpServerConfig,
|
||||
): MCPServerConfig | null {
|
||||
if (!config || typeof config !== 'object') {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Ignoring invalid MCP server config for '${serverName}'`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const authProvider = this.normalizeAuthProviderType(
|
||||
config.authProviderType,
|
||||
);
|
||||
const oauthConfig = this.normalizeOAuthConfig(config.oauth);
|
||||
|
||||
return new MCPServerConfig(
|
||||
config.command,
|
||||
config.args,
|
||||
config.env,
|
||||
config.cwd,
|
||||
config.url,
|
||||
config.httpUrl,
|
||||
config.headers,
|
||||
config.tcp,
|
||||
config.timeout,
|
||||
config.trust,
|
||||
config.description,
|
||||
config.includeTools,
|
||||
config.excludeTools,
|
||||
config.extensionName,
|
||||
oauthConfig,
|
||||
authProvider,
|
||||
config.targetAudience,
|
||||
config.targetServiceAccount,
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeAuthProviderType(
|
||||
value?: string,
|
||||
): AuthProviderType | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (value) {
|
||||
case AuthProviderType.DYNAMIC_DISCOVERY:
|
||||
case AuthProviderType.GOOGLE_CREDENTIALS:
|
||||
case AuthProviderType.SERVICE_ACCOUNT_IMPERSONATION:
|
||||
return value;
|
||||
default:
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Unsupported authProviderType '${value}', skipping`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeOAuthConfig(
|
||||
oauth?: CLIMcpServerConfig['oauth'],
|
||||
): MCPOAuthConfig | undefined {
|
||||
if (!oauth) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: oauth.enabled,
|
||||
clientId: oauth.clientId,
|
||||
clientSecret: oauth.clientSecret,
|
||||
authorizationUrl: oauth.authorizationUrl,
|
||||
tokenUrl: oauth.tokenUrl,
|
||||
scopes: oauth.scopes,
|
||||
audiences: oauth.audiences,
|
||||
redirectUri: oauth.redirectUri,
|
||||
tokenParamName: oauth.tokenParamName,
|
||||
registrationUrl: oauth.registrationUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle interrupt request
|
||||
*
|
||||
@@ -183,7 +346,12 @@ export class SystemController extends BaseController {
|
||||
*/
|
||||
private async handleSetModel(
|
||||
payload: CLIControlSetModelRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const model = payload.model;
|
||||
|
||||
// Validate model parameter
|
||||
@@ -223,8 +391,14 @@ export class SystemController extends BaseController {
|
||||
*
|
||||
* Returns list of supported slash commands loaded dynamically
|
||||
*/
|
||||
private async handleSupportedCommands(): Promise<Record<string, unknown>> {
|
||||
const slashCommands = await this.loadSlashCommandNames();
|
||||
private async handleSupportedCommands(
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const slashCommands = await this.loadSlashCommandNames(signal);
|
||||
|
||||
return {
|
||||
subtype: 'supported_commands',
|
||||
@@ -235,15 +409,24 @@ export class SystemController extends BaseController {
|
||||
/**
|
||||
* Load slash command names using CommandService
|
||||
*
|
||||
* @param signal - AbortSignal to respect for cancellation
|
||||
* @returns Promise resolving to array of slash command names
|
||||
*/
|
||||
private async loadSlashCommandNames(): Promise<string[]> {
|
||||
const controller = new AbortController();
|
||||
private async loadSlashCommandNames(signal: AbortSignal): Promise<string[]> {
|
||||
if (signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const service = await CommandService.create(
|
||||
[new BuiltinCommandLoader(this.context.config)],
|
||||
controller.signal,
|
||||
signal,
|
||||
);
|
||||
|
||||
if (signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const names = new Set<string>();
|
||||
const commands = service.getCommands();
|
||||
for (const command of commands) {
|
||||
@@ -251,6 +434,11 @@ export class SystemController extends BaseController {
|
||||
}
|
||||
return Array.from(names).sort();
|
||||
} catch (error) {
|
||||
// Check if the error is due to abort
|
||||
if (signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to load slash commands:',
|
||||
@@ -258,8 +446,6 @@ export class SystemController extends BaseController {
|
||||
);
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,11 @@ describe('runNonInteractiveStreamJson', () => {
|
||||
handleControlResponse: ReturnType<typeof vi.fn>;
|
||||
handleCancel: ReturnType<typeof vi.fn>;
|
||||
shutdown: ReturnType<typeof vi.fn>;
|
||||
getPendingIncomingRequestCount: ReturnType<typeof vi.fn>;
|
||||
waitForPendingIncomingRequests: ReturnType<typeof vi.fn>;
|
||||
sdkMcpController: {
|
||||
createSendSdkMcpMessage: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
let mockConsolePatcher: {
|
||||
patch: ReturnType<typeof vi.fn>;
|
||||
@@ -187,6 +192,11 @@ describe('runNonInteractiveStreamJson', () => {
|
||||
handleControlResponse: vi.fn(),
|
||||
handleCancel: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
getPendingIncomingRequestCount: vi.fn().mockReturnValue(0),
|
||||
waitForPendingIncomingRequests: vi.fn().mockResolvedValue(undefined),
|
||||
sdkMcpController: {
|
||||
createSendSdkMcpMessage: vi.fn().mockReturnValue(vi.fn()),
|
||||
},
|
||||
};
|
||||
(
|
||||
ControlDispatcher as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
Config,
|
||||
ConfigInitializeOptions,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
|
||||
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
|
||||
import { ControlContext } from './control/ControlContext.js';
|
||||
@@ -50,6 +53,12 @@ class Session {
|
||||
private isShuttingDown: boolean = false;
|
||||
private configInitialized: boolean = false;
|
||||
|
||||
// Single initialization promise that resolves when session is ready for user messages.
|
||||
// Created lazily once initialization actually starts.
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
private initializationResolve: (() => void) | null = null;
|
||||
private initializationReject: ((error: Error) => void) | null = null;
|
||||
|
||||
constructor(config: Config, initialPrompt?: CLIUserMessage) {
|
||||
this.config = config;
|
||||
this.sessionId = config.getSessionId();
|
||||
@@ -66,12 +75,32 @@ class Session {
|
||||
this.setupSignalHandlers();
|
||||
}
|
||||
|
||||
private ensureInitializationPromise(): void {
|
||||
if (this.initializationPromise) {
|
||||
return;
|
||||
}
|
||||
this.initializationPromise = new Promise<void>((resolve, reject) => {
|
||||
this.initializationResolve = () => {
|
||||
resolve();
|
||||
this.initializationResolve = null;
|
||||
this.initializationReject = null;
|
||||
};
|
||||
this.initializationReject = (error: Error) => {
|
||||
reject(error);
|
||||
this.initializationResolve = null;
|
||||
this.initializationReject = null;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getNextPromptId(): string {
|
||||
this.promptIdCounter++;
|
||||
return `${this.sessionId}########${this.promptIdCounter}`;
|
||||
}
|
||||
|
||||
private async ensureConfigInitialized(): Promise<void> {
|
||||
private async ensureConfigInitialized(
|
||||
options?: ConfigInitializeOptions,
|
||||
): Promise<void> {
|
||||
if (this.configInitialized) {
|
||||
return;
|
||||
}
|
||||
@@ -81,7 +110,7 @@ class Session {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.config.initialize();
|
||||
await this.config.initialize(options);
|
||||
this.configInitialized = true;
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
@@ -91,6 +120,44 @@ class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark initialization as complete
|
||||
*/
|
||||
private completeInitialization(): void {
|
||||
if (this.initializationResolve) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Initialization complete');
|
||||
}
|
||||
this.initializationResolve();
|
||||
this.initializationResolve = null;
|
||||
this.initializationReject = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark initialization as failed
|
||||
*/
|
||||
private failInitialization(error: Error): void {
|
||||
if (this.initializationReject) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Initialization failed:', error);
|
||||
}
|
||||
this.initializationReject(error);
|
||||
this.initializationResolve = null;
|
||||
this.initializationReject = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for session to be ready for user messages
|
||||
*/
|
||||
private async waitForInitialization(): Promise<void> {
|
||||
if (!this.initializationPromise) {
|
||||
return;
|
||||
}
|
||||
await this.initializationPromise;
|
||||
}
|
||||
|
||||
private ensureControlSystem(): void {
|
||||
if (this.controlContext && this.dispatcher && this.controlService) {
|
||||
return;
|
||||
@@ -120,49 +187,114 @@ class Session {
|
||||
return this.dispatcher;
|
||||
}
|
||||
|
||||
private async handleFirstMessage(
|
||||
/**
|
||||
* Handle the first message to determine session mode (SDK vs direct).
|
||||
* This is synchronous from the message loop's perspective - it starts
|
||||
* async work but does not return a promise that the loop awaits.
|
||||
*
|
||||
* The initialization completes asynchronously and resolves initializationPromise
|
||||
* when ready for user messages.
|
||||
*/
|
||||
private handleFirstMessage(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): Promise<boolean> {
|
||||
): void {
|
||||
if (isControlRequest(message)) {
|
||||
const request = message as CLIControlRequest;
|
||||
this.controlSystemEnabled = true;
|
||||
this.ensureControlSystem();
|
||||
if (request.request.subtype === 'initialize') {
|
||||
// Dispatch the initialize request first
|
||||
await this.dispatcher?.dispatch(request);
|
||||
|
||||
// After handling initialize control request, initialize the config
|
||||
// This is the SDK mode where config initialization is deferred
|
||||
await this.ensureConfigInitialized();
|
||||
return true;
|
||||
if (request.request.subtype === 'initialize') {
|
||||
// Start SDK mode initialization (fire-and-forget from loop perspective)
|
||||
void this.initializeSdkMode(request);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[Session] Ignoring non-initialize control request during initialization',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCLIUserMessage(message)) {
|
||||
this.controlSystemEnabled = false;
|
||||
// For non-SDK mode (direct user message), initialize config if not already done
|
||||
await this.ensureConfigInitialized();
|
||||
this.enqueueUserMessage(message as CLIUserMessage);
|
||||
return true;
|
||||
// Start direct mode initialization (fire-and-forget from loop perspective)
|
||||
void this.initializeDirectMode(message as CLIUserMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
this.controlSystemEnabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
private async handleControlRequest(
|
||||
request: CLIControlRequest,
|
||||
/**
|
||||
* SDK mode initialization flow
|
||||
* Dispatches initialize request and initializes config with MCP support
|
||||
*/
|
||||
private async initializeSdkMode(request: CLIControlRequest): Promise<void> {
|
||||
this.ensureInitializationPromise();
|
||||
try {
|
||||
// Dispatch the initialize request first
|
||||
// This registers SDK MCP servers in the control context
|
||||
await this.dispatcher?.dispatch(request);
|
||||
|
||||
// Get sendSdkMcpMessage callback from SdkMcpController
|
||||
// This callback is used by McpClientManager to send MCP messages
|
||||
// from CLI MCP clients to SDK MCP servers via the control plane
|
||||
const sendSdkMcpMessage =
|
||||
this.dispatcher?.sdkMcpController.createSendSdkMcpMessage();
|
||||
|
||||
// Initialize config with SDK MCP message support
|
||||
await this.ensureConfigInitialized({ sendSdkMcpMessage });
|
||||
|
||||
// Initialization complete!
|
||||
this.completeInitialization();
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] SDK mode initialization failed:', error);
|
||||
}
|
||||
this.failInitialization(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct mode initialization flow
|
||||
* Initializes config and enqueues the first user message
|
||||
*/
|
||||
private async initializeDirectMode(
|
||||
userMessage: CLIUserMessage,
|
||||
): Promise<void> {
|
||||
this.ensureInitializationPromise();
|
||||
try {
|
||||
// Initialize config
|
||||
await this.ensureConfigInitialized();
|
||||
|
||||
// Initialization complete!
|
||||
this.completeInitialization();
|
||||
|
||||
// Enqueue the first user message for processing
|
||||
this.enqueueUserMessage(userMessage);
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Direct mode initialization failed:', error);
|
||||
}
|
||||
this.failInitialization(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle control request asynchronously (fire-and-forget from main loop).
|
||||
* Errors are handled internally and responses sent by dispatcher.
|
||||
*/
|
||||
private handleControlRequestAsync(request: CLIControlRequest): void {
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
@@ -171,9 +303,20 @@ class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
// Fire-and-forget: dispatch runs concurrently
|
||||
// The dispatcher's pendingIncomingRequests tracks completion
|
||||
void dispatcher.dispatch(request).catch((error) => {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Control request dispatch error:', error);
|
||||
}
|
||||
// Error response is already sent by dispatcher.dispatch()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle control response - MUST be synchronous
|
||||
* This resolves pending outgoing requests, breaking the deadlock cycle.
|
||||
*/
|
||||
private handleControlResponse(response: CLIControlResponse): void {
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (!dispatcher) {
|
||||
@@ -201,8 +344,8 @@ class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure config is initialized before processing user messages
|
||||
await this.ensureConfigInitialized();
|
||||
// Wait for initialization to complete before processing user messages
|
||||
await this.waitForInitialization();
|
||||
|
||||
const promptId = this.getNextPromptId();
|
||||
|
||||
@@ -307,6 +450,45 @@ class Session {
|
||||
process.on('SIGTERM', this.shutdownHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all pending work to complete before shutdown
|
||||
*/
|
||||
private async waitForAllPendingWork(): Promise<void> {
|
||||
// 1. Wait for initialization to complete (or fail)
|
||||
try {
|
||||
await this.waitForInitialization();
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Initialization error during shutdown:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Wait for all control request handlers using dispatcher's tracking
|
||||
if (this.dispatcher) {
|
||||
const pendingCount = this.dispatcher.getPendingIncomingRequestCount();
|
||||
if (pendingCount > 0 && this.debugMode) {
|
||||
console.error(
|
||||
`[Session] Waiting for ${pendingCount} pending control request handlers`,
|
||||
);
|
||||
}
|
||||
await this.dispatcher.waitForPendingIncomingRequests();
|
||||
}
|
||||
|
||||
// 3. Wait for user message processing queue
|
||||
while (this.processingPromise) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Waiting for user message processing');
|
||||
}
|
||||
try {
|
||||
await this.processingPromise;
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Error in user message processing:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async shutdown(): Promise<void> {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Shutting down');
|
||||
@@ -314,18 +496,8 @@ class Session {
|
||||
|
||||
this.isShuttingDown = true;
|
||||
|
||||
if (this.processingPromise) {
|
||||
try {
|
||||
await this.processingPromise;
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[Session] Error waiting for processing to complete:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Wait for all pending work
|
||||
await this.waitForAllPendingWork();
|
||||
|
||||
this.dispatcher?.shutdown();
|
||||
this.cleanupSignalHandlers();
|
||||
@@ -339,18 +511,30 @@ class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main message processing loop
|
||||
*
|
||||
* CRITICAL: This loop must NEVER await handlers that might need to
|
||||
* send control requests and wait for responses. Such handlers must
|
||||
* be started in fire-and-forget mode, allowing the loop to continue
|
||||
* reading responses that resolve pending requests.
|
||||
*
|
||||
* Message handling order:
|
||||
* 1. control_response - FIRST, synchronously resolves pending requests
|
||||
* 2. First message - determines mode, starts async initialization
|
||||
* 3. control_request - fire-and-forget, tracked by dispatcher
|
||||
* 4. control_cancel - synchronous
|
||||
* 5. user_message - enqueued for processing
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Starting session', this.sessionId);
|
||||
}
|
||||
|
||||
// Handle initial prompt if provided (fire-and-forget)
|
||||
if (this.initialPrompt !== null) {
|
||||
const handled = await this.handleFirstMessage(this.initialPrompt);
|
||||
if (handled && this.isShuttingDown) {
|
||||
await this.shutdown();
|
||||
return;
|
||||
}
|
||||
this.handleFirstMessage(this.initialPrompt);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -359,23 +543,33 @@ class Session {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.controlSystemEnabled === null) {
|
||||
const handled = await this.handleFirstMessage(message);
|
||||
if (handled) {
|
||||
if (this.isShuttingDown) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ============================================================
|
||||
// CRITICAL: Handle control_response FIRST and SYNCHRONOUSLY
|
||||
// This resolves pending outgoing requests, breaking deadlock.
|
||||
// ============================================================
|
||||
if (isControlResponse(message)) {
|
||||
this.handleControlResponse(message as CLIControlResponse);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle first message to determine session mode
|
||||
if (this.controlSystemEnabled === null) {
|
||||
this.handleFirstMessage(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CRITICAL: Handle control_request in FIRE-AND-FORGET mode
|
||||
// DON'T await - let handler run concurrently while loop continues
|
||||
// Dispatcher's pendingIncomingRequests tracks completion
|
||||
// ============================================================
|
||||
if (isControlRequest(message)) {
|
||||
await this.handleControlRequest(message as CLIControlRequest);
|
||||
} else if (isControlResponse(message)) {
|
||||
this.handleControlResponse(message as CLIControlResponse);
|
||||
this.handleControlRequestAsync(message as CLIControlRequest);
|
||||
} else if (isControlCancel(message)) {
|
||||
// Cancel is synchronous - OK to handle inline
|
||||
this.handleControlCancel(message as ControlCancelRequest);
|
||||
} else if (isCLIUserMessage(message)) {
|
||||
// User messages are enqueued, processing runs separately
|
||||
this.enqueueUserMessage(message as CLIUserMessage);
|
||||
} else if (this.debugMode) {
|
||||
if (
|
||||
@@ -402,19 +596,8 @@ class Session {
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
while (this.processingPromise) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Waiting for final processing to complete');
|
||||
}
|
||||
try {
|
||||
await this.processingPromise;
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Error in final processing:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended - wait for all pending work before shutdown
|
||||
await this.waitForAllPendingWork();
|
||||
await this.shutdown();
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
SubagentConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { SubagentConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Annotation for attaching metadata to content blocks
|
||||
@@ -298,11 +295,68 @@ export interface CLIControlPermissionRequest {
|
||||
blocked_path: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire format for SDK MCP server config in initialization request.
|
||||
* The actual Server instance stays in the SDK process.
|
||||
*/
|
||||
export interface SDKMcpServerConfig {
|
||||
type: 'sdk';
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire format for external MCP server config in initialization request.
|
||||
* Represents stdio/SSE/HTTP/TCP transports that must run in the CLI process.
|
||||
*/
|
||||
export interface CLIMcpServerConfig {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
httpUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
tcp?: string;
|
||||
timeout?: number;
|
||||
trust?: boolean;
|
||||
description?: string;
|
||||
includeTools?: string[];
|
||||
excludeTools?: string[];
|
||||
extensionName?: string;
|
||||
oauth?: {
|
||||
enabled?: boolean;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
scopes?: string[];
|
||||
audiences?: string[];
|
||||
redirectUri?: string;
|
||||
tokenParamName?: string;
|
||||
registrationUrl?: string;
|
||||
};
|
||||
authProviderType?:
|
||||
| 'dynamic_discovery'
|
||||
| 'google_credentials'
|
||||
| 'service_account_impersonation';
|
||||
targetAudience?: string;
|
||||
targetServiceAccount?: string;
|
||||
}
|
||||
|
||||
export interface CLIControlInitializeRequest {
|
||||
subtype: 'initialize';
|
||||
hooks?: HookRegistration[] | null;
|
||||
sdkMcpServers?: Record<string, MCPServerConfig>;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
/**
|
||||
* SDK MCP servers config
|
||||
* These are MCP servers running in the SDK process, connected via control plane.
|
||||
* External MCP servers are configured separately in settings, not via initialization.
|
||||
*/
|
||||
sdkMcpServers?: Record<string, Omit<SDKMcpServerConfig, 'instance'>>;
|
||||
/**
|
||||
* External MCP servers that the SDK wants the CLI to manage.
|
||||
* These run outside the SDK process and require CLI-side transport setup.
|
||||
*/
|
||||
mcpServers?: Record<string, CLIMcpServerConfig>;
|
||||
agents?: SubagentConfig[];
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} }));
|
||||
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
|
||||
vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} }));
|
||||
vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} }));
|
||||
vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} }));
|
||||
vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} }));
|
||||
vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} }));
|
||||
vi.mock('../ui/commands/extensionsCommand.js', () => ({
|
||||
|
||||
@@ -15,7 +15,6 @@ import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { compressCommand } from '../ui/commands/compressCommand.js';
|
||||
import { copyCommand } from '../ui/commands/copyCommand.js';
|
||||
import { corgiCommand } from '../ui/commands/corgiCommand.js';
|
||||
import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
@@ -63,7 +62,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
clearCommand,
|
||||
compressCommand,
|
||||
copyCommand,
|
||||
corgiCommand,
|
||||
docsCommand,
|
||||
directoryCommand,
|
||||
editorCommand,
|
||||
|
||||
@@ -56,10 +56,10 @@ export const createMockCommandContext = (
|
||||
pendingItem: null,
|
||||
setPendingItem: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
toggleCorgiMode: vi.fn(),
|
||||
toggleVimEnabled: vi.fn(),
|
||||
extensionsUpdateState: new Map(),
|
||||
setExtensionsUpdateState: vi.fn(),
|
||||
reloadCommands: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
session: {
|
||||
|
||||
@@ -136,7 +136,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const { settings, config, initializationResult } = props;
|
||||
const historyManager = useHistory();
|
||||
useMemoryMonitor(historyManager);
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
const [quittingMessages, setQuittingMessages] = useState<
|
||||
HistoryItem[] | null
|
||||
@@ -485,7 +484,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
}, 100);
|
||||
},
|
||||
setDebugMessage,
|
||||
toggleCorgiMode: () => setCorgiMode((prev) => !prev),
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
@@ -498,7 +496,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
openSettingsDialog,
|
||||
openModelDialog,
|
||||
setDebugMessage,
|
||||
setCorgiMode,
|
||||
dispatchExtensionStateUpdate,
|
||||
openPermissionsDialog,
|
||||
openApprovalModeDialog,
|
||||
@@ -945,6 +942,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isFocused,
|
||||
streamingState,
|
||||
elapsedTime,
|
||||
settings,
|
||||
});
|
||||
|
||||
// Dialog close functionality
|
||||
@@ -1218,7 +1216,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
qwenAuthState,
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
corgiMode,
|
||||
debugMessage,
|
||||
quittingMessages,
|
||||
isSettingsDialogOpen,
|
||||
@@ -1309,7 +1306,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
qwenAuthState,
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
corgiMode,
|
||||
debugMessage,
|
||||
quittingMessages,
|
||||
isSettingsDialogOpen,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { corgiCommand } from './corgiCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('corgiCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
vi.spyOn(mockContext.ui, 'toggleCorgiMode');
|
||||
});
|
||||
|
||||
it('should call the toggleCorgiMode function on the UI context', async () => {
|
||||
if (!corgiCommand.action) {
|
||||
throw new Error('The corgi command must have an action.');
|
||||
}
|
||||
|
||||
await corgiCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(corgiCommand.name).toBe('corgi');
|
||||
expect(corgiCommand.description).toBe('Toggles corgi mode.');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const corgiCommand: SlashCommand = {
|
||||
name: 'corgi',
|
||||
description: 'Toggles corgi mode.',
|
||||
hidden: true,
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context, _args) => {
|
||||
context.ui.toggleCorgiMode();
|
||||
},
|
||||
};
|
||||
587
packages/cli/src/ui/commands/languageCommand.test.ts
Normal file
587
packages/cli/src/ui/commands/languageCommand.test.ts
Normal file
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import { type CommandContext, CommandKind } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../i18n/index.js', () => ({
|
||||
setLanguageAsync: vi.fn().mockResolvedValue(undefined),
|
||||
getCurrentLanguage: vi.fn().mockReturnValue('en'),
|
||||
t: vi.fn((key: string) => key),
|
||||
}));
|
||||
|
||||
// Mock settings module to avoid Storage side effect
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
SettingScope: {
|
||||
User: 'user',
|
||||
Workspace: 'workspace',
|
||||
Default: 'default',
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fs module
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
default: {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Storage from core
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
Storage: {
|
||||
getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'),
|
||||
getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/.qwen/settings.json'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Import modules after mocking
|
||||
import * as i18n from '../../i18n/index.js';
|
||||
import { languageCommand } from './languageCommand.js';
|
||||
|
||||
describe('languageCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
},
|
||||
settings: {
|
||||
merged: {},
|
||||
setValue: vi.fn(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Reset i18n mocks
|
||||
vi.mocked(i18n.getCurrentLanguage).mockReturnValue('en');
|
||||
vi.mocked(i18n.t).mockImplementation((key: string) => key);
|
||||
|
||||
// Reset fs mocks
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('command metadata', () => {
|
||||
it('should have the correct name', () => {
|
||||
expect(languageCommand.name).toBe('language');
|
||||
});
|
||||
|
||||
it('should have a description', () => {
|
||||
expect(languageCommand.description).toBeDefined();
|
||||
expect(typeof languageCommand.description).toBe('string');
|
||||
});
|
||||
|
||||
it('should be a built-in command', () => {
|
||||
expect(languageCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
it('should have subcommands', () => {
|
||||
expect(languageCommand.subCommands).toBeDefined();
|
||||
expect(languageCommand.subCommands?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should have ui and output subcommands', () => {
|
||||
const subCommandNames = languageCommand.subCommands?.map((c) => c.name);
|
||||
expect(subCommandNames).toContain('ui');
|
||||
expect(subCommandNames).toContain('output');
|
||||
});
|
||||
});
|
||||
|
||||
describe('main command action - no arguments', () => {
|
||||
it('should show current language settings when no arguments provided', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Current UI language:'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should show available subcommands in help', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('/language ui'),
|
||||
});
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('/language output'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should show LLM output language when set', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
'# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY',
|
||||
);
|
||||
|
||||
// Make t() function handle interpolation for this test
|
||||
vi.mocked(i18n.t).mockImplementation(
|
||||
(key: string, params?: Record<string, string>) => {
|
||||
if (params && key.includes('{{lang}}')) {
|
||||
return key.replace('{{lang}}', params['lang'] || '');
|
||||
}
|
||||
return key;
|
||||
},
|
||||
);
|
||||
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Current UI language:'),
|
||||
});
|
||||
// Verify it correctly parses "Chinese" from the template format
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Chinese'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('main command action - config not available', () => {
|
||||
it('should return error when config is null', async () => {
|
||||
mockContext.services.config = null;
|
||||
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Configuration not available'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/language ui subcommand', () => {
|
||||
it('should show help when no language argument provided', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Usage: /language ui'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set English with "en"', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui en');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalled();
|
||||
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set English with "en-US"', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui en-US');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set English with "english"', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui english');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set Chinese with "zh"', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui zh');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set Chinese with "zh-CN"', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui zh-CN');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set Chinese with "chinese"', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui chinese');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error for invalid language', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui invalid');
|
||||
|
||||
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Invalid language'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should persist setting to user scope', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
await languageCommand.action(mockContext, 'ui en');
|
||||
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
|
||||
expect.anything(), // SettingScope.User
|
||||
'general.language',
|
||||
'en',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/language output subcommand', () => {
|
||||
it('should show help when no language argument provided', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'output');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Usage: /language output'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create LLM output language rule file', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'output Chinese');
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('output-language.md'),
|
||||
expect.stringContaining('Chinese'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('LLM output language rule file generated'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should include restart notice in success message', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'output Japanese');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('restart'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle file write errors gracefully', async () => {
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'output German');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Failed to generate'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility - direct language arguments', () => {
|
||||
it('should set Chinese with direct "zh" argument', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'zh');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set English with direct "en" argument', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'en');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error for unknown direct argument', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'unknown');
|
||||
|
||||
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Invalid command'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui subcommand object', () => {
|
||||
const uiSubcommand = languageCommand.subCommands?.find(
|
||||
(c) => c.name === 'ui',
|
||||
);
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(uiSubcommand).toBeDefined();
|
||||
expect(uiSubcommand?.name).toBe('ui');
|
||||
expect(uiSubcommand?.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
it('should have nested language subcommands', () => {
|
||||
const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name);
|
||||
expect(nestedNames).toContain('zh-CN');
|
||||
expect(nestedNames).toContain('en-US');
|
||||
});
|
||||
|
||||
it('should have action that sets language', async () => {
|
||||
if (!uiSubcommand?.action) {
|
||||
throw new Error('UI subcommand must have an action.');
|
||||
}
|
||||
|
||||
const result = await uiSubcommand.action(mockContext, 'en');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('output subcommand object', () => {
|
||||
const outputSubcommand = languageCommand.subCommands?.find(
|
||||
(c) => c.name === 'output',
|
||||
);
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(outputSubcommand).toBeDefined();
|
||||
expect(outputSubcommand?.name).toBe('output');
|
||||
expect(outputSubcommand?.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
it('should have action that generates rule file', async () => {
|
||||
if (!outputSubcommand?.action) {
|
||||
throw new Error('Output subcommand must have an action.');
|
||||
}
|
||||
|
||||
// Ensure mocks are properly set for this test
|
||||
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
|
||||
const result = await outputSubcommand.action(mockContext, 'French');
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('LLM output language rule file generated'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested ui language subcommands', () => {
|
||||
const uiSubcommand = languageCommand.subCommands?.find(
|
||||
(c) => c.name === 'ui',
|
||||
);
|
||||
const zhCNSubcommand = uiSubcommand?.subCommands?.find(
|
||||
(c) => c.name === 'zh-CN',
|
||||
);
|
||||
const enUSSubcommand = uiSubcommand?.subCommands?.find(
|
||||
(c) => c.name === 'en-US',
|
||||
);
|
||||
|
||||
it('zh-CN should have aliases', () => {
|
||||
expect(zhCNSubcommand?.altNames).toContain('zh');
|
||||
expect(zhCNSubcommand?.altNames).toContain('chinese');
|
||||
});
|
||||
|
||||
it('en-US should have aliases', () => {
|
||||
expect(enUSSubcommand?.altNames).toContain('en');
|
||||
expect(enUSSubcommand?.altNames).toContain('english');
|
||||
});
|
||||
|
||||
it('zh-CN action should set Chinese', async () => {
|
||||
if (!zhCNSubcommand?.action) {
|
||||
throw new Error('zh-CN subcommand must have an action.');
|
||||
}
|
||||
|
||||
const result = await zhCNSubcommand.action(mockContext, '');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('en-US action should set English', async () => {
|
||||
if (!enUSSubcommand?.action) {
|
||||
throw new Error('en-US subcommand must have an action.');
|
||||
}
|
||||
|
||||
const result = await enUSSubcommand.action(mockContext, '');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject extra arguments', async () => {
|
||||
if (!zhCNSubcommand?.action) {
|
||||
throw new Error('zh-CN subcommand must have an action.');
|
||||
}
|
||||
|
||||
const result = await zhCNSubcommand.action(mockContext, 'extra args');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('do not accept additional arguments'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -81,8 +81,9 @@ function getCurrentLlmOutputLanguage(): string | null {
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
// Extract language name from the first line (e.g., "# Chinese Response Rules" -> "Chinese")
|
||||
const match = content.match(/^#\s+(.+?)\s+Response Rules/i);
|
||||
// Extract language name from the first line
|
||||
// Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY"
|
||||
const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
@@ -127,16 +128,17 @@ async function setUiLanguage(
|
||||
context.ui.reloadCommands();
|
||||
|
||||
// Map language codes to friendly display names
|
||||
const langDisplayNames: Record<SupportedLanguage, string> = {
|
||||
const langDisplayNames: Partial<Record<SupportedLanguage, string>> = {
|
||||
zh: '中文(zh-CN)',
|
||||
en: 'English(en-US)',
|
||||
ru: 'Русский (ru-RU)',
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('UI language changed to {{lang}}', {
|
||||
lang: langDisplayNames[lang],
|
||||
lang: langDisplayNames[lang] || lang,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -216,7 +218,7 @@ export const languageCommand: SlashCommand = {
|
||||
: t('LLM output language not set'),
|
||||
'',
|
||||
t('Available subcommands:'),
|
||||
` /language ui [zh-CN|en-US] - ${t('Set UI language')}`,
|
||||
` /language ui [zh-CN|en-US|ru-RU] - ${t('Set UI language')}`,
|
||||
` /language output <language> - ${t('Set LLM output language')}`,
|
||||
].join('\n');
|
||||
|
||||
@@ -232,7 +234,7 @@ export const languageCommand: SlashCommand = {
|
||||
const subcommand = parts[0].toLowerCase();
|
||||
|
||||
if (subcommand === 'ui') {
|
||||
// Handle /language ui [zh-CN|en-US]
|
||||
// Handle /language ui [zh-CN|en-US|ru-RU]
|
||||
if (parts.length === 1) {
|
||||
// Show UI language subcommand help
|
||||
return {
|
||||
@@ -241,11 +243,12 @@ export const languageCommand: SlashCommand = {
|
||||
content: [
|
||||
t('Set UI language'),
|
||||
'',
|
||||
t('Usage: /language ui [zh-CN|en-US]'),
|
||||
t('Usage: /language ui [zh-CN|en-US|ru-RU]'),
|
||||
'',
|
||||
t('Available options:'),
|
||||
t(' - zh-CN: Simplified Chinese'),
|
||||
t(' - en-US: English'),
|
||||
t(' - ru-RU: Russian'),
|
||||
'',
|
||||
t(
|
||||
'To request additional UI language packs, please open an issue on GitHub.',
|
||||
@@ -266,11 +269,18 @@ export const languageCommand: SlashCommand = {
|
||||
langArg === 'zh-cn'
|
||||
) {
|
||||
targetLang = 'zh';
|
||||
} else if (
|
||||
langArg === 'ru' ||
|
||||
langArg === 'ru-RU' ||
|
||||
langArg === 'russian' ||
|
||||
langArg === 'русский'
|
||||
) {
|
||||
targetLang = 'ru';
|
||||
} else {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Invalid language. Available: en-US, zh-CN'),
|
||||
content: t('Invalid language. Available: en-US, zh-CN, ru-RU'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,13 +317,20 @@ export const languageCommand: SlashCommand = {
|
||||
langArg === 'zh-cn'
|
||||
) {
|
||||
targetLang = 'zh';
|
||||
} else if (
|
||||
langArg === 'ru' ||
|
||||
langArg === 'ru-RU' ||
|
||||
langArg === 'russian' ||
|
||||
langArg === 'русский'
|
||||
) {
|
||||
targetLang = 'ru';
|
||||
} else {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: [
|
||||
t('Invalid command. Available subcommands:'),
|
||||
' - /language ui [zh-CN|en-US] - ' + t('Set UI language'),
|
||||
' - /language ui [zh-CN|en-US|ru-RU] - ' + t('Set UI language'),
|
||||
' - /language output <language> - ' + t('Set LLM output language'),
|
||||
].join('\n'),
|
||||
};
|
||||
@@ -423,6 +440,29 @@ export const languageCommand: SlashCommand = {
|
||||
return setUiLanguage(context, 'en');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ru-RU',
|
||||
altNames: ['ru', 'russian', 'русский'],
|
||||
get description() {
|
||||
return t('Set UI language to Russian (ru-RU)');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
if (args.trim().length > 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Language subcommands do not accept additional arguments.',
|
||||
),
|
||||
};
|
||||
}
|
||||
return setUiLanguage(context, 'ru');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -64,8 +64,6 @@ export interface CommandContext {
|
||||
* @param history The array of history items to load.
|
||||
*/
|
||||
loadHistory: UseHistoryManagerReturn['loadHistory'];
|
||||
/** Toggles a special display mode. */
|
||||
toggleCorgiMode: () => void;
|
||||
toggleVimEnabled: () => Promise<boolean>;
|
||||
setGeminiMdFileCount: (count: number) => void;
|
||||
reloadCommands: () => void;
|
||||
|
||||
@@ -120,7 +120,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
},
|
||||
branchName: 'main',
|
||||
debugMessage: '',
|
||||
corgiMode: false,
|
||||
errorCount: 0,
|
||||
nightly: false,
|
||||
isTrustedFolder: true,
|
||||
@@ -183,6 +182,7 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
// Smoke check that the Footer renders when enabled.
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
|
||||
@@ -200,7 +200,6 @@ describe('Composer', () => {
|
||||
it('passes correct props to Footer including vim mode when enabled', async () => {
|
||||
const uiState = createMockUIState({
|
||||
branchName: 'feature-branch',
|
||||
corgiMode: true,
|
||||
errorCount: 2,
|
||||
sessionStats: {
|
||||
sessionId: 'test-session',
|
||||
|
||||
@@ -33,7 +33,6 @@ export const Footer: React.FC = () => {
|
||||
debugMode,
|
||||
branchName,
|
||||
debugMessage,
|
||||
corgiMode,
|
||||
errorCount,
|
||||
showErrorDetails,
|
||||
promptTokenCount,
|
||||
@@ -45,7 +44,6 @@ export const Footer: React.FC = () => {
|
||||
debugMode: config.getDebugMode(),
|
||||
branchName: uiState.branchName,
|
||||
debugMessage: uiState.debugMessage,
|
||||
corgiMode: uiState.corgiMode,
|
||||
errorCount: uiState.errorCount,
|
||||
showErrorDetails: uiState.showErrorDetails,
|
||||
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
|
||||
@@ -153,16 +151,6 @@ export const Footer: React.FC = () => {
|
||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
</Box>
|
||||
<Box alignItems="center" paddingLeft={2}>
|
||||
{corgiMode && (
|
||||
<Text>
|
||||
<Text color={theme.ui.symbol}>| </Text>
|
||||
<Text color={theme.status.error}>▼</Text>
|
||||
<Text color={theme.text.primary}>(´</Text>
|
||||
<Text color={theme.status.error}>ᴥ</Text>
|
||||
<Text color={theme.text.primary}>`)</Text>
|
||||
<Text color={theme.status.error}>▼ </Text>
|
||||
</Text>
|
||||
)}
|
||||
{!showErrorDetails && errorCount > 0 && (
|
||||
<Box>
|
||||
<Text color={theme.ui.symbol}>| </Text>
|
||||
|
||||
@@ -1307,7 +1307,7 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualCursor = [0, 8]; // cursor after '👍' (length is 6 + 2 for emoji)
|
||||
mockBuffer.visualCursor = [0, 7]; // cursor after '👍' (emoji is 1 code point, so total is 7)
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
|
||||
@@ -707,15 +707,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
statusText = t('Accepting edits');
|
||||
}
|
||||
|
||||
const borderColor =
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
: theme.border.default;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
: theme.border.default
|
||||
}
|
||||
borderStyle="single"
|
||||
borderTop={true}
|
||||
borderBottom={true}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text
|
||||
@@ -829,9 +834,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText)
|
||||
) {
|
||||
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,16 +20,21 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
|
||||
|
||||
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
|
||||
|
||||
const renderWithMockedStats = (metrics: SessionMetrics) => {
|
||||
const renderWithMockedStats = (
|
||||
metrics: SessionMetrics,
|
||||
sessionId: string = 'test-session-id-12345',
|
||||
promptCount: number = 5,
|
||||
) => {
|
||||
useSessionStatsMock.mockReturnValue({
|
||||
stats: {
|
||||
sessionId,
|
||||
sessionStartTime: new Date(),
|
||||
metrics,
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 5,
|
||||
promptCount,
|
||||
},
|
||||
|
||||
getPromptCount: () => 5,
|
||||
getPromptCount: () => promptCount,
|
||||
startNewPrompt: vi.fn(),
|
||||
});
|
||||
|
||||
@@ -70,6 +75,38 @@ describe('<SessionSummaryDisplay />', () => {
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Agent powering down. Goodbye!');
|
||||
expect(output).toContain('To continue this session, run');
|
||||
expect(output).toContain('qwen --resume test-session-id-12345');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not show resume message when there are no messages', () => {
|
||||
const metrics: SessionMetrics = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
files: {
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Pass promptCount = 0 to simulate no messages
|
||||
const { lastFrame } = renderWithMockedStats(
|
||||
metrics,
|
||||
'test-session-id-12345',
|
||||
0,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Agent powering down. Goodbye!');
|
||||
expect(output).not.toContain('To continue this session, run');
|
||||
expect(output).not.toContain('qwen --resume');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface SessionSummaryDisplayProps {
|
||||
@@ -14,9 +17,28 @@ interface SessionSummaryDisplayProps {
|
||||
|
||||
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
duration,
|
||||
}) => (
|
||||
<StatsDisplay
|
||||
title={t('Agent powering down. Goodbye!')}
|
||||
duration={duration}
|
||||
/>
|
||||
);
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
|
||||
// Only show the resume message if there were messages in the session
|
||||
const hasMessages = stats.promptCount > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatsDisplay
|
||||
title={t('Agent powering down. Goodbye!')}
|
||||
duration={duration}
|
||||
/>
|
||||
{hasMessages && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('To continue this session, run')}{' '}
|
||||
<Text color={theme.text.accent}>
|
||||
qwen --resume {stats.sessionId}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1461,7 +1461,7 @@ describe('SettingsDialog', () => {
|
||||
context: {
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
respectQwemIgnore: true,
|
||||
respectQwenIgnore: true,
|
||||
enableRecursiveFileSearch: false,
|
||||
disableFuzzySearch: true,
|
||||
},
|
||||
@@ -1535,7 +1535,7 @@ describe('SettingsDialog', () => {
|
||||
loadMemoryFromIncludeDirectories: false,
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
respectQwemIgnore: false,
|
||||
respectQwenIgnore: false,
|
||||
enableRecursiveFileSearch: false,
|
||||
disableFuzzySearch: false,
|
||||
},
|
||||
|
||||
@@ -19,39 +19,39 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) commit │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
(r:) commit
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) commit │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
(r:) commit
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ! Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
! Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ * Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
* Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
@@ -6,7 +6,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
|
||||
│ Agent powering down. Goodbye! │
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: │
|
||||
│ Session ID: test-session-id-12345 │
|
||||
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
|
||||
│ Success Rate: 0.0% │
|
||||
│ Code Changes: +42 -15 │
|
||||
@@ -26,5 +26,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
|
||||
│ │
|
||||
│ » Tip: For a full token breakdown, run \`/stats model\`. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
To continue this session, run qwen --resume test-session-id-12345"
|
||||
`;
|
||||
|
||||
@@ -14,14 +14,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -48,14 +48,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -82,14 +82,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -116,14 +116,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false* │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -150,14 +150,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -184,14 +184,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -218,14 +218,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -252,14 +252,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -286,14 +286,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -320,14 +320,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title true* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips true* │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
|
||||
@@ -54,7 +54,6 @@ export interface UIState {
|
||||
qwenAuthState: QwenAuthState;
|
||||
editorError: string | null;
|
||||
isEditorDialogOpen: boolean;
|
||||
corgiMode: boolean;
|
||||
debugMessage: string;
|
||||
quittingMessages: HistoryItem[] | null;
|
||||
isSettingsDialogOpen: boolean;
|
||||
|
||||
@@ -153,7 +153,6 @@ describe('useSlashCommandProcessor', () => {
|
||||
openModelDialog: mockOpenModelDialog,
|
||||
quit: mockSetQuittingMessages,
|
||||
setDebugMessage: vi.fn(),
|
||||
toggleCorgiMode: vi.fn(),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -909,7 +908,6 @@ describe('useSlashCommandProcessor', () => {
|
||||
vi.fn(), // openThemeDialog
|
||||
mockOpenAuthDialog,
|
||||
vi.fn(), // openEditorDialog
|
||||
vi.fn(), // toggleCorgiMode
|
||||
mockSetQuittingMessages,
|
||||
vi.fn(), // openSettingsDialog
|
||||
vi.fn(), // openModelSelectionDialog
|
||||
|
||||
@@ -68,7 +68,6 @@ interface SlashCommandProcessorActions {
|
||||
openApprovalModeDialog: () => void;
|
||||
quit: (messages: HistoryItem[]) => void;
|
||||
setDebugMessage: (message: string) => void;
|
||||
toggleCorgiMode: () => void;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||
openSubagentCreateDialog: () => void;
|
||||
@@ -206,7 +205,6 @@ export const useSlashCommandProcessor = (
|
||||
setDebugMessage: actions.setDebugMessage,
|
||||
pendingItem,
|
||||
setPendingItem,
|
||||
toggleCorgiMode: actions.toggleCorgiMode,
|
||||
toggleVimEnabled,
|
||||
setGeminiMdFileCount,
|
||||
reloadCommands,
|
||||
|
||||
@@ -15,6 +15,23 @@ import {
|
||||
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS,
|
||||
useAttentionNotifications,
|
||||
} from './useAttentionNotifications.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
const mockSettings: LoadedSettings = {
|
||||
merged: {
|
||||
general: {
|
||||
terminalBell: true,
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
const mockSettingsDisabled: LoadedSettings = {
|
||||
merged: {
|
||||
general: {
|
||||
terminalBell: false,
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
vi.mock('../../utils/attentionNotification.js', () => ({
|
||||
notifyTerminalAttention: vi.fn(),
|
||||
@@ -40,6 +57,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
...props,
|
||||
},
|
||||
},
|
||||
@@ -53,11 +71,13 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).toHaveBeenCalledWith(
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
{ enabled: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -72,6 +92,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -86,6 +107,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,11 +116,13 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).toHaveBeenCalledWith(
|
||||
AttentionNotificationReason.LongTaskComplete,
|
||||
{ enabled: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -110,6 +134,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -118,6 +143,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -135,6 +161,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: 5,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -143,9 +170,30 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not notify when terminalBell setting is disabled', () => {
|
||||
const { rerender } = render({
|
||||
settings: mockSettingsDisabled,
|
||||
});
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettingsDisabled,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).toHaveBeenCalledWith(
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
{ enabled: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
notifyTerminalAttention,
|
||||
AttentionNotificationReason,
|
||||
} from '../../utils/attentionNotification.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
|
||||
|
||||
@@ -17,13 +18,16 @@ interface UseAttentionNotificationsOptions {
|
||||
isFocused: boolean;
|
||||
streamingState: StreamingState;
|
||||
elapsedTime: number;
|
||||
settings: LoadedSettings;
|
||||
}
|
||||
|
||||
export const useAttentionNotifications = ({
|
||||
isFocused,
|
||||
streamingState,
|
||||
elapsedTime,
|
||||
settings,
|
||||
}: UseAttentionNotificationsOptions) => {
|
||||
const terminalBellEnabled = settings?.merged?.general?.terminalBell ?? true;
|
||||
const awaitingNotificationSentRef = useRef(false);
|
||||
const respondingElapsedRef = useRef(0);
|
||||
|
||||
@@ -33,14 +37,16 @@ export const useAttentionNotifications = ({
|
||||
!isFocused &&
|
||||
!awaitingNotificationSentRef.current
|
||||
) {
|
||||
notifyTerminalAttention(AttentionNotificationReason.ToolApproval);
|
||||
notifyTerminalAttention(AttentionNotificationReason.ToolApproval, {
|
||||
enabled: terminalBellEnabled,
|
||||
});
|
||||
awaitingNotificationSentRef.current = true;
|
||||
}
|
||||
|
||||
if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) {
|
||||
awaitingNotificationSentRef.current = false;
|
||||
}
|
||||
}, [isFocused, streamingState]);
|
||||
}, [isFocused, streamingState, terminalBellEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
@@ -53,11 +59,13 @@ export const useAttentionNotifications = ({
|
||||
respondingElapsedRef.current >=
|
||||
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS;
|
||||
if (wasLongTask && !isFocused) {
|
||||
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete);
|
||||
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete, {
|
||||
enabled: terminalBellEnabled,
|
||||
});
|
||||
}
|
||||
// Reset tracking for next task
|
||||
respondingElapsedRef.current = 0;
|
||||
return;
|
||||
}
|
||||
}, [streamingState, elapsedTime, isFocused]);
|
||||
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled]);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
loadHistory: (_newHistory) => {},
|
||||
pendingItem: null,
|
||||
setPendingItem: (_item) => {},
|
||||
toggleCorgiMode: () => {},
|
||||
toggleVimEnabled: async () => false,
|
||||
setGeminiMdFileCount: (_count) => {},
|
||||
reloadCommands: () => {},
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum AttentionNotificationReason {
|
||||
|
||||
export interface TerminalNotificationOptions {
|
||||
stream?: Pick<NodeJS.WriteStream, 'write' | 'isTTY'>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const TERMINAL_BELL = '\u0007';
|
||||
@@ -28,6 +29,11 @@ export function notifyTerminalAttention(
|
||||
_reason: AttentionNotificationReason,
|
||||
options: TerminalNotificationOptions = {},
|
||||
): boolean {
|
||||
// Check if terminal bell is enabled (default true for backwards compatibility)
|
||||
if (options.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stream = options.stream ?? process.stdout;
|
||||
if (!stream?.write || stream.isTTY === false) {
|
||||
return false;
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
"src/ui/commands/clearCommand.test.ts",
|
||||
"src/ui/commands/compressCommand.test.ts",
|
||||
"src/ui/commands/copyCommand.test.ts",
|
||||
"src/ui/commands/corgiCommand.test.ts",
|
||||
"src/ui/commands/docsCommand.test.ts",
|
||||
"src/ui/commands/editorCommand.test.ts",
|
||||
"src/ui/commands/extensionsCommand.test.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -47,7 +47,7 @@
|
||||
"fast-uri": "^3.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.4.5",
|
||||
"glob": "^10.5.0",
|
||||
"google-auth-library": "^9.11.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
|
||||
@@ -63,6 +63,7 @@ vi.mock('../tools/tool-registry', () => {
|
||||
ToolRegistryMock.prototype.registerTool = vi.fn();
|
||||
ToolRegistryMock.prototype.discoverAllTools = vi.fn();
|
||||
ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed
|
||||
ToolRegistryMock.prototype.getAllToolNames = vi.fn(() => []);
|
||||
ToolRegistryMock.prototype.getTool = vi.fn();
|
||||
ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []);
|
||||
return { ToolRegistry: ToolRegistryMock };
|
||||
|
||||
@@ -46,6 +46,7 @@ import { ExitPlanModeTool } from '../tools/exitPlanMode.js';
|
||||
import { GlobTool } from '../tools/glob.js';
|
||||
import { GrepTool } from '../tools/grep.js';
|
||||
import { LSTool } from '../tools/ls.js';
|
||||
import type { SendSdkMcpMessage } from '../tools/mcp-client.js';
|
||||
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
|
||||
import { ReadFileTool } from '../tools/read-file.js';
|
||||
import { ReadManyFilesTool } from '../tools/read-many-files.js';
|
||||
@@ -239,9 +240,18 @@ export class MCPServerConfig {
|
||||
readonly targetAudience?: string,
|
||||
/* targetServiceAccount format: <service-account-name>@<project-num>.iam.gserviceaccount.com */
|
||||
readonly targetServiceAccount?: string,
|
||||
// SDK MCP server type - 'sdk' indicates server runs in SDK process
|
||||
readonly type?: 'sdk',
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an MCP server config represents an SDK server
|
||||
*/
|
||||
export function isSdkMcpServerConfig(config: MCPServerConfig): boolean {
|
||||
return config.type === 'sdk';
|
||||
}
|
||||
|
||||
export enum AuthProviderType {
|
||||
DYNAMIC_DISCOVERY = 'dynamic_discovery',
|
||||
GOOGLE_CREDENTIALS = 'google_credentials',
|
||||
@@ -339,6 +349,7 @@ export interface ConfigParameters {
|
||||
skipStartupContext?: boolean;
|
||||
sdkMode?: boolean;
|
||||
sessionSubagents?: SubagentConfig[];
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
function normalizeConfigOutputFormat(
|
||||
@@ -360,6 +371,17 @@ function normalizeConfigOutputFormat(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for Config.initialize()
|
||||
*/
|
||||
export interface ConfigInitializeOptions {
|
||||
/**
|
||||
* Callback for sending MCP messages to SDK servers via control plane.
|
||||
* Required for SDK MCP server support in SDK mode.
|
||||
*/
|
||||
sendSdkMcpMessage?: SendSdkMcpMessage;
|
||||
}
|
||||
|
||||
export class Config {
|
||||
private sessionId: string;
|
||||
private sessionData?: ResumedSessionData;
|
||||
@@ -464,6 +486,7 @@ export class Config {
|
||||
private readonly enableToolOutputTruncation: boolean;
|
||||
private readonly eventEmitter?: EventEmitter;
|
||||
private readonly useSmartEdit: boolean;
|
||||
private readonly channel: string | undefined;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this.sessionId = params.sessionId ?? randomUUID();
|
||||
@@ -577,6 +600,7 @@ export class Config {
|
||||
this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true;
|
||||
this.useSmartEdit = params.useSmartEdit ?? false;
|
||||
this.extensionManagement = params.extensionManagement ?? true;
|
||||
this.channel = params.channel;
|
||||
this.storage = new Storage(this.targetDir);
|
||||
this.vlmSwitchMode = params.vlmSwitchMode;
|
||||
this.inputFormat = params.inputFormat ?? InputFormat.TEXT;
|
||||
@@ -599,8 +623,9 @@ export class Config {
|
||||
|
||||
/**
|
||||
* Must only be called once, throws if called again.
|
||||
* @param options Optional initialization options including sendSdkMcpMessage callback
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
async initialize(options?: ConfigInitializeOptions): Promise<void> {
|
||||
if (this.initialized) {
|
||||
throw Error('Config was already initialized');
|
||||
}
|
||||
@@ -619,7 +644,9 @@ export class Config {
|
||||
this.subagentManager.loadSessionSubagents(this.sessionSubagents);
|
||||
}
|
||||
|
||||
this.toolRegistry = await this.createToolRegistry();
|
||||
this.toolRegistry = await this.createToolRegistry(
|
||||
options?.sendSdkMcpMessage,
|
||||
);
|
||||
|
||||
await this.geminiClient.initialize();
|
||||
|
||||
@@ -1120,6 +1147,10 @@ export class Config {
|
||||
return this.cliVersion;
|
||||
}
|
||||
|
||||
getChannel(): string | undefined {
|
||||
return this.channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current FileSystemService
|
||||
*/
|
||||
@@ -1261,8 +1292,14 @@ export class Config {
|
||||
return this.subagentManager;
|
||||
}
|
||||
|
||||
async createToolRegistry(): Promise<ToolRegistry> {
|
||||
const registry = new ToolRegistry(this, this.eventEmitter);
|
||||
async createToolRegistry(
|
||||
sendSdkMcpMessage?: SendSdkMcpMessage,
|
||||
): Promise<ToolRegistry> {
|
||||
const registry = new ToolRegistry(
|
||||
this,
|
||||
this.eventEmitter,
|
||||
sendSdkMcpMessage,
|
||||
);
|
||||
|
||||
const coreToolsConfig = this.getCoreTools();
|
||||
const excludeToolsConfig = this.getExcludeTools();
|
||||
@@ -1347,6 +1384,7 @@ export class Config {
|
||||
}
|
||||
|
||||
await registry.discoverAllTools();
|
||||
console.debug('ToolRegistry created', registry.getAllToolNames());
|
||||
return registry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { OpenAIContentConverter } from './converter.js';
|
||||
import type { StreamingToolCallParser } from './streamingToolCallParser.js';
|
||||
import type { GenerateContentParameters, Content } from '@google/genai';
|
||||
import {
|
||||
Type,
|
||||
type GenerateContentParameters,
|
||||
type Content,
|
||||
type Tool,
|
||||
type CallableTool,
|
||||
} from '@google/genai';
|
||||
import type OpenAI from 'openai';
|
||||
|
||||
describe('OpenAIContentConverter', () => {
|
||||
@@ -202,4 +208,338 @@ describe('OpenAIContentConverter', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertGeminiToolsToOpenAI', () => {
|
||||
it('should convert Gemini tools with parameters field', async () => {
|
||||
const geminiTools = [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'get_weather',
|
||||
description: 'Get weather for a location',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
location: { type: Type.STRING },
|
||||
},
|
||||
required: ['location'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as Tool[];
|
||||
|
||||
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_weather',
|
||||
description: 'Get weather for a location',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: { type: 'string' },
|
||||
},
|
||||
required: ['location'],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert MCP tools with parametersJsonSchema field', async () => {
|
||||
// MCP tools use parametersJsonSchema which contains plain JSON schema (not Gemini types)
|
||||
const mcpTools = [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'read_file',
|
||||
description: 'Read a file from disk',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as Tool[];
|
||||
|
||||
const result = await converter.convertGeminiToolsToOpenAI(mcpTools);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_file',
|
||||
description: 'Read a file from disk',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle CallableTool by resolving tool function', async () => {
|
||||
const callableTools = [
|
||||
{
|
||||
tool: async () => ({
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'dynamic_tool',
|
||||
description: 'A dynamically resolved tool',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
] as CallableTool[];
|
||||
|
||||
const result = await converter.convertGeminiToolsToOpenAI(callableTools);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].function.name).toBe('dynamic_tool');
|
||||
});
|
||||
|
||||
it('should skip functions without name or description', async () => {
|
||||
const geminiTools = [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'valid_tool',
|
||||
description: 'A valid tool',
|
||||
},
|
||||
{
|
||||
name: 'missing_description',
|
||||
// no description
|
||||
},
|
||||
{
|
||||
// no name
|
||||
description: 'Missing name',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as Tool[];
|
||||
|
||||
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].function.name).toBe('valid_tool');
|
||||
});
|
||||
|
||||
it('should handle tools without functionDeclarations', async () => {
|
||||
const emptyTools: Tool[] = [
|
||||
{} as Tool,
|
||||
{ functionDeclarations: [] },
|
||||
];
|
||||
|
||||
const result = await converter.convertGeminiToolsToOpenAI(emptyTools);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle functions without parameters', async () => {
|
||||
const geminiTools: Tool[] = [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'no_params_tool',
|
||||
description: 'A tool without parameters',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].function.parameters).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not mutate original parametersJsonSchema', async () => {
|
||||
const originalSchema = {
|
||||
type: 'object',
|
||||
properties: { foo: { type: 'string' } },
|
||||
};
|
||||
const mcpTools: Tool[] = [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'test_tool',
|
||||
description: 'Test tool',
|
||||
parametersJsonSchema: originalSchema,
|
||||
},
|
||||
],
|
||||
} as Tool,
|
||||
];
|
||||
|
||||
const result = await converter.convertGeminiToolsToOpenAI(mcpTools);
|
||||
|
||||
// Verify the result is a copy, not the same reference
|
||||
expect(result[0].function.parameters).not.toBe(originalSchema);
|
||||
expect(result[0].function.parameters).toEqual(originalSchema);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertGeminiToolParametersToOpenAI', () => {
|
||||
it('should convert type names to lowercase', () => {
|
||||
const params = {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
count: { type: 'INTEGER' },
|
||||
amount: { type: 'NUMBER' },
|
||||
name: { type: 'STRING' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convertGeminiToolParametersToOpenAI(params);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'integer' },
|
||||
amount: { type: 'number' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert string numeric constraints to numbers', () => {
|
||||
const params = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: {
|
||||
type: 'number',
|
||||
minimum: '0',
|
||||
maximum: '100',
|
||||
multipleOf: '0.5',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convertGeminiToolParametersToOpenAI(params);
|
||||
const properties = result?.['properties'] as Record<string, unknown>;
|
||||
|
||||
expect(properties?.['value']).toEqual({
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
multipleOf: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert string length constraints to integers', () => {
|
||||
const params = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: {
|
||||
type: 'string',
|
||||
minLength: '1',
|
||||
maxLength: '100',
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
minItems: '0',
|
||||
maxItems: '10',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convertGeminiToolParametersToOpenAI(params);
|
||||
const properties = result?.['properties'] as Record<string, unknown>;
|
||||
|
||||
expect(properties?.['text']).toEqual({
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 100,
|
||||
});
|
||||
expect(properties?.['items']).toEqual({
|
||||
type: 'array',
|
||||
minItems: 0,
|
||||
maxItems: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested objects', () => {
|
||||
const params = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nested: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
deep: {
|
||||
type: 'INTEGER',
|
||||
minimum: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convertGeminiToolParametersToOpenAI(params);
|
||||
const properties = result?.['properties'] as Record<string, unknown>;
|
||||
const nested = properties?.['nested'] as Record<string, unknown>;
|
||||
const nestedProperties = nested?.['properties'] as Record<string, unknown>;
|
||||
|
||||
expect(nestedProperties?.['deep']).toEqual({
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
const params = {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'INTEGER',
|
||||
},
|
||||
};
|
||||
|
||||
const result = converter.convertGeminiToolParametersToOpenAI(params);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'integer',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for null or non-object input', () => {
|
||||
expect(
|
||||
converter.convertGeminiToolParametersToOpenAI(
|
||||
null as unknown as Record<string, unknown>,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
converter.convertGeminiToolParametersToOpenAI(
|
||||
undefined as unknown as Record<string, unknown>,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not mutate the original parameters', () => {
|
||||
const original = {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
count: { type: 'INTEGER' },
|
||||
},
|
||||
};
|
||||
const originalCopy = JSON.parse(JSON.stringify(original));
|
||||
|
||||
converter.convertGeminiToolParametersToOpenAI(original);
|
||||
|
||||
expect(original).toEqual(originalCopy);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,13 +193,11 @@ export class OpenAIContentConverter {
|
||||
// Handle both Gemini tools (parameters) and MCP tools (parametersJsonSchema)
|
||||
if (func.parametersJsonSchema) {
|
||||
// MCP tool format - use parametersJsonSchema directly
|
||||
if (func.parametersJsonSchema) {
|
||||
// Create a shallow copy to avoid mutating the original object
|
||||
const paramsCopy = {
|
||||
...(func.parametersJsonSchema as Record<string, unknown>),
|
||||
};
|
||||
parameters = paramsCopy;
|
||||
}
|
||||
// Create a shallow copy to avoid mutating the original object
|
||||
const paramsCopy = {
|
||||
...(func.parametersJsonSchema as Record<string, unknown>),
|
||||
};
|
||||
parameters = paramsCopy;
|
||||
} else if (func.parameters) {
|
||||
// Gemini tool format - convert parameters to OpenAI format
|
||||
parameters = this.convertGeminiToolParametersToOpenAI(
|
||||
|
||||
@@ -130,10 +130,13 @@ export class DashScopeOpenAICompatibleProvider
|
||||
}
|
||||
|
||||
buildMetadata(userPromptId: string): DashScopeRequestMetadata {
|
||||
const channel = this.cliConfig.getChannel?.();
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
sessionId: this.cliConfig.getSessionId?.(),
|
||||
promptId: userPromptId,
|
||||
...(channel ? { channel } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,5 +28,6 @@ export type DashScopeRequestMetadata = {
|
||||
metadata: {
|
||||
sessionId?: string;
|
||||
promptId: string;
|
||||
channel?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -102,7 +102,9 @@ export * from './tools/shell.js';
|
||||
export * from './tools/web-search/index.js';
|
||||
export * from './tools/read-many-files.js';
|
||||
export * from './tools/mcp-client.js';
|
||||
export * from './tools/mcp-client-manager.js';
|
||||
export * from './tools/mcp-tool.js';
|
||||
export * from './tools/sdk-control-client-transport.js';
|
||||
export * from './tools/task.js';
|
||||
export * from './tools/todoWrite.js';
|
||||
export * from './tools/exitPlanMode.js';
|
||||
|
||||
@@ -761,7 +761,6 @@ describe('getQwenOAuthClient', () => {
|
||||
});
|
||||
|
||||
it('should load cached credentials if available', async () => {
|
||||
const fs = await import('node:fs');
|
||||
const mockCredentials = {
|
||||
access_token: 'cached-token',
|
||||
refresh_token: 'cached-refresh',
|
||||
@@ -769,10 +768,6 @@ describe('getQwenOAuthClient', () => {
|
||||
expiry_date: Date.now() + 3600000,
|
||||
};
|
||||
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(
|
||||
JSON.stringify(mockCredentials),
|
||||
);
|
||||
|
||||
// Mock SharedTokenManager to use cached credentials
|
||||
const mockTokenManager = {
|
||||
getValidCredentials: vi.fn().mockResolvedValue(mockCredentials),
|
||||
@@ -792,18 +787,6 @@ describe('getQwenOAuthClient', () => {
|
||||
});
|
||||
|
||||
it('should handle cached credentials refresh failure', async () => {
|
||||
const fs = await import('node:fs');
|
||||
const mockCredentials = {
|
||||
access_token: 'cached-token',
|
||||
refresh_token: 'expired-refresh',
|
||||
token_type: 'Bearer',
|
||||
expiry_date: Date.now() + 3600000, // Valid expiry time so loadCachedQwenCredentials returns true
|
||||
};
|
||||
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(
|
||||
JSON.stringify(mockCredentials),
|
||||
);
|
||||
|
||||
// Mock SharedTokenManager to fail with a specific error
|
||||
const mockTokenManager = {
|
||||
getValidCredentials: vi
|
||||
@@ -833,6 +816,35 @@ describe('getQwenOAuthClient', () => {
|
||||
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
|
||||
it('should not start device flow when requireCachedCredentials is true', async () => {
|
||||
// Make SharedTokenManager fail so we hit the fallback path
|
||||
const mockTokenManager = {
|
||||
getValidCredentials: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('No credentials')),
|
||||
};
|
||||
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager);
|
||||
|
||||
// If requireCachedCredentials is honored, device-flow network requests should not start
|
||||
vi.mocked(global.fetch).mockResolvedValue({ ok: true } as Response);
|
||||
|
||||
await expect(
|
||||
import('./qwenOAuth2.js').then((module) =>
|
||||
module.getQwenOAuthClient(mockConfig, {
|
||||
requireCachedCredentials: true,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
|
||||
);
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
});
|
||||
|
||||
describe('CredentialsClearRequiredError', () => {
|
||||
@@ -1574,178 +1586,6 @@ describe('Credential Caching Functions', () => {
|
||||
expect(updatedCredentials.access_token).toBe('new-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCachedQwenCredentials', () => {
|
||||
it('should load and validate cached credentials successfully', async () => {
|
||||
const { promises: fs } = await import('node:fs');
|
||||
const mockCredentials = {
|
||||
access_token: 'cached-token',
|
||||
refresh_token: 'cached-refresh',
|
||||
token_type: 'Bearer',
|
||||
expiry_date: Date.now() + 3600000,
|
||||
};
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials));
|
||||
|
||||
// Test through getQwenOAuthClient which calls loadCachedQwenCredentials
|
||||
const mockConfig = {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
// Make SharedTokenManager fail to test the fallback
|
||||
const mockTokenManager = {
|
||||
getValidCredentials: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('No cached creds')),
|
||||
};
|
||||
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockTokenManager);
|
||||
|
||||
// Mock successful auth flow after cache load fails
|
||||
const mockAuthResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
device_code: 'test-device-code',
|
||||
user_code: 'TEST123',
|
||||
verification_uri: 'https://chat.qwen.ai/device',
|
||||
verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123',
|
||||
expires_in: 1800,
|
||||
}),
|
||||
};
|
||||
|
||||
const mockTokenResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
scope: 'openid profile email model.completion',
|
||||
}),
|
||||
};
|
||||
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mockAuthResponse as Response)
|
||||
.mockResolvedValue(mockTokenResponse as Response);
|
||||
|
||||
try {
|
||||
await import('./qwenOAuth2.js').then((module) =>
|
||||
module.getQwenOAuthClient(mockConfig),
|
||||
);
|
||||
} catch {
|
||||
// Expected to fail in test environment
|
||||
}
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalled();
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
|
||||
it('should handle invalid cached credentials gracefully', async () => {
|
||||
const { promises: fs } = await import('node:fs');
|
||||
|
||||
// Mock file read to return invalid JSON
|
||||
vi.mocked(fs.readFile).mockResolvedValue('invalid-json');
|
||||
|
||||
const mockConfig = {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockTokenManager = {
|
||||
getValidCredentials: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('No cached creds')),
|
||||
};
|
||||
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockTokenManager);
|
||||
|
||||
// Mock auth flow
|
||||
const mockAuthResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
device_code: 'test-device-code',
|
||||
user_code: 'TEST123',
|
||||
verification_uri: 'https://chat.qwen.ai/device',
|
||||
verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123',
|
||||
expires_in: 1800,
|
||||
}),
|
||||
};
|
||||
|
||||
const mockTokenResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'new-token',
|
||||
refresh_token: 'new-refresh',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
};
|
||||
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mockAuthResponse as Response)
|
||||
.mockResolvedValue(mockTokenResponse as Response);
|
||||
|
||||
try {
|
||||
await import('./qwenOAuth2.js').then((module) =>
|
||||
module.getQwenOAuthClient(mockConfig),
|
||||
);
|
||||
} catch {
|
||||
// Expected to fail in test environment
|
||||
}
|
||||
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
|
||||
it('should handle file access errors', async () => {
|
||||
const { promises: fs } = await import('node:fs');
|
||||
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found'));
|
||||
|
||||
const mockConfig = {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockTokenManager = {
|
||||
getValidCredentials: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('No cached creds')),
|
||||
};
|
||||
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockTokenManager);
|
||||
|
||||
// Mock device flow to fail quickly
|
||||
const mockAuthResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
error: 'invalid_request',
|
||||
error_description: 'Invalid request parameters',
|
||||
}),
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(mockAuthResponse as Response);
|
||||
|
||||
// Should proceed to device flow when cache loading fails
|
||||
try {
|
||||
await import('./qwenOAuth2.js').then((module) =>
|
||||
module.getQwenOAuthClient(mockConfig),
|
||||
);
|
||||
} catch {
|
||||
// Expected to fail in test environment
|
||||
}
|
||||
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enhanced Error Handling and Edge Cases', () => {
|
||||
|
||||
@@ -514,26 +514,14 @@ export async function getQwenOAuthClient(
|
||||
}
|
||||
}
|
||||
|
||||
// If shared manager fails, check if we have cached credentials for device flow
|
||||
if (await loadCachedQwenCredentials(client)) {
|
||||
// We have cached credentials but they might be expired
|
||||
// Try device flow instead of forcing refresh
|
||||
const result = await authWithQwenDeviceFlow(client, config);
|
||||
if (!result.success) {
|
||||
// Use detailed error message if available, otherwise use default
|
||||
const errorMessage =
|
||||
result.message || 'Qwen OAuth authentication failed';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
if (options?.requireCachedCredentials) {
|
||||
throw new Error(
|
||||
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
|
||||
);
|
||||
}
|
||||
|
||||
// If we couldn't obtain valid credentials via SharedTokenManager, fall back to
|
||||
// interactive device authorization (unless explicitly forbidden above).
|
||||
const result = await authWithQwenDeviceFlow(client, config);
|
||||
if (!result.success) {
|
||||
// Only emit timeout event if the failure reason is actually timeout
|
||||
@@ -689,6 +677,19 @@ async function authWithQwenDeviceFlow(
|
||||
// Cache the new tokens
|
||||
await cacheQwenCredentials(credentials);
|
||||
|
||||
// IMPORTANT:
|
||||
// SharedTokenManager maintains an in-memory cache and throttles file checks.
|
||||
// If we only write the creds file here, a subsequent `getQwenOAuthClient()`
|
||||
// call in the same process (within the throttle window) may not re-read the
|
||||
// updated file and could incorrectly re-trigger device auth.
|
||||
// Clearing the cache forces the next call to reload from disk.
|
||||
try {
|
||||
SharedTokenManager.getInstance().clearCache();
|
||||
} catch {
|
||||
// In unit tests we sometimes mock SharedTokenManager.getInstance() with a
|
||||
// minimal stub; cache invalidation is best-effort and should not break auth.
|
||||
}
|
||||
|
||||
// Emit auth progress success event
|
||||
qwenOAuth2Events.emit(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
@@ -847,27 +848,6 @@ async function authWithQwenDeviceFlow(
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCachedQwenCredentials(
|
||||
client: QwenOAuth2Client,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const keyFile = getQwenCachedCredentialPath();
|
||||
const creds = await fs.readFile(keyFile, 'utf-8');
|
||||
const credentials = JSON.parse(creds) as QwenCredentials;
|
||||
client.setCredentials(credentials);
|
||||
|
||||
// Verify that the credentials are still valid
|
||||
const { token } = await client.getAccessToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheQwenCredentials(credentials: QwenCredentials) {
|
||||
const filePath = getQwenCachedCredentialPath();
|
||||
try {
|
||||
@@ -913,6 +893,14 @@ export async function clearQwenCredentials(): Promise<void> {
|
||||
}
|
||||
// Log other errors but don't throw - clearing credentials should be non-critical
|
||||
console.warn('Warning: Failed to clear cached Qwen credentials:', error);
|
||||
} finally {
|
||||
// Also clear SharedTokenManager in-memory cache to prevent stale credentials
|
||||
// from being reused within the same process after the file is removed.
|
||||
try {
|
||||
SharedTokenManager.getInstance().clearCache();
|
||||
} catch {
|
||||
// Best-effort; don't fail credential clearing if SharedTokenManager is mocked.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export type {
|
||||
SubAgentStartEvent,
|
||||
SubAgentRoundEvent,
|
||||
SubAgentStreamTextEvent,
|
||||
SubAgentUsageEvent,
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentFinishEvent,
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
ToolConfirmationOutcome,
|
||||
ToolResultDisplay,
|
||||
} from '../tools/tools.js';
|
||||
import type { Part } from '@google/genai';
|
||||
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
|
||||
export type SubAgentEvent =
|
||||
| 'start'
|
||||
@@ -20,6 +20,7 @@ export type SubAgentEvent =
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'tool_waiting_approval'
|
||||
| 'usage_metadata'
|
||||
| 'finish'
|
||||
| 'error';
|
||||
|
||||
@@ -31,6 +32,7 @@ export enum SubAgentEventType {
|
||||
TOOL_CALL = 'tool_call',
|
||||
TOOL_RESULT = 'tool_result',
|
||||
TOOL_WAITING_APPROVAL = 'tool_waiting_approval',
|
||||
USAGE_METADATA = 'usage_metadata',
|
||||
FINISH = 'finish',
|
||||
ERROR = 'error',
|
||||
}
|
||||
@@ -57,6 +59,14 @@ export interface SubAgentStreamTextEvent {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SubAgentUsageEvent {
|
||||
subagentId: string;
|
||||
round: number;
|
||||
usage: GenerateContentResponseUsageMetadata;
|
||||
durationMs?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SubAgentToolCallEvent {
|
||||
subagentId: string;
|
||||
round: number;
|
||||
|
||||
@@ -50,6 +50,15 @@ describe('SubagentStatistics', () => {
|
||||
expect(summary.outputTokens).toBe(600);
|
||||
expect(summary.totalTokens).toBe(1800);
|
||||
});
|
||||
|
||||
it('should track thought and cached tokens', () => {
|
||||
stats.recordTokens(100, 50, 10, 5);
|
||||
|
||||
const summary = stats.getSummary();
|
||||
expect(summary.thoughtTokens).toBe(10);
|
||||
expect(summary.cachedTokens).toBe(5);
|
||||
expect(summary.totalTokens).toBe(165); // 100 + 50 + 10 + 5
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool usage statistics', () => {
|
||||
@@ -93,14 +102,14 @@ describe('SubagentStatistics', () => {
|
||||
stats.start(baseTime);
|
||||
stats.setRounds(2);
|
||||
stats.recordToolCall('file_read', true, 100);
|
||||
stats.recordTokens(1000, 500);
|
||||
stats.recordTokens(1000, 500, 20, 10);
|
||||
|
||||
const result = stats.formatCompact('Test task', baseTime + 5000);
|
||||
|
||||
expect(result).toContain('📋 Task Completed: Test task');
|
||||
expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success');
|
||||
expect(result).toContain('⏱️ Duration: 5.0s | 🔁 Rounds: 2');
|
||||
expect(result).toContain('🔢 Tokens: 1,500 (in 1000, out 500)');
|
||||
expect(result).toContain('🔢 Tokens: 1,530 (in 1000, out 500)');
|
||||
});
|
||||
|
||||
it('should handle zero tool calls', () => {
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface SubagentStatsSummary {
|
||||
successRate: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
thoughtTokens: number;
|
||||
cachedTokens: number;
|
||||
totalTokens: number;
|
||||
estimatedCost: number;
|
||||
toolUsage: ToolUsageStats[];
|
||||
@@ -36,6 +38,8 @@ export class SubagentStatistics {
|
||||
private failedToolCalls = 0;
|
||||
private inputTokens = 0;
|
||||
private outputTokens = 0;
|
||||
private thoughtTokens = 0;
|
||||
private cachedTokens = 0;
|
||||
private toolUsage = new Map<string, ToolUsageStats>();
|
||||
|
||||
start(now = Date.now()) {
|
||||
@@ -74,9 +78,16 @@ export class SubagentStatistics {
|
||||
this.toolUsage.set(name, tu);
|
||||
}
|
||||
|
||||
recordTokens(input: number, output: number) {
|
||||
recordTokens(
|
||||
input: number,
|
||||
output: number,
|
||||
thought: number = 0,
|
||||
cached: number = 0,
|
||||
) {
|
||||
this.inputTokens += Math.max(0, input || 0);
|
||||
this.outputTokens += Math.max(0, output || 0);
|
||||
this.thoughtTokens += Math.max(0, thought || 0);
|
||||
this.cachedTokens += Math.max(0, cached || 0);
|
||||
}
|
||||
|
||||
getSummary(now = Date.now()): SubagentStatsSummary {
|
||||
@@ -86,7 +97,11 @@ export class SubagentStatistics {
|
||||
totalToolCalls > 0
|
||||
? (this.successfulToolCalls / totalToolCalls) * 100
|
||||
: 0;
|
||||
const totalTokens = this.inputTokens + this.outputTokens;
|
||||
const totalTokens =
|
||||
this.inputTokens +
|
||||
this.outputTokens +
|
||||
this.thoughtTokens +
|
||||
this.cachedTokens;
|
||||
const estimatedCost = this.inputTokens * 3e-5 + this.outputTokens * 6e-5;
|
||||
return {
|
||||
rounds: this.rounds,
|
||||
@@ -97,6 +112,8 @@ export class SubagentStatistics {
|
||||
successRate,
|
||||
inputTokens: this.inputTokens,
|
||||
outputTokens: this.outputTokens,
|
||||
thoughtTokens: this.thoughtTokens,
|
||||
cachedTokens: this.cachedTokens,
|
||||
totalTokens,
|
||||
estimatedCost,
|
||||
toolUsage: Array.from(this.toolUsage.values()),
|
||||
@@ -116,8 +133,12 @@ export class SubagentStatistics {
|
||||
`⏱️ Duration: ${this.fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`,
|
||||
];
|
||||
if (typeof stats.totalTokens === 'number') {
|
||||
const parts = [
|
||||
`in ${stats.inputTokens ?? 0}`,
|
||||
`out ${stats.outputTokens ?? 0}`,
|
||||
];
|
||||
lines.push(
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${stats.inputTokens || stats.outputTokens ? ` (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})` : ''}`,
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${parts.length ? ` (${parts.join(', ')})` : ''}`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
@@ -152,8 +173,12 @@ export class SubagentStatistics {
|
||||
`🔧 Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`,
|
||||
);
|
||||
if (typeof stats.totalTokens === 'number') {
|
||||
const parts = [
|
||||
`in ${stats.inputTokens ?? 0}`,
|
||||
`out ${stats.outputTokens ?? 0}`,
|
||||
];
|
||||
lines.push(
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})`,
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (${parts.join(', ')})`,
|
||||
);
|
||||
}
|
||||
if (stats.toolUsage && stats.toolUsage.length) {
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentStreamTextEvent,
|
||||
SubAgentErrorEvent,
|
||||
SubAgentUsageEvent,
|
||||
} from './subagent-events.js';
|
||||
import {
|
||||
type SubAgentEventEmitter,
|
||||
@@ -369,6 +370,7 @@ export class SubAgentScope {
|
||||
},
|
||||
};
|
||||
|
||||
const roundStreamStart = Date.now();
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
this.modelConfig.model ||
|
||||
this.runtimeContext.getModel() ||
|
||||
@@ -439,10 +441,19 @@ export class SubAgentScope {
|
||||
if (lastUsage) {
|
||||
const inTok = Number(lastUsage.promptTokenCount || 0);
|
||||
const outTok = Number(lastUsage.candidatesTokenCount || 0);
|
||||
if (isFinite(inTok) || isFinite(outTok)) {
|
||||
const thoughtTok = Number(lastUsage.thoughtsTokenCount || 0);
|
||||
const cachedTok = Number(lastUsage.cachedContentTokenCount || 0);
|
||||
if (
|
||||
isFinite(inTok) ||
|
||||
isFinite(outTok) ||
|
||||
isFinite(thoughtTok) ||
|
||||
isFinite(cachedTok)
|
||||
) {
|
||||
this.stats.recordTokens(
|
||||
isFinite(inTok) ? inTok : 0,
|
||||
isFinite(outTok) ? outTok : 0,
|
||||
isFinite(thoughtTok) ? thoughtTok : 0,
|
||||
isFinite(cachedTok) ? cachedTok : 0,
|
||||
);
|
||||
// mirror legacy fields for compatibility
|
||||
this.executionStats.inputTokens =
|
||||
@@ -453,11 +464,20 @@ export class SubAgentScope {
|
||||
(isFinite(outTok) ? outTok : 0);
|
||||
this.executionStats.totalTokens =
|
||||
(this.executionStats.inputTokens || 0) +
|
||||
(this.executionStats.outputTokens || 0);
|
||||
(this.executionStats.outputTokens || 0) +
|
||||
(isFinite(thoughtTok) ? thoughtTok : 0) +
|
||||
(isFinite(cachedTok) ? cachedTok : 0);
|
||||
this.executionStats.estimatedCost =
|
||||
(this.executionStats.inputTokens || 0) * 3e-5 +
|
||||
(this.executionStats.outputTokens || 0) * 6e-5;
|
||||
}
|
||||
this.eventEmitter?.emit(SubAgentEventType.USAGE_METADATA, {
|
||||
subagentId: this.subagentId,
|
||||
round: turnCounter,
|
||||
usage: lastUsage,
|
||||
durationMs: Date.now() - roundStreamStart,
|
||||
timestamp: Date.now(),
|
||||
} as SubAgentUsageEvent);
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
|
||||
@@ -249,6 +249,9 @@ export class QwenLogger {
|
||||
authType === AuthType.USE_OPENAI
|
||||
? this.config?.getContentGeneratorConfig().baseUrl || ''
|
||||
: '',
|
||||
...(this.config?.getChannel?.()
|
||||
? { channel: this.config.getChannel() }
|
||||
: {}),
|
||||
},
|
||||
_v: `qwen-code@${version}`,
|
||||
} as RumPayload;
|
||||
|
||||
@@ -23,6 +23,12 @@ export type UiEvent =
|
||||
| (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR })
|
||||
| (ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL });
|
||||
|
||||
export {
|
||||
EVENT_API_ERROR,
|
||||
EVENT_API_RESPONSE,
|
||||
EVENT_TOOL_CALL,
|
||||
} from './constants.js';
|
||||
|
||||
export interface ToolCallStats {
|
||||
count: number;
|
||||
success: number;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Config, MCPServerConfig } from '../config/config.js';
|
||||
import { isSdkMcpServerConfig } from '../config/config.js';
|
||||
import type { ToolRegistry } from './tool-registry.js';
|
||||
import type { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import {
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
MCPDiscoveryState,
|
||||
populateMcpServerCommand,
|
||||
} from './mcp-client.js';
|
||||
import type { SendSdkMcpMessage } from './mcp-client.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import type { EventEmitter } from 'node:events';
|
||||
import type { WorkspaceContext } from '../utils/workspaceContext.js';
|
||||
@@ -31,6 +33,7 @@ export class McpClientManager {
|
||||
private readonly workspaceContext: WorkspaceContext;
|
||||
private discoveryState: MCPDiscoveryState = MCPDiscoveryState.NOT_STARTED;
|
||||
private readonly eventEmitter?: EventEmitter;
|
||||
private readonly sendSdkMcpMessage?: SendSdkMcpMessage;
|
||||
|
||||
constructor(
|
||||
mcpServers: Record<string, MCPServerConfig>,
|
||||
@@ -40,6 +43,7 @@ export class McpClientManager {
|
||||
debugMode: boolean,
|
||||
workspaceContext: WorkspaceContext,
|
||||
eventEmitter?: EventEmitter,
|
||||
sendSdkMcpMessage?: SendSdkMcpMessage,
|
||||
) {
|
||||
this.mcpServers = mcpServers;
|
||||
this.mcpServerCommand = mcpServerCommand;
|
||||
@@ -48,6 +52,7 @@ export class McpClientManager {
|
||||
this.debugMode = debugMode;
|
||||
this.workspaceContext = workspaceContext;
|
||||
this.eventEmitter = eventEmitter;
|
||||
this.sendSdkMcpMessage = sendSdkMcpMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,6 +76,11 @@ export class McpClientManager {
|
||||
this.eventEmitter?.emit('mcp-client-update', this.clients);
|
||||
const discoveryPromises = Object.entries(servers).map(
|
||||
async ([name, config]) => {
|
||||
// For SDK MCP servers, pass the sendSdkMcpMessage callback
|
||||
const sdkCallback = isSdkMcpServerConfig(config)
|
||||
? this.sendSdkMcpMessage
|
||||
: undefined;
|
||||
|
||||
const client = new McpClient(
|
||||
name,
|
||||
config,
|
||||
@@ -78,6 +88,7 @@ export class McpClientManager {
|
||||
this.promptRegistry,
|
||||
this.workspaceContext,
|
||||
this.debugMode,
|
||||
sdkCallback,
|
||||
);
|
||||
this.clients.set(name, client);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user