Compare commits

..

23 Commits

Author SHA1 Message Date
xuewenjie
dd7f9ed489 test: update glob tool test to cover case-insensitive path comparison on macOS 2025-12-08 18:15:40 +08:00
xuewenjie
5fa87e6fbb fix: handle case-insensitive path comparison on macOS in glob tool 2025-12-08 18:13:25 +08:00
xuewenjie
0a8281f2dd fix: handle case-insensitive path comparison in glob tool on Windows 2025-12-08 10:58:50 +08:00
Mingholy
b4be2c6c7f Merge pull request #1166 from QwenLM/mingholy/fix/unstable-e2e-test
test: skip unstable e2e test
2025-12-06 18:42:29 +08:00
mingholy.lmh
8b5b8d2b90 test: skip unstable e2e test 2025-12-06 18:41:19 +08:00
Mingholy
6e826b815e Merge pull request #1165 from QwenLM/mingholy/fix/sdk-timeout
fix: update timeout settings and default logging level in SDK
2025-12-06 18:18:23 +08:00
mingholy.lmh
86b166bb1d fix: adjust e2e tests via timeout option 2025-12-06 17:53:31 +08:00
mingholy.lmh
bf6abf7752 fix: update timeout settings and default logging level in SDK 2025-12-06 12:27:16 +08:00
Mingholy
ab228c682f Merge pull request #1161 from QwenLM/mingholy/fix/integration-test-scripts
test: separating integration tests for the CLI and SDK
2025-12-05 22:34:30 +08:00
mingholy.lmh
22943b888d test: clean up integration test by removing unnecessary console logs 2025-12-05 22:11:27 +08:00
mingholy.lmh
96d458fa8c chore: rename @qwen-code/sdk-typescript to @qwen-code/sdk 2025-12-05 21:47:26 +08:00
mingholy.lmh
0e9255b122 fix: integration test scripts 2025-12-05 21:27:12 +08:00
Mingholy
3ed0a34b5e Merge pull request #1147 from QwenLM/mingholy/feat/cli-sdk-stage-2
Custom tools support via SDK controlled MCP servers
2025-12-05 21:19:58 +08:00
mingholy.lmh
2949b33a4e chore: enhance integration testing for SDK and CLI 2025-12-05 21:05:36 +08:00
mingholy.lmh
c218048551 fix: prevent sending control request when query is closed 2025-12-05 18:46:51 +08:00
tanzhenxin
3e2a2255ee DeepSeek V3.2 Thinking Mode Integration (#1134) 2025-12-05 15:08:35 +08:00
mingholy.lmh
46478e5dd3 fix: try fix sandbox integration test failure 2025-12-05 13:14:55 +08:00
mingholy.lmh
64de3520b3 docs: update README to include SDK-embedded MCP server details and usage examples 2025-12-05 13:14:55 +08:00
mingholy.lmh
322ce80e2c feat: implement SDK MCP server support and enhance control request handling
- Added new `SdkMcpController` to manage communication between CLI MCP clients and SDK MCP servers.
- Introduced `createSdkMcpServer` function for creating SDK-embedded MCP servers.
- Updated configuration options to support both external and SDK MCP servers.
- Enhanced timeout settings for various SDK operations, including MCP requests.
- Refactored existing control request handling to accommodate new SDK MCP server functionality.
- Updated tests to cover new SDK MCP server features and ensure proper integration.
2025-12-05 13:14:54 +08:00
pomelo
a58d3f7aaf Merge pull request #1148 from QwenLM/feat/no-quit-confirm
Remove `/quit-confirm` flow
2025-12-05 10:56:08 +08:00
Mingholy
aacc4b43ff Merge pull request #1150 from QwenLM/mingholy/ci/skip-case
test: skip qwen-oauth test in containerized environments
2025-12-04 20:53:12 +08:00
tanzhenxin
1c45ef563d remove unused files 2025-12-04 17:41:52 +08:00
tanzhenxin
5b2f3e285c remove /quit-confirm slash command 2025-12-04 16:21:32 +08:00
90 changed files with 3472 additions and 1364 deletions

View File

@@ -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' || '' }}

View File

@@ -145,16 +145,6 @@ Slash commands provide meta-level control over the CLI itself.
- **`nodesc`** or **`nodescriptions`**:
- **Description:** Hide tool descriptions, showing only the tool names.
- **`/quit-confirm`**
- **Description:** Show a confirmation dialog before exiting Qwen Code, allowing you to choose how to handle your current session.
- **Usage:** `/quit-confirm`
- **Features:**
- **Quit immediately:** Exit without saving anything (equivalent to `/quit`)
- **Generate summary and quit:** Create a project summary using `/summary` before exiting
- **Save conversation and quit:** Save the current conversation with an auto-generated tag before exiting
- **Keyboard shortcut:** Press **Ctrl+C** twice to trigger the quit confirmation dialog
- **Note:** This command is automatically triggered when you press Ctrl+C once, providing a safety mechanism to prevent accidental exits.
- **`/quit`** (or **`/exit`**)
- **Description:** Exit Qwen Code immediately without any confirmation dialog.

View File

@@ -671,4 +671,4 @@ Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM
- **Category:** UI
- **Requires Restart:** No
- **Example:** `"enableWelcomeBack": false`
- **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command and quit confirmation dialog. See the [Welcome Back documentation](./welcome-back.md) for more details.
- **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command. See the [Welcome Back documentation](./welcome-back.md) for more details.

View File

@@ -81,14 +81,6 @@ The Welcome Back feature works seamlessly with the `/summary` command:
2. **Automatic Detection:** Next time you start Qwen Code in this project, Welcome Back will detect the summary
3. **Resume Work:** Choose to continue and the summary will be loaded as context
### Quit Confirmation
When exiting with `/quit-confirm` and choosing "Generate summary and quit":
1. A project summary is automatically created
2. Next session will trigger the Welcome Back dialog
3. You can seamlessly continue your work
## File Structure
The Welcome Back feature creates and uses:

View File

@@ -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();

View File

@@ -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);
},

View File

@@ -19,7 +19,7 @@ import {
type SDKMessage,
type ToolUseBlock,
type SDKSystemMessage,
} from '@qwen-code/sdk-typescript';
} from '@qwen-code/sdk';
import {
SDKTestHelper,
createMCPServer,

View File

@@ -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();

View File

@@ -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'> = [

View 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();
}
});
});
});

View File

@@ -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',
},
});

View File

@@ -17,7 +17,7 @@ import {
type SubagentConfig,
type ContentBlock,
type ToolUseBlock,
} from '@qwen-code/sdk-typescript';
} from '@qwen-code/sdk';
import {
SDKTestHelper,
extractText,

View File

@@ -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();

View File

@@ -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

View File

@@ -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,

View File

@@ -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"],

View File

@@ -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',
),

4
package-lock.json generated
View File

@@ -2793,7 +2793,7 @@
"resolved": "packages/test-utils",
"link": true
},
"node_modules/@qwen-code/sdk-typescript": {
"node_modules/@qwen-code/sdk": {
"resolved": "packages/sdk-typescript",
"link": true
},
@@ -16676,7 +16676,7 @@
}
},
"packages/sdk-typescript": {
"name": "@qwen-code/sdk-typescript",
"name": "@qwen-code/sdk",
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {

View File

@@ -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'",

View File

@@ -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();
}

View File

@@ -110,7 +110,6 @@ export default {
'open full Qwen Code documentation in your browser',
'Configuration not available.': 'Configuration not available.',
'change the auth method': 'change the auth method',
'Show quit confirmation dialog': 'Show quit confirmation dialog',
'Copy the last result or code snippet to clipboard':
'Copy the last result or code snippet to clipboard',
@@ -690,18 +689,6 @@ export default {
'A custom command wants to run the following shell commands:':
'A custom command wants to run the following shell commands:',
// ============================================================================
// Dialogs - Quit Confirmation
// ============================================================================
'What would you like to do before exiting?':
'What would you like to do before exiting?',
'Quit immediately (/quit)': 'Quit immediately (/quit)',
'Generate summary and quit (/summary)':
'Generate summary and quit (/summary)',
'Save conversation and quit (/chat save)':
'Save conversation and quit (/chat save)',
'Cancel (stay in application)': 'Cancel (stay in application)',
// ============================================================================
// Dialogs - Pro Quota
// ============================================================================

View File

@@ -108,7 +108,6 @@ export default {
'在浏览器中打开完整的 Qwen Code 文档',
'Configuration not available.': '配置不可用',
'change the auth method': '更改认证方法',
'Show quit confirmation dialog': '显示退出确认对话框',
'Copy the last result or code snippet to clipboard':
'将最后的结果或代码片段复制到剪贴板',
@@ -655,15 +654,6 @@ export default {
'A custom command wants to run the following shell commands:':
'自定义命令想要运行以下 shell 命令:',
// ============================================================================
// Dialogs - Quit Confirmation
// ============================================================================
'What would you like to do before exiting?': '退出前您想要做什么?',
'Quit immediately (/quit)': '立即退出 (/quit)',
'Generate summary and quit (/summary)': '生成摘要并退出 (/summary)',
'Save conversation and quit (/chat save)': '保存对话并退出 (/chat save)',
'Cancel (stay in application)': '取消(留在应用程序中)',
// ============================================================================
// Dialogs - Pro Quota
// ============================================================================

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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(

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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[];
}

View File

@@ -245,6 +245,7 @@ describe('runNonInteractive', () => {
[{ text: 'Test input' }],
expect.any(AbortSignal),
'prompt-id-1',
{ isContinuation: false },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Hello');
expect(processStdoutSpy).toHaveBeenCalledWith(' World');
@@ -293,11 +294,21 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
undefined,
);
// Verify first call has isContinuation: false
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
1,
[{ text: 'Use a tool' }],
expect.any(AbortSignal),
'prompt-id-2',
{ isContinuation: false },
);
// Verify second call (after tool execution) has isContinuation: true
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
2,
[{ text: 'Tool response' }],
expect.any(AbortSignal),
'prompt-id-2',
{ isContinuation: true },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
@@ -372,6 +383,7 @@ describe('runNonInteractive', () => {
],
expect.any(AbortSignal),
'prompt-id-3',
{ isContinuation: true },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.');
});
@@ -497,6 +509,7 @@ describe('runNonInteractive', () => {
processedParts,
expect.any(AbortSignal),
'prompt-id-7',
{ isContinuation: false },
);
// 6. Assert the final output is correct
@@ -528,6 +541,7 @@ describe('runNonInteractive', () => {
[{ text: 'Test input' }],
expect.any(AbortSignal),
'prompt-id-1',
{ isContinuation: false },
);
// JSON adapter emits array of messages, last one is result with stats
@@ -680,6 +694,7 @@ describe('runNonInteractive', () => {
[{ text: 'Empty response test' }],
expect.any(AbortSignal),
'prompt-id-empty',
{ isContinuation: false },
);
// JSON adapter emits array of messages, last one is result with stats
@@ -831,6 +846,7 @@ describe('runNonInteractive', () => {
[{ text: 'Prompt from command' }],
expect.any(AbortSignal),
'prompt-id-slash',
{ isContinuation: false },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
@@ -887,6 +903,7 @@ describe('runNonInteractive', () => {
[{ text: '/unknowncommand' }],
expect.any(AbortSignal),
'prompt-id-unknown',
{ isContinuation: false },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
@@ -1217,6 +1234,7 @@ describe('runNonInteractive', () => {
[{ text: 'Message from stream-json input' }],
expect.any(AbortSignal),
'prompt-envelope',
{ isContinuation: false },
);
});
@@ -1692,6 +1710,7 @@ describe('runNonInteractive', () => {
[{ text: 'Simple string content' }],
expect.any(AbortSignal),
'prompt-string-content',
{ isContinuation: false },
);
// UserMessage with array of text blocks
@@ -1724,6 +1743,7 @@ describe('runNonInteractive', () => {
[{ text: 'First part' }, { text: 'Second part' }],
expect.any(AbortSignal),
'prompt-blocks-content',
{ isContinuation: false },
);
});
});

View File

@@ -172,6 +172,7 @@ export async function runNonInteractive(
adapter.emitMessage(systemMessage);
}
let isFirstTurn = true;
while (true) {
turnCount++;
if (
@@ -187,7 +188,9 @@ export async function runNonInteractive(
currentMessages[0]?.parts || [],
abortController.signal,
prompt_id,
{ isContinuation: !isFirstTurn },
);
isFirstTurn = false;
// Start assistant message for this turn
if (adapter) {
@@ -207,7 +210,9 @@ export async function runNonInteractive(
}
} else {
// Text output mode - direct stdout
if (event.type === GeminiEventType.Content) {
if (event.type === GeminiEventType.Thought) {
process.stdout.write(event.value.description);
} else if (event.type === GeminiEventType.Content) {
process.stdout.write(event.value);
} else if (event.type === GeminiEventType.ToolCallRequest) {
toolCallRequests.push(event.value);

View File

@@ -71,7 +71,6 @@ vi.mock('../ui/commands/modelCommand.js', () => ({
}));
vi.mock('../ui/commands/quitCommand.js', () => ({
quitCommand: {},
quitConfirmCommand: {},
}));
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} }));

View File

@@ -28,7 +28,7 @@ import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { quitCommand, quitConfirmCommand } from '../ui/commands/quitCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
@@ -77,7 +77,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
modelCommand,
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
quitCommand,
quitConfirmCommand,
restoreCommand(this.config),
statsCommand,
summaryCommand,

View File

@@ -89,7 +89,6 @@ import { useSessionStats } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
@@ -446,8 +445,6 @@ export const AppContainer = (props: AppContainerProps) => {
const { toggleVimEnabled } = useVimMode();
const { showQuitConfirmation } = useQuitConfirmation();
const {
isSubagentCreateDialogOpen,
openSubagentCreateDialog,
@@ -493,7 +490,6 @@ export const AppContainer = (props: AppContainerProps) => {
addConfirmUpdateExtensionRequest,
openSubagentCreateDialog,
openAgentsManagerDialog,
_showQuitConfirmation: showQuitConfirmation,
}),
[
openAuthDialog,
@@ -507,7 +503,6 @@ export const AppContainer = (props: AppContainerProps) => {
openPermissionsDialog,
openApprovalModeDialog,
addConfirmUpdateExtensionRequest,
showQuitConfirmation,
openSubagentCreateDialog,
openAgentsManagerDialog,
],
@@ -520,7 +515,6 @@ export const AppContainer = (props: AppContainerProps) => {
commandContext,
shellConfirmationRequest,
confirmationRequest,
quitConfirmationRequest,
} = useSlashCommandProcessor(
config,
settings,
@@ -969,7 +963,6 @@ export const AppContainer = (props: AppContainerProps) => {
isFolderTrustDialogOpen,
showWelcomeBackDialog,
handleWelcomeBackClose,
quitConfirmationRequest,
});
const handleExit = useCallback(
@@ -983,25 +976,18 @@ export const AppContainer = (props: AppContainerProps) => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
// Exit directly without showing confirmation dialog
// Exit directly
handleSlashCommand('/quit');
return;
}
// First press: Prioritize cleanup tasks
// Special case: If quit-confirm dialog is open, Ctrl+C means "quit immediately"
if (quitConfirmationRequest) {
handleSlashCommand('/quit');
return;
}
// 1. Close other dialogs (highest priority)
/**
* For AuthDialog it is required to complete the authentication process,
* otherwise user cannot proceed to the next step.
* So a quit on AuthDialog should go with normal two press quit
* and without quit-confirm dialog.
* So a quit on AuthDialog should go with normal two press quit.
*/
if (isAuthDialogOpen) {
setPressedOnce(true);
@@ -1022,14 +1008,17 @@ export const AppContainer = (props: AppContainerProps) => {
return; // Request cancelled, end processing
}
// 3. Clear input buffer (if has content)
// 4. Clear input buffer (if has content)
if (buffer.text.length > 0) {
buffer.setText('');
return; // Input cleared, end processing
}
// All cleanup tasks completed, show quit confirmation dialog
handleSlashCommand('/quit-confirm');
// All cleanup tasks completed, set flag for double-press to quit
setPressedOnce(true);
timerRef.current = setTimeout(() => {
setPressedOnce(false);
}, CTRL_EXIT_PROMPT_DURATION_MS);
},
[
isAuthDialogOpen,
@@ -1037,7 +1026,6 @@ export const AppContainer = (props: AppContainerProps) => {
closeAnyOpenDialog,
streamingState,
cancelOngoingRequest,
quitConfirmationRequest,
buffer,
],
);
@@ -1054,8 +1042,8 @@ export const AppContainer = (props: AppContainerProps) => {
return;
}
// On first press: set flag, start timer, and call handleExit for cleanup/quit-confirm
// On second press (within 500ms): handleExit sees flag and does fast quit
// On first press: set flag, start timer, and call handleExit for cleanup
// On second press (within timeout): handleExit sees flag and does fast quit
if (!ctrlCPressedOnce) {
setCtrlCPressedOnce(true);
ctrlCTimerRef.current = setTimeout(() => {
@@ -1196,7 +1184,6 @@ export const AppContainer = (props: AppContainerProps) => {
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
!!quitConfirmationRequest ||
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
@@ -1245,7 +1232,6 @@ export const AppContainer = (props: AppContainerProps) => {
confirmationRequest,
confirmUpdateExtensionRequests,
loopDetectionConfirmationRequest,
quitConfirmationRequest,
geminiMdFileCount,
streamingState,
initError,
@@ -1337,7 +1323,6 @@ export const AppContainer = (props: AppContainerProps) => {
confirmationRequest,
confirmUpdateExtensionRequests,
loopDetectionConfirmationRequest,
quitConfirmationRequest,
geminiMdFileCount,
streamingState,
initError,

View File

@@ -8,35 +8,6 @@ import { formatDuration } from '../utils/formatters.js';
import { CommandKind, type SlashCommand } from './types.js';
import { t } from '../../i18n/index.js';
export const quitConfirmCommand: SlashCommand = {
name: 'quit-confirm',
get description() {
return t('Show quit confirmation dialog');
},
kind: CommandKind.BUILT_IN,
action: (context) => {
const now = Date.now();
const { sessionStartTime } = context.session.stats;
const wallDuration = now - sessionStartTime.getTime();
return {
type: 'quit_confirmation',
messages: [
{
type: 'user',
text: `/quit-confirm`,
id: now - 1,
},
{
type: 'quit_confirmation',
duration: formatDuration(wallDuration),
id: now,
},
],
};
},
};
export const quitCommand: SlashCommand = {
name: 'quit',
altNames: ['exit'],

View File

@@ -100,12 +100,6 @@ export interface QuitActionReturn {
messages: HistoryItem[];
}
/** The return type for a command action that requests quit confirmation. */
export interface QuitConfirmationActionReturn {
type: 'quit_confirmation';
messages: HistoryItem[];
}
/**
* The return type for a command action that results in a simple message
* being displayed to the user.
@@ -182,7 +176,6 @@ export type SlashCommandActionReturn =
| ToolActionReturn
| MessageActionReturn
| QuitActionReturn
| QuitConfirmationActionReturn
| OpenDialogActionReturn
| LoadHistoryActionReturn
| SubmitPromptActionReturn

View File

@@ -36,10 +36,6 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
import {
QuitConfirmationDialog,
QuitChoice,
} from './QuitConfirmationDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@@ -127,24 +123,6 @@ export const DialogManager = ({
/>
);
}
if (uiState.quitConfirmationRequest) {
return (
<QuitConfirmationDialog
onSelect={(choice: QuitChoice) => {
if (choice === QuitChoice.CANCEL) {
uiState.quitConfirmationRequest?.onConfirm(false, 'cancel');
} else if (choice === QuitChoice.QUIT) {
uiState.quitConfirmationRequest?.onConfirm(true, 'quit');
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
uiState.quitConfirmationRequest?.onConfirm(
true,
'summary_and_quit',
);
}
}}
/>
);
}
if (uiState.confirmationRequest) {
return (
<ConsentPrompt

View File

@@ -15,6 +15,8 @@ import { InfoMessage } from './messages/InfoMessage.js';
import { ErrorMessage } from './messages/ErrorMessage.js';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
import { GeminiThoughtMessage } from './messages/GeminiThoughtMessage.js';
import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageContent.js';
import { CompressionMessage } from './messages/CompressionMessage.js';
import { SummaryMessage } from './messages/SummaryMessage.js';
import { WarningMessage } from './messages/WarningMessage.js';
@@ -85,6 +87,26 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
terminalWidth={terminalWidth}
/>
)}
{itemForDisplay.type === 'gemini_thought' && (
<GeminiThoughtMessage
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
}
terminalWidth={terminalWidth}
/>
)}
{itemForDisplay.type === 'gemini_thought_content' && (
<GeminiThoughtMessageContent
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
}
terminalWidth={terminalWidth}
/>
)}
{itemForDisplay.type === 'info' && (
<InfoMessage text={itemForDisplay.text} />
)}
@@ -108,9 +130,6 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'quit' && (
<SessionSummaryDisplay duration={itemForDisplay.duration} />
)}
{itemForDisplay.type === 'quit_confirmation' && (
<SessionSummaryDisplay duration={itemForDisplay.duration} />
)}
{itemForDisplay.type === 'tool_group' && (
<ToolGroupMessage
toolCalls={itemForDisplay.tools}

View File

@@ -1,73 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type React from 'react';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
export enum QuitChoice {
CANCEL = 'cancel',
QUIT = 'quit',
SUMMARY_AND_QUIT = 'summary_and_quit',
}
interface QuitConfirmationDialogProps {
onSelect: (choice: QuitChoice) => void;
}
export const QuitConfirmationDialog: React.FC<QuitConfirmationDialogProps> = ({
onSelect,
}) => {
useKeypress(
(key) => {
if (key.name === 'escape') {
onSelect(QuitChoice.CANCEL);
}
},
{ isActive: true },
);
const options: Array<RadioSelectItem<QuitChoice>> = [
{
key: 'quit',
label: t('Quit immediately (/quit)'),
value: QuitChoice.QUIT,
},
{
key: 'summary-and-quit',
label: t('Generate summary and quit (/summary)'),
value: QuitChoice.SUMMARY_AND_QUIT,
},
{
key: 'cancel',
label: t('Cancel (stay in application)'),
value: QuitChoice.CANCEL,
},
];
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentYellow}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text>{t('What would you like to do before exiting?')}</Text>
</Box>
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />
</Box>
);
};

View File

@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
interface GeminiThoughtMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
}
/**
* Displays model thinking/reasoning text with a softer, dimmed style
* to visually distinguish it from regular content output.
*/
export const GeminiThoughtMessage: React.FC<GeminiThoughtMessageProps> = ({
text,
isPending,
availableTerminalHeight,
terminalWidth,
}) => {
const prefix = '✦ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row" marginBottom={1}>
<Box width={prefixWidth}>
<Text color={theme.text.secondary}>{prefix}</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
textColor={theme.text.secondary}
/>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
interface GeminiThoughtMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
}
/**
* Continuation component for thought messages, similar to GeminiMessageContent.
* Used when a thought response gets too long and needs to be split for performance.
*/
export const GeminiThoughtMessageContent: React.FC<
GeminiThoughtMessageContentProps
> = ({ text, isPending, availableTerminalHeight, terminalWidth }) => {
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
return (
<Box flexDirection="column" paddingLeft={prefixWidth} marginBottom={1}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
textColor={theme.text.secondary}
/>
</Box>
);
};

View File

@@ -12,7 +12,6 @@ import type {
ShellConfirmationRequest,
ConfirmationRequest,
LoopDetectionConfirmationRequest,
QuitConfirmationRequest,
HistoryItemWithoutId,
StreamingState,
} from '../types.js';
@@ -69,7 +68,6 @@ export interface UIState {
confirmationRequest: ConfirmationRequest | null;
confirmUpdateExtensionRequests: ConfirmationRequest[];
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
quitConfirmationRequest: QuitConfirmationRequest | null;
geminiMdFileCount: number;
streamingState: StreamingState;
initError: string | null;

View File

@@ -918,7 +918,6 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // toggleVimEnabled
vi.fn(), // setIsProcessing
vi.fn(), // setGeminiMdFileCount
vi.fn(), // _showQuitConfirmation
),
);

View File

@@ -18,7 +18,6 @@ import {
IdeClient,
} from '@qwen-code/qwen-code-core';
import { useSessionStats } from '../contexts/SessionContext.js';
import { formatDuration } from '../utils/formatters.js';
import type {
Message,
HistoryItemWithoutId,
@@ -53,7 +52,6 @@ function serializeHistoryItemForRecording(
const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'quit',
'quit-confirm',
'exit',
'clear',
'reset',
@@ -75,7 +73,6 @@ interface SlashCommandProcessorActions {
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
openSubagentCreateDialog: () => void;
openAgentsManagerDialog: () => void;
_showQuitConfirmation: () => void;
}
/**
@@ -115,10 +112,6 @@ export const useSlashCommandProcessor = (
prompt: React.ReactNode;
onConfirm: (confirmed: boolean) => void;
}>(null);
const [quitConfirmationRequest, setQuitConfirmationRequest] =
useState<null | {
onConfirm: (shouldQuit: boolean, action?: string) => void;
}>(null);
const [sessionShellAllowlist, setSessionShellAllowlist] = useState(
new Set<string>(),
@@ -174,11 +167,6 @@ export const useSlashCommandProcessor = (
type: 'quit',
duration: message.duration,
};
} else if (message.type === MessageType.QUIT_CONFIRMATION) {
historyItemContent = {
type: 'quit_confirmation',
duration: message.duration,
};
} else if (message.type === MessageType.COMPRESSION) {
historyItemContent = {
type: 'compression',
@@ -449,66 +437,6 @@ export const useSlashCommandProcessor = (
});
return { type: 'handled' };
}
case 'quit_confirmation':
// Show quit confirmation dialog instead of immediately quitting
setQuitConfirmationRequest({
onConfirm: (shouldQuit: boolean, action?: string) => {
setQuitConfirmationRequest(null);
if (!shouldQuit) {
// User cancelled the quit operation - do nothing
return;
}
if (shouldQuit) {
if (action === 'summary_and_quit') {
// Generate summary and then quit
handleSlashCommand('/summary')
.then(() => {
// Wait for user to see the summary result
setTimeout(() => {
handleSlashCommand('/quit');
}, 1200);
})
.catch((error) => {
// If summary fails, still quit but show error
addItemWithRecording(
{
type: 'error',
text: `Failed to generate summary before quit: ${
error instanceof Error
? error.message
: String(error)
}`,
},
Date.now(),
);
// Give user time to see the error message
setTimeout(() => {
handleSlashCommand('/quit');
}, 1000);
});
} else {
// Just quit immediately - trigger the actual quit action
const now = Date.now();
const { sessionStartTime } = sessionStats;
const wallDuration = now - sessionStartTime.getTime();
actions.quit([
{
type: 'user',
text: `/quit`,
id: now - 1,
},
{
type: 'quit',
duration: formatDuration(wallDuration),
id: now,
},
]);
}
}
},
});
return { type: 'handled' };
case 'quit':
actions.quit(result.messages);
@@ -692,7 +620,6 @@ export const useSlashCommandProcessor = (
setSessionShellAllowlist,
setIsProcessing,
setConfirmationRequest,
sessionStats,
],
);
@@ -703,6 +630,5 @@ export const useSlashCommandProcessor = (
commandContext,
shellConfirmationRequest,
confirmationRequest,
quitConfirmationRequest,
};
};

View File

@@ -44,11 +44,6 @@ export interface DialogCloseOptions {
// Welcome back dialog
showWelcomeBackDialog: boolean;
handleWelcomeBackClose: () => void;
// Quit confirmation dialog
quitConfirmationRequest: {
onConfirm: (shouldQuit: boolean, action?: string) => void;
} | null;
}
/**
@@ -96,9 +91,6 @@ export function useDialogClose(options: DialogCloseOptions) {
return true;
}
// Note: quitConfirmationRequest is NOT handled here anymore
// It's handled specially in handleExit - ctrl+c in quit-confirm should exit immediately
// No dialog was open
return false;
}, [options]);

View File

@@ -2261,6 +2261,57 @@ describe('useGeminiStream', () => {
});
});
it('should accumulate streamed thought descriptions', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Thought,
value: { subject: '', description: 'thinking ' },
};
yield {
type: ServerGeminiEventType.Thought,
value: { subject: '', description: 'more' },
};
yield {
type: ServerGeminiEventType.Finished,
value: { reason: 'STOP', usageMetadata: undefined },
};
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
),
);
await act(async () => {
await result.current.submitQuery('Streamed thought');
});
await waitFor(() => {
expect(result.current.thought?.description).toBe('thinking more');
});
});
it('should memoize pendingHistoryItems', () => {
mockUseReactToolScheduler.mockReturnValue([
[],

View File

@@ -497,6 +497,61 @@ export const useGeminiStream = (
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
);
const mergeThought = useCallback(
(incoming: ThoughtSummary) => {
setThought((prev) => {
if (!prev) {
return incoming;
}
const subject = incoming.subject || prev.subject;
const description = `${prev.description ?? ''}${incoming.description ?? ''}`;
return { subject, description };
});
},
[setThought],
);
const handleThoughtEvent = useCallback(
(
eventValue: ThoughtSummary,
currentThoughtBuffer: string,
userMessageTimestamp: number,
): string => {
if (turnCancelledRef.current) {
return '';
}
// Extract the description text from the thought summary
const thoughtText = eventValue.description ?? '';
if (!thoughtText) {
return currentThoughtBuffer;
}
const newThoughtBuffer = currentThoughtBuffer + thoughtText;
// If we're not already showing a thought, start a new one
if (pendingHistoryItemRef.current?.type !== 'gemini_thought') {
// If there's a pending non-thought item, finalize it first
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
}
setPendingHistoryItem({ type: 'gemini_thought', text: '' });
}
// Update the existing thought message with accumulated content
setPendingHistoryItem({
type: 'gemini_thought',
text: newThoughtBuffer,
});
// Also update the thought state for the loading indicator
mergeThought(eventValue);
return newThoughtBuffer;
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem, mergeThought],
);
const handleUserCancelledEvent = useCallback(
(userMessageTimestamp: number) => {
if (turnCancelledRef.current) {
@@ -710,11 +765,16 @@ export const useGeminiStream = (
signal: AbortSignal,
): Promise<StreamProcessingStatus> => {
let geminiMessageBuffer = '';
let thoughtBuffer = '';
const toolCallRequests: ToolCallRequestInfo[] = [];
for await (const event of stream) {
switch (event.type) {
case ServerGeminiEventType.Thought:
setThought(event.value);
thoughtBuffer = handleThoughtEvent(
event.value,
thoughtBuffer,
userMessageTimestamp,
);
break;
case ServerGeminiEventType.Content:
geminiMessageBuffer = handleContentEvent(
@@ -776,6 +836,7 @@ export const useGeminiStream = (
},
[
handleContentEvent,
handleThoughtEvent,
handleUserCancelledEvent,
handleErrorEvent,
scheduleToolCalls,

View File

@@ -1,37 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
import { QuitChoice } from '../components/QuitConfirmationDialog.js';
export const useQuitConfirmation = () => {
const [isQuitConfirmationOpen, setIsQuitConfirmationOpen] = useState(false);
const showQuitConfirmation = useCallback(() => {
setIsQuitConfirmationOpen(true);
}, []);
const handleQuitConfirmationSelect = useCallback((choice: QuitChoice) => {
setIsQuitConfirmationOpen(false);
if (choice === QuitChoice.CANCEL) {
return { shouldQuit: false, action: 'cancel' };
} else if (choice === QuitChoice.QUIT) {
return { shouldQuit: true, action: 'quit' };
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
return { shouldQuit: true, action: 'summary_and_quit' };
}
// Default to cancel if unknown choice
return { shouldQuit: false, action: 'cancel' };
}, []);
return {
isQuitConfirmationOpen,
showQuitConfirmation,
handleQuitConfirmationSelect,
};
};

View File

@@ -103,6 +103,16 @@ export type HistoryItemGeminiContent = HistoryItemBase & {
text: string;
};
export type HistoryItemGeminiThought = HistoryItemBase & {
type: 'gemini_thought';
text: string;
};
export type HistoryItemGeminiThoughtContent = HistoryItemBase & {
type: 'gemini_thought_content';
text: string;
};
export type HistoryItemInfo = HistoryItemBase & {
type: 'info';
text: string;
@@ -161,11 +171,6 @@ export type HistoryItemQuit = HistoryItemBase & {
duration: string;
};
export type HistoryItemQuitConfirmation = HistoryItemBase & {
type: 'quit_confirmation';
duration: string;
};
export type HistoryItemToolGroup = HistoryItemBase & {
type: 'tool_group';
tools: IndividualToolCallDisplay[];
@@ -246,6 +251,8 @@ export type HistoryItemWithoutId =
| HistoryItemUserShell
| HistoryItemGemini
| HistoryItemGeminiContent
| HistoryItemGeminiThought
| HistoryItemGeminiThoughtContent
| HistoryItemInfo
| HistoryItemError
| HistoryItemWarning
@@ -256,7 +263,6 @@ export type HistoryItemWithoutId =
| HistoryItemModelStats
| HistoryItemToolStats
| HistoryItemQuit
| HistoryItemQuitConfirmation
| HistoryItemCompression
| HistoryItemSummary
| HistoryItemCompression
@@ -278,7 +284,6 @@ export enum MessageType {
MODEL_STATS = 'model_stats',
TOOL_STATS = 'tool_stats',
QUIT = 'quit',
QUIT_CONFIRMATION = 'quit_confirmation',
GEMINI = 'gemini',
COMPRESSION = 'compression',
SUMMARY = 'summary',
@@ -342,12 +347,6 @@ export type Message =
duration: string;
content?: string;
}
| {
type: MessageType.QUIT_CONFIRMATION;
timestamp: Date;
duration: string;
content?: string;
}
| {
type: MessageType.COMPRESSION;
compression: CompressionProps;
@@ -404,7 +403,3 @@ export interface ConfirmationRequest {
export interface LoopDetectionConfirmationRequest {
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
}
export interface QuitConfirmationRequest {
onConfirm: (shouldQuit: boolean, action?: string) => void;
}

View File

@@ -19,12 +19,16 @@ const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
interface RenderInlineProps {
text: string;
textColor?: string;
}
const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
const RenderInlineInternal: React.FC<RenderInlineProps> = ({
text,
textColor = theme.text.primary,
}) => {
// Early return for plain text without markdown or URLs
if (!/[*_~`<[https?:]/.test(text)) {
return <Text color={theme.text.primary}>{text}</Text>;
return <Text color={textColor}>{text}</Text>;
}
const nodes: React.ReactNode[] = [];

View File

@@ -17,6 +17,7 @@ interface MarkdownDisplayProps {
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
textColor?: string;
}
// Constants for Markdown parsing and rendering
@@ -31,6 +32,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
isPending,
availableTerminalHeight,
terminalWidth,
textColor = theme.text.primary,
}) => {
if (!text) return <></>;
@@ -116,7 +118,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
addContentBlock(
<Box key={key}>
<Text wrap="wrap">
<RenderInline text={line} />
<RenderInline text={line} textColor={textColor} />
</Text>
</Box>,
);
@@ -155,7 +157,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
addContentBlock(
<Box key={key}>
<Text wrap="wrap">
<RenderInline text={line} />
<RenderInline text={line} textColor={textColor} />
</Text>
</Box>,
);
@@ -173,36 +175,36 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
switch (level) {
case 1:
headerNode = (
<Text bold color={theme.text.link}>
<RenderInline text={headerText} />
<Text bold color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
</Text>
);
break;
case 2:
headerNode = (
<Text bold color={theme.text.link}>
<RenderInline text={headerText} />
<Text bold color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
</Text>
);
break;
case 3:
headerNode = (
<Text bold color={theme.text.primary}>
<RenderInline text={headerText} />
<Text bold color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
</Text>
);
break;
case 4:
headerNode = (
<Text italic color={theme.text.secondary}>
<RenderInline text={headerText} />
<Text italic color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
</Text>
);
break;
default:
headerNode = (
<Text color={theme.text.primary}>
<RenderInline text={headerText} />
<Text color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
</Text>
);
break;
@@ -219,6 +221,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
type="ul"
marker={marker}
leadingWhitespace={leadingWhitespace}
textColor={textColor}
/>,
);
} else if (olMatch) {
@@ -232,6 +235,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
type="ol"
marker={marker}
leadingWhitespace={leadingWhitespace}
textColor={textColor}
/>,
);
} else {
@@ -245,8 +249,8 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
} else {
addContentBlock(
<Box key={key}>
<Text wrap="wrap" color={theme.text.primary}>
<RenderInline text={line} />
<Text wrap="wrap" color={textColor}>
<RenderInline text={line} textColor={textColor} />
</Text>
</Box>,
);
@@ -367,6 +371,7 @@ interface RenderListItemProps {
type: 'ul' | 'ol';
marker: string;
leadingWhitespace?: string;
textColor?: string;
}
const RenderListItemInternal: React.FC<RenderListItemProps> = ({
@@ -374,6 +379,7 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
type,
marker,
leadingWhitespace = '',
textColor = theme.text.primary,
}) => {
const prefix = type === 'ol' ? `${marker}. ` : `${marker} `;
const prefixWidth = prefix.length;
@@ -385,11 +391,11 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
flexDirection="row"
>
<Box width={prefixWidth}>
<Text color={theme.text.primary}>{prefix}</Text>
<Text color={textColor}>{prefix}</Text>
</Box>
<Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}>
<Text wrap="wrap" color={theme.text.primary}>
<RenderInline text={itemText} />
<Text wrap="wrap" color={textColor}>
<RenderInline text={itemText} textColor={textColor} />
</Text>
</Box>
</Box>

View File

@@ -102,7 +102,7 @@ describe('resumeHistoryUtils', () => {
]);
});
it('marks tool results as error, skips thought text, and falls back when tool is missing', () => {
it('marks tool results as error, captures thought text, and falls back when tool is missing', () => {
const conversation = {
messages: [
{
@@ -142,6 +142,11 @@ describe('resumeHistoryUtils', () => {
const items = buildResumedHistoryItems(session, makeConfig({}));
expect(items).toEqual([
{
id: expect.any(Number),
type: 'gemini_thought',
text: 'should be skipped',
},
{ id: expect.any(Number), type: 'gemini', text: 'visible text' },
{
id: expect.any(Number),

View File

@@ -17,7 +17,7 @@ import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
import { ToolCallStatus } from '../types.js';
/**
* Extracts text content from a Content object's parts.
* Extracts text content from a Content object's parts (excluding thought parts).
*/
function extractTextFromParts(parts: Part[] | undefined): string {
if (!parts) return '';
@@ -34,6 +34,22 @@ function extractTextFromParts(parts: Part[] | undefined): string {
return textParts.join('\n');
}
/**
* Extracts thought text content from a Content object's parts.
* Thought parts are identified by having `thought: true`.
*/
function extractThoughtTextFromParts(parts: Part[] | undefined): string {
if (!parts) return '';
const thoughtParts: string[] = [];
for (const part of parts) {
if ('text' in part && part.text && 'thought' in part && part.thought) {
thoughtParts.push(part.text);
}
}
return thoughtParts.join('\n');
}
/**
* Extracts function calls from a Content object's parts.
*/
@@ -187,12 +203,28 @@ function convertToHistoryItems(
case 'assistant': {
const parts = record.message?.parts as Part[] | undefined;
// Extract thought content
const thoughtText = extractThoughtTextFromParts(parts);
// Extract text content (non-function-call, non-thought)
const text = extractTextFromParts(parts);
// Extract function calls
const functionCalls = extractFunctionCalls(parts);
// If there's thought content, add it as a gemini_thought message
if (thoughtText) {
// Flush any pending tool group before thought
if (currentToolGroup.length > 0) {
items.push({
type: 'tool_group',
tools: [...currentToolGroup],
});
currentToolGroup = [];
}
items.push({ type: 'gemini_thought', text: thoughtText });
}
// If there's text content, add it as a gemini message
if (text) {
// Flush any pending tool group before text

View File

@@ -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 };

View File

@@ -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',
@@ -360,6 +370,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;
@@ -599,8 +620,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 +641,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();
@@ -1261,8 +1285,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 +1377,7 @@ export class Config {
}
await registry.discoverAllTools();
console.debug('ToolRegistry created', registry.getAllToolNames());
return registry;
}
}

View File

@@ -448,6 +448,7 @@ describe('Gemini Client (client.ts)', () => {
getHistory: mockGetHistory,
addHistory: vi.fn(),
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
} as unknown as GeminiChat;
});
@@ -462,6 +463,7 @@ describe('Gemini Client (client.ts)', () => {
const mockOriginalChat: Partial<GeminiChat> = {
getHistory: vi.fn((_curated?: boolean) => chatHistory),
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
};
client['chat'] = mockOriginalChat as GeminiChat;
@@ -1080,6 +1082,7 @@ describe('Gemini Client (client.ts)', () => {
const mockChat = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
} as unknown as GeminiChat;
client['chat'] = mockChat;
@@ -1142,6 +1145,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
@@ -1197,6 +1201,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
@@ -1273,6 +1278,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
@@ -1319,6 +1325,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
@@ -1363,6 +1370,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
@@ -1450,6 +1458,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
@@ -1506,6 +1515,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
@@ -1586,6 +1596,7 @@ ${JSON.stringify(
.mockReturnValue([
{ role: 'user', parts: [{ text: 'previous message' }] },
]),
stripThoughtsFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
});
@@ -1840,6 +1851,7 @@ ${JSON.stringify(
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]), // Default empty history
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
@@ -2180,6 +2192,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
@@ -2216,6 +2229,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
@@ -2256,6 +2270,7 @@ ${JSON.stringify(
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

View File

@@ -419,6 +419,9 @@ export class GeminiClient {
// record user message for session management
this.config.getChatRecordingService()?.recordUserMessage(request);
// strip thoughts from history before sending the message
this.stripThoughtsFromHistory();
}
this.sessionTurnCount++;
if (

View File

@@ -1541,10 +1541,10 @@ describe('GeminiChat', () => {
{
role: 'model',
parts: [
{ text: 'thinking...', thoughtSignature: 'thought-123' },
{ text: 'thinking...', thought: true },
{ text: 'hi' },
{
functionCall: { name: 'test', args: {} },
thoughtSignature: 'thought-456',
},
],
},
@@ -1559,10 +1559,7 @@ describe('GeminiChat', () => {
},
{
role: 'model',
parts: [
{ text: 'thinking...' },
{ functionCall: { name: 'test', args: {} } },
],
parts: [{ text: 'hi' }, { functionCall: { name: 'test', args: {} } }],
},
]);
});

View File

@@ -443,20 +443,28 @@ export class GeminiChat {
}
stripThoughtsFromHistory(): void {
this.history = this.history.map((content) => {
const newContent = { ...content };
if (newContent.parts) {
newContent.parts = newContent.parts.map((part) => {
if (part && typeof part === 'object' && 'thoughtSignature' in part) {
const newPart = { ...part };
delete (newPart as { thoughtSignature?: string }).thoughtSignature;
return newPart;
}
return part;
});
}
return newContent;
});
this.history = this.history
.map((content) => {
if (!content.parts) return content;
// Filter out thought parts entirely
const filteredParts = content.parts.filter(
(part) =>
!(
part &&
typeof part === 'object' &&
'thought' in part &&
part.thought
),
);
return {
...content,
parts: filteredParts,
};
})
// Remove Content objects that have no parts left after filtering
.filter((content) => content.parts && content.parts.length > 0);
}
setTools(tools: Tool[]): void {
@@ -497,8 +505,6 @@ export class GeminiChat {
): AsyncGenerator<GenerateContentResponse> {
// Collect ALL parts from the model response (including thoughts for recording)
const allModelParts: Part[] = [];
// Non-thought parts for history (what we send back to the API)
const historyParts: Part[] = [];
let usageMetadata: GenerateContentResponseUsageMetadata | undefined;
let hasToolCall = false;
@@ -516,8 +522,6 @@ export class GeminiChat {
// Collect all parts for recording
allModelParts.push(...content.parts);
// Collect non-thought parts for history
historyParts.push(...content.parts.filter((part) => !part.thought));
}
}
@@ -534,9 +538,15 @@ export class GeminiChat {
yield chunk; // Yield every chunk to the UI immediately.
}
// Consolidate text parts for history (merges adjacent text parts).
const thoughtParts = allModelParts.filter((part) => part.thought);
const thoughtText = thoughtParts
.map((part) => part.text)
.join('')
.trim();
const contentParts = allModelParts.filter((part) => !part.thought);
const consolidatedHistoryParts: Part[] = [];
for (const part of historyParts) {
for (const part of contentParts) {
const lastPart =
consolidatedHistoryParts[consolidatedHistoryParts.length - 1];
if (
@@ -550,20 +560,21 @@ export class GeminiChat {
}
}
const responseText = consolidatedHistoryParts
const contentText = consolidatedHistoryParts
.filter((part) => part.text)
.map((part) => part.text)
.join('')
.trim();
// Record assistant turn with raw Content and metadata
if (responseText || hasToolCall || usageMetadata) {
if (thoughtText || contentText || hasToolCall || usageMetadata) {
this.chatRecordingService?.recordAssistantTurn({
model,
message: [
...(responseText ? [{ text: responseText }] : []),
...(thoughtText ? [{ text: thoughtText, thought: true }] : []),
...(contentText ? [{ text: contentText }] : []),
...(hasToolCall
? historyParts
? contentParts
.filter((part) => part.functionCall)
.map((part) => ({ functionCall: part.functionCall }))
: []),
@@ -579,7 +590,7 @@ export class GeminiChat {
// We throw an error only when there's no tool call AND:
// - No finish reason, OR
// - Empty response text (e.g., only thoughts with no actual content)
if (!hasToolCall && (!hasFinishReason || !responseText)) {
if (!hasToolCall && (!hasFinishReason || !contentText)) {
if (!hasFinishReason) {
throw new InvalidStreamError(
'Model stream ended without a finish reason.',
@@ -593,8 +604,13 @@ export class GeminiChat {
}
}
// Add to history (without thoughts, for API calls)
this.history.push({ role: 'model', parts: consolidatedHistoryParts });
this.history.push({
role: 'model',
parts: [
...(thoughtText ? [{ text: thoughtText, thought: true }] : []),
...consolidatedHistoryParts,
],
});
}
}

View File

@@ -8,6 +8,7 @@ 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 OpenAI from 'openai';
describe('OpenAIContentConverter', () => {
let converter: OpenAIContentConverter;
@@ -142,4 +143,63 @@ describe('OpenAIContentConverter', () => {
expect(toolMessage?.content).toBe('{"data":{"value":42}}');
});
});
describe('OpenAI -> Gemini reasoning content', () => {
it('should convert reasoning_content to a thought part for non-streaming responses', () => {
const response = converter.convertOpenAIResponseToGemini({
object: 'chat.completion',
id: 'chatcmpl-1',
created: 123,
model: 'gpt-test',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: 'final answer',
reasoning_content: 'chain-of-thought',
},
finish_reason: 'stop',
logprobs: null,
},
],
} as unknown as OpenAI.Chat.ChatCompletion);
const parts = response.candidates?.[0]?.content?.parts;
expect(parts?.[0]).toEqual(
expect.objectContaining({ thought: true, text: 'chain-of-thought' }),
);
expect(parts?.[1]).toEqual(
expect.objectContaining({ text: 'final answer' }),
);
});
it('should convert streaming reasoning_content delta to a thought part', () => {
const chunk = converter.convertOpenAIChunkToGemini({
object: 'chat.completion.chunk',
id: 'chunk-1',
created: 456,
choices: [
{
index: 0,
delta: {
content: 'visible text',
reasoning_content: 'thinking...',
},
finish_reason: 'stop',
logprobs: null,
},
],
model: 'gpt-test',
} as unknown as OpenAI.Chat.ChatCompletionChunk);
const parts = chunk.candidates?.[0]?.content?.parts;
expect(parts?.[0]).toEqual(
expect.objectContaining({ thought: true, text: 'thinking...' }),
);
expect(parts?.[1]).toEqual(
expect.objectContaining({ text: 'visible text' }),
);
});
});
});

View File

@@ -31,6 +31,25 @@ interface ExtendedCompletionUsage extends OpenAI.CompletionUsage {
cached_tokens?: number;
}
interface ExtendedChatCompletionAssistantMessageParam
extends OpenAI.Chat.ChatCompletionAssistantMessageParam {
reasoning_content?: string | null;
}
type ExtendedChatCompletionMessageParam =
| OpenAI.Chat.ChatCompletionMessageParam
| ExtendedChatCompletionAssistantMessageParam;
export interface ExtendedCompletionMessage
extends OpenAI.Chat.ChatCompletionMessage {
reasoning_content?: string | null;
}
export interface ExtendedCompletionChunkDelta
extends OpenAI.Chat.ChatCompletionChunk.Choice.Delta {
reasoning_content?: string | null;
}
/**
* Tool call accumulator for streaming responses
*/
@@ -44,7 +63,8 @@ export interface ToolCallAccumulator {
* Parsed parts from Gemini content, categorized by type
*/
interface ParsedParts {
textParts: string[];
thoughtParts: string[];
contentParts: string[];
functionCalls: FunctionCall[];
functionResponses: FunctionResponse[];
mediaParts: Array<{
@@ -251,7 +271,7 @@ export class OpenAIContentConverter {
*/
private processContents(
contents: ContentListUnion,
messages: OpenAI.Chat.ChatCompletionMessageParam[],
messages: ExtendedChatCompletionMessageParam[],
): void {
if (Array.isArray(contents)) {
for (const content of contents) {
@@ -267,7 +287,7 @@ export class OpenAIContentConverter {
*/
private processContent(
content: ContentUnion | PartUnion,
messages: OpenAI.Chat.ChatCompletionMessageParam[],
messages: ExtendedChatCompletionMessageParam[],
): void {
if (typeof content === 'string') {
messages.push({ role: 'user' as const, content });
@@ -301,11 +321,19 @@ export class OpenAIContentConverter {
},
}));
messages.push({
const assistantMessage: ExtendedChatCompletionAssistantMessageParam = {
role: 'assistant' as const,
content: parsedParts.textParts.join('') || null,
content: parsedParts.contentParts.join('') || null,
tool_calls: toolCalls,
});
};
// Only include reasoning_content if it has actual content
const reasoningContent = parsedParts.thoughtParts.join('');
if (reasoningContent) {
assistantMessage.reasoning_content = reasoningContent;
}
messages.push(assistantMessage);
return;
}
@@ -322,7 +350,8 @@ export class OpenAIContentConverter {
* Parse Gemini parts into categorized components
*/
private parseParts(parts: Part[]): ParsedParts {
const textParts: string[] = [];
const thoughtParts: string[] = [];
const contentParts: string[] = [];
const functionCalls: FunctionCall[] = [];
const functionResponses: FunctionResponse[] = [];
const mediaParts: Array<{
@@ -334,9 +363,20 @@ export class OpenAIContentConverter {
for (const part of parts) {
if (typeof part === 'string') {
textParts.push(part);
} else if ('text' in part && part.text) {
textParts.push(part.text);
contentParts.push(part);
} else if (
'text' in part &&
part.text &&
!('thought' in part && part.thought)
) {
contentParts.push(part.text);
} else if (
'text' in part &&
part.text &&
'thought' in part &&
part.thought
) {
thoughtParts.push(part.text);
} else if ('functionCall' in part && part.functionCall) {
functionCalls.push(part.functionCall);
} else if ('functionResponse' in part && part.functionResponse) {
@@ -361,7 +401,13 @@ export class OpenAIContentConverter {
}
}
return { textParts, functionCalls, functionResponses, mediaParts };
return {
thoughtParts,
contentParts,
functionCalls,
functionResponses,
mediaParts,
};
}
private extractFunctionResponseContent(response: unknown): string {
@@ -408,14 +454,29 @@ export class OpenAIContentConverter {
*/
private createMultimodalMessage(
role: 'user' | 'assistant',
parsedParts: Pick<ParsedParts, 'textParts' | 'mediaParts'>,
): OpenAI.Chat.ChatCompletionMessageParam | null {
const { textParts, mediaParts } = parsedParts;
const content = textParts.map((text) => ({ type: 'text' as const, text }));
parsedParts: Pick<
ParsedParts,
'contentParts' | 'mediaParts' | 'thoughtParts'
>,
): ExtendedChatCompletionMessageParam | null {
const { contentParts, mediaParts, thoughtParts } = parsedParts;
const reasoningContent = thoughtParts.join('');
const content = contentParts.map((text) => ({
type: 'text' as const,
text,
}));
// If no media parts, return simple text message
if (mediaParts.length === 0) {
return content.length > 0 ? { role, content } : null;
if (content.length === 0) return null;
const message: ExtendedChatCompletionMessageParam = { role, content };
// Only include reasoning_content if it has actual content
if (reasoningContent) {
(
message as ExtendedChatCompletionAssistantMessageParam
).reasoning_content = reasoningContent;
}
return message;
}
// For assistant messages with media, convert to text only
@@ -536,6 +597,13 @@ export class OpenAIContentConverter {
const parts: Part[] = [];
// Handle reasoning content (thoughts)
const reasoningText = (choice.message as ExtendedCompletionMessage)
.reasoning_content;
if (reasoningText) {
parts.push({ text: reasoningText, thought: true });
}
// Handle text content
if (choice.message.content) {
parts.push({ text: choice.message.content });
@@ -632,6 +700,12 @@ export class OpenAIContentConverter {
if (choice) {
const parts: Part[] = [];
const reasoningText = (choice.delta as ExtendedCompletionChunkDelta)
.reasoning_content;
if (reasoningText) {
parts.push({ text: reasoningText, thought: true });
}
// Handle text content
if (choice.delta?.content) {
if (typeof choice.delta.content === 'string') {
@@ -721,6 +795,8 @@ export class OpenAIContentConverter {
const promptTokens = usage.prompt_tokens || 0;
const completionTokens = usage.completion_tokens || 0;
const totalTokens = usage.total_tokens || 0;
const thinkingTokens =
usage.completion_tokens_details?.reasoning_tokens || 0;
// Support both formats: prompt_tokens_details.cached_tokens (OpenAI standard)
// and cached_tokens (some models return it at top level)
const extendedUsage = usage as ExtendedCompletionUsage;
@@ -743,6 +819,7 @@ export class OpenAIContentConverter {
response.usageMetadata = {
promptTokenCount: finalPromptTokens,
candidatesTokenCount: finalCompletionTokens,
thoughtsTokenCount: thinkingTokens,
totalTokenCount: totalTokens,
cachedContentTokenCount: cachedTokens,
};

View File

@@ -561,11 +561,14 @@ describe('DefaultTelemetryService', () => {
choices: [
{
index: 0,
delta: { content: 'Hello' },
delta: {
content: 'Hello',
reasoning_content: 'thinking ',
},
finish_reason: null,
},
],
} as OpenAI.Chat.ChatCompletionChunk,
} as unknown as OpenAI.Chat.ChatCompletionChunk,
{
id: 'test-id',
object: 'chat.completion.chunk',
@@ -574,7 +577,10 @@ describe('DefaultTelemetryService', () => {
choices: [
{
index: 0,
delta: { content: ' world' },
delta: {
content: ' world',
reasoning_content: 'more',
},
finish_reason: 'stop',
},
],
@@ -583,7 +589,7 @@ describe('DefaultTelemetryService', () => {
completion_tokens: 5,
total_tokens: 15,
},
} as OpenAI.Chat.ChatCompletionChunk,
} as unknown as OpenAI.Chat.ChatCompletionChunk,
];
await telemetryService.logStreamingSuccess(
@@ -603,11 +609,11 @@ describe('DefaultTelemetryService', () => {
choices: [
{
index: 0,
message: {
message: expect.objectContaining({
role: 'assistant',
content: 'Hello world',
refusal: null,
},
reasoning_content: 'thinking more',
}),
finish_reason: 'stop',
logprobs: null,
},
@@ -722,11 +728,14 @@ describe('DefaultTelemetryService', () => {
choices: [
{
index: 0,
delta: { content: 'Hello' },
delta: {
content: 'Hello',
reasoning_content: 'thinking ',
},
finish_reason: null,
},
],
} as OpenAI.Chat.ChatCompletionChunk,
} as unknown as OpenAI.Chat.ChatCompletionChunk,
{
id: 'test-id',
object: 'chat.completion.chunk',
@@ -735,7 +744,10 @@ describe('DefaultTelemetryService', () => {
choices: [
{
index: 0,
delta: { content: ' world!' },
delta: {
content: ' world!',
reasoning_content: 'more',
},
finish_reason: 'stop',
},
],
@@ -744,7 +756,7 @@ describe('DefaultTelemetryService', () => {
completion_tokens: 5,
total_tokens: 15,
},
} as OpenAI.Chat.ChatCompletionChunk,
} as unknown as OpenAI.Chat.ChatCompletionChunk,
];
await telemetryService.logStreamingSuccess(
@@ -757,27 +769,14 @@ describe('DefaultTelemetryService', () => {
expect(openaiLogger.logInteraction).toHaveBeenCalledWith(
mockOpenAIRequest,
expect.objectContaining({
id: 'test-id',
object: 'chat.completion',
created: 1234567890,
model: 'gpt-4',
choices: [
{
index: 0,
message: {
role: 'assistant',
expect.objectContaining({
message: expect.objectContaining({
content: 'Hello world!',
refusal: null,
},
finish_reason: 'stop',
logprobs: null,
},
reasoning_content: 'thinking more',
}),
}),
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
}),
);
});

View File

@@ -10,6 +10,7 @@ import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.js';
import { OpenAILogger } from '../../utils/openaiLogger.js';
import type { GenerateContentResponse } from '@google/genai';
import type OpenAI from 'openai';
import type { ExtendedCompletionChunkDelta } from './converter.js';
export interface RequestContext {
userPromptId: string;
@@ -172,6 +173,7 @@ export class DefaultTelemetryService implements TelemetryService {
| 'content_filter'
| 'function_call'
| null = null;
let combinedReasoning = '';
let usage:
| {
prompt_tokens: number;
@@ -183,6 +185,12 @@ export class DefaultTelemetryService implements TelemetryService {
for (const chunk of chunks) {
const choice = chunk.choices?.[0];
if (choice) {
// Combine reasoning content
const reasoningContent = (choice.delta as ExtendedCompletionChunkDelta)
?.reasoning_content;
if (reasoningContent) {
combinedReasoning += reasoningContent;
}
// Combine text content
if (choice.delta?.content) {
combinedContent += choice.delta.content;
@@ -230,6 +238,11 @@ export class DefaultTelemetryService implements TelemetryService {
content: combinedContent || null,
refusal: null,
};
if (combinedReasoning) {
// Attach reasoning content if any thought tokens were streamed
(message as { reasoning_content?: string }).reasoning_content =
combinedReasoning;
}
// Add tool calls if any
if (toolCalls.length > 0) {

View File

@@ -120,6 +120,97 @@ describe('Turn', () => {
expect(turn.getDebugResponses().length).toBe(2);
});
it('should emit Thought events when a thought part is present', async () => {
const mockResponseStream = (async function* () {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
role: 'model',
parts: [
{ thought: true, text: 'reasoning...' },
{ text: 'final answer' },
],
},
},
],
} as GenerateContentResponse,
};
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
const events = [];
const reqParts: Part[] = [{ text: 'Hi' }];
for await (const event of turn.run(
'test-model',
reqParts,
new AbortController().signal,
)) {
events.push(event);
}
expect(events).toEqual([
{
type: GeminiEventType.Thought,
value: { subject: '', description: 'reasoning...' },
},
]);
});
it('should emit thought descriptions per incoming chunk', async () => {
const mockResponseStream = (async function* () {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
role: 'model',
parts: [{ thought: true, text: 'part1' }],
},
},
],
} as GenerateContentResponse,
};
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
role: 'model',
parts: [{ thought: true, text: 'part2' }],
},
},
],
} as GenerateContentResponse,
};
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
const events = [];
for await (const event of turn.run(
'test-model',
[{ text: 'Hi' }],
new AbortController().signal,
)) {
events.push(event);
}
expect(events).toEqual([
{
type: GeminiEventType.Thought,
value: { subject: '', description: 'part1' },
},
{
type: GeminiEventType.Thought,
value: { subject: '', description: 'part2' },
},
]);
});
it('should yield tool_call_request events for function calls', async () => {
const mockResponseStream = (async function* () {
yield {

View File

@@ -27,7 +27,7 @@ import {
toFriendlyError,
} from '../utils/errors.js';
import type { GeminiChat } from './geminiChat.js';
import { parseThought, type ThoughtSummary } from '../utils/thoughtUtils.js';
import { getThoughtText, type ThoughtSummary } from '../utils/thoughtUtils.js';
// Define a structure for tools passed to the server
export interface ServerTool {
@@ -266,12 +266,11 @@ export class Turn {
this.currentResponseId = resp.responseId;
}
const thoughtPart = resp.candidates?.[0]?.content?.parts?.[0];
if (thoughtPart?.thought) {
const thought = parseThought(thoughtPart.text ?? '');
const thoughtPart = getThoughtText(resp);
if (thoughtPart) {
yield {
type: GeminiEventType.Thought,
value: thought,
value: { subject: '', description: thoughtPart },
};
continue;
}

View File

@@ -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';

View File

@@ -542,6 +542,39 @@ export class SessionService {
}
}
/**
* Options for building API history from conversation.
*/
export interface BuildApiHistoryOptions {
/**
* Whether to strip thought parts from the history.
* Thought parts are content parts that have `thought: true`.
* @default true
*/
stripThoughtsFromHistory?: boolean;
}
/**
* Strips thought parts from a Content object.
* Thought parts are identified by having `thought: true`.
* Returns null if the content only contained thought parts.
*/
function stripThoughtsFromContent(content: Content): Content | null {
if (!content.parts) return content;
const filteredParts = content.parts.filter((part) => !(part as Part).thought);
// If all parts were thoughts, remove the entire content
if (filteredParts.length === 0) {
return null;
}
return {
...content,
parts: filteredParts,
};
}
/**
* Builds the model-facing chat history (Content[]) from a reconstructed
* conversation. This keeps UI history intact while applying chat compression
@@ -555,7 +588,9 @@ export class SessionService {
*/
export function buildApiHistoryFromConversation(
conversation: ConversationRecord,
options: BuildApiHistoryOptions = {},
): Content[] {
const { stripThoughtsFromHistory = true } = options;
const { messages } = conversation;
let lastCompressionIndex = -1;
@@ -585,14 +620,26 @@ export function buildApiHistoryFromConversation(
}
}
if (stripThoughtsFromHistory) {
return baseHistory
.map(stripThoughtsFromContent)
.filter((content): content is Content => content !== null);
}
return baseHistory;
}
// Fallback: return linear messages as Content[]
return messages
const result = messages
.map((record) => record.message)
.filter((message): message is Content => message !== undefined)
.map((message) => structuredClone(message));
if (stripThoughtsFromHistory) {
return result
.map(stripThoughtsFromContent)
.filter((content): content is Content => content !== null);
}
return result;
}
/**

View File

@@ -198,6 +198,52 @@ describe('GlobTool', () => {
);
});
it('should find files even if workspace path casing differs from glob results (Windows/macOS)', async () => {
// Only relevant for Windows and macOS
if (process.platform !== 'win32' && process.platform !== 'darwin') {
return;
}
let mismatchedRootDir = tempRootDir;
if (process.platform === 'win32') {
// 1. Create a path with mismatched casing for the workspace root
// e.g., if tempRootDir is "C:\Users\...", make it "c:\Users\..."
const drive = path.parse(tempRootDir).root;
if (!drive || !drive.match(/^[A-Z]:\\/)) {
// Skip if we can't determine/manipulate the drive letter easily
return;
}
const lowerDrive = drive.toLowerCase();
mismatchedRootDir = lowerDrive + tempRootDir.substring(drive.length);
} else {
// macOS: change the casing of the path
if (tempRootDir === tempRootDir.toLowerCase()) {
mismatchedRootDir = tempRootDir.toUpperCase();
} else {
mismatchedRootDir = tempRootDir.toLowerCase();
}
}
// 2. Create a new GlobTool instance with this mismatched root
const mismatchedConfig = {
...mockConfig,
getTargetDir: () => mismatchedRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(mismatchedRootDir),
} as unknown as Config;
const mismatchedGlobTool = new GlobTool(mismatchedConfig);
// 3. Execute search
const params: GlobToolParams = { pattern: '*.txt' };
const invocation = mismatchedGlobTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('Found 2 file(s)');
});
it('should return error if path is outside workspace', async () => {
// Bypassing validation to test execute method directly
vi.spyOn(globTool, 'validateToolParams').mockReturnValue(null);

View File

@@ -134,12 +134,21 @@ class GlobToolInvocation extends BaseToolInvocation<
this.getFileFilteringOptions(),
);
const normalizePathForComparison = (p: string) =>
process.platform === 'win32' || process.platform === 'darwin'
? p.toLowerCase()
: p;
const filteredAbsolutePaths = new Set(
filteredPaths.map((p) => path.resolve(this.config.getTargetDir(), p)),
filteredPaths.map((p) =>
normalizePathForComparison(
path.resolve(this.config.getTargetDir(), p),
),
),
);
const filteredEntries = allEntries.filter((entry) =>
filteredAbsolutePaths.has(entry.fullpath()),
filteredAbsolutePaths.has(normalizePathForComparison(entry.fullpath())),
);
if (!filteredEntries || filteredEntries.length === 0) {

View File

@@ -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);

View File

@@ -13,6 +13,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type {
GetPromptResult,
JSONRPCMessage,
Prompt,
} from '@modelcontextprotocol/sdk/types.js';
import {
@@ -22,10 +23,11 @@ import {
} from '@modelcontextprotocol/sdk/types.js';
import { parse } from 'shell-quote';
import type { Config, MCPServerConfig } from '../config/config.js';
import { AuthProviderType } from '../config/config.js';
import { AuthProviderType, isSdkMcpServerConfig } from '../config/config.js';
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import { SdkControlClientTransport } from './sdk-control-client-transport.js';
import type { FunctionDeclaration } from '@google/genai';
import { mcpToTool } from '@google/genai';
@@ -42,6 +44,14 @@ import type {
} from '../utils/workspaceContext.js';
import type { ToolRegistry } from './tool-registry.js';
/**
* Callback type for sending MCP messages to SDK servers via control plane
*/
export type SendSdkMcpMessage = (
serverName: string,
message: JSONRPCMessage,
) => Promise<JSONRPCMessage>;
export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes
export type DiscoveredMCPPrompt = Prompt & {
@@ -92,6 +102,7 @@ export class McpClient {
private readonly promptRegistry: PromptRegistry,
private readonly workspaceContext: WorkspaceContext,
private readonly debugMode: boolean,
private readonly sendSdkMcpMessage?: SendSdkMcpMessage,
) {
this.client = new Client({
name: `qwen-cli-mcp-client-${this.serverName}`,
@@ -189,7 +200,12 @@ export class McpClient {
}
private async createTransport(): Promise<Transport> {
return createTransport(this.serverName, this.serverConfig, this.debugMode);
return createTransport(
this.serverName,
this.serverConfig,
this.debugMode,
this.sendSdkMcpMessage,
);
}
private async discoverTools(cliConfig: Config): Promise<DiscoveredMCPTool[]> {
@@ -501,6 +517,7 @@ export function populateMcpServerCommand(
* @param mcpServerName The name identifier for this MCP server
* @param mcpServerConfig Configuration object containing connection details
* @param toolRegistry The registry to register discovered tools with
* @param sendSdkMcpMessage Optional callback for SDK MCP servers to route messages via control plane.
* @returns Promise that resolves when discovery is complete
*/
export async function connectAndDiscover(
@@ -511,6 +528,7 @@ export async function connectAndDiscover(
debugMode: boolean,
workspaceContext: WorkspaceContext,
cliConfig: Config,
sendSdkMcpMessage?: SendSdkMcpMessage,
): Promise<void> {
updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTING);
@@ -521,6 +539,7 @@ export async function connectAndDiscover(
mcpServerConfig,
debugMode,
workspaceContext,
sendSdkMcpMessage,
);
mcpClient.onerror = (error) => {
@@ -744,6 +763,7 @@ export function hasNetworkTransport(config: MCPServerConfig): boolean {
*
* @param mcpServerName The name of the MCP server, used for logging and identification.
* @param mcpServerConfig The configuration specifying how to connect to the server.
* @param sendSdkMcpMessage Optional callback for SDK MCP servers to route messages via control plane.
* @returns A promise that resolves to a connected MCP `Client` instance.
* @throws An error if the connection fails or the configuration is invalid.
*/
@@ -752,6 +772,7 @@ export async function connectToMcpServer(
mcpServerConfig: MCPServerConfig,
debugMode: boolean,
workspaceContext: WorkspaceContext,
sendSdkMcpMessage?: SendSdkMcpMessage,
): Promise<Client> {
const mcpClient = new Client({
name: 'qwen-code-mcp-client',
@@ -808,6 +829,7 @@ export async function connectToMcpServer(
mcpServerName,
mcpServerConfig,
debugMode,
sendSdkMcpMessage,
);
try {
await mcpClient.connect(transport, {
@@ -1172,7 +1194,21 @@ export async function createTransport(
mcpServerName: string,
mcpServerConfig: MCPServerConfig,
debugMode: boolean,
sendSdkMcpMessage?: SendSdkMcpMessage,
): Promise<Transport> {
if (isSdkMcpServerConfig(mcpServerConfig)) {
if (!sendSdkMcpMessage) {
throw new Error(
`SDK MCP server '${mcpServerName}' requires sendSdkMcpMessage callback`,
);
}
return new SdkControlClientTransport({
serverName: mcpServerName,
sendMcpMessage: sendSdkMcpMessage,
debugMode,
});
}
if (
mcpServerConfig.authProviderType ===
AuthProviderType.SERVICE_ACCOUNT_IMPERSONATION

View File

@@ -0,0 +1,163 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* SdkControlClientTransport - MCP Client transport for SDK MCP servers
*
* This transport enables CLI's MCP client to connect to SDK MCP servers
* through the control plane. Messages are routed:
*
* CLI MCP Client → SdkControlClientTransport → sendMcpMessage() →
* control_request (mcp_message) → SDK → control_response → onmessage → CLI
*
* Unlike StdioClientTransport which spawns a subprocess, this transport
* communicates with SDK MCP servers running in the SDK process.
*/
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
/**
* Callback to send MCP messages to SDK via control plane
* Returns the MCP response from the SDK
*/
export type SendMcpMessageCallback = (
serverName: string,
message: JSONRPCMessage,
) => Promise<JSONRPCMessage>;
export interface SdkControlClientTransportOptions {
serverName: string;
sendMcpMessage: SendMcpMessageCallback;
debugMode?: boolean;
}
/**
* MCP Client Transport for SDK MCP servers
*
* Implements the @modelcontextprotocol/sdk Transport interface to enable
* CLI's MCP client to connect to SDK MCP servers via the control plane.
*/
export class SdkControlClientTransport {
private serverName: string;
private sendMcpMessage: SendMcpMessageCallback;
private debugMode: boolean;
private started = false;
// Transport interface callbacks
onmessage?: (message: JSONRPCMessage) => void;
onerror?: (error: Error) => void;
onclose?: () => void;
constructor(options: SdkControlClientTransportOptions) {
this.serverName = options.serverName;
this.sendMcpMessage = options.sendMcpMessage;
this.debugMode = options.debugMode ?? false;
}
/**
* Start the transport
* For SDK transport, this just marks it as ready - no subprocess to spawn
*/
async start(): Promise<void> {
if (this.started) {
return;
}
this.started = true;
if (this.debugMode) {
console.error(
`[SdkControlClientTransport] Started for server '${this.serverName}'`,
);
}
}
/**
* Send a message to the SDK MCP server via control plane
*
* Routes the message through the control plane and delivers
* the response via onmessage callback.
*/
async send(message: JSONRPCMessage): Promise<void> {
if (!this.started) {
throw new Error(
`SdkControlClientTransport (${this.serverName}) not started. Call start() first.`,
);
}
if (this.debugMode) {
console.error(
`[SdkControlClientTransport] Sending message to '${this.serverName}':`,
JSON.stringify(message),
);
}
try {
// Send message to SDK and wait for response
const response = await this.sendMcpMessage(this.serverName, message);
if (this.debugMode) {
console.error(
`[SdkControlClientTransport] Received response from '${this.serverName}':`,
JSON.stringify(response),
);
}
// Deliver response via onmessage callback
if (this.onmessage) {
this.onmessage(response);
}
} catch (error) {
if (this.debugMode) {
console.error(
`[SdkControlClientTransport] Error sending to '${this.serverName}':`,
error,
);
}
if (this.onerror) {
this.onerror(error instanceof Error ? error : new Error(String(error)));
}
throw error;
}
}
/**
* Close the transport
*/
async close(): Promise<void> {
if (!this.started) {
return;
}
this.started = false;
if (this.debugMode) {
console.error(
`[SdkControlClientTransport] Closed for server '${this.serverName}'`,
);
}
if (this.onclose) {
this.onclose();
}
}
/**
* Check if transport is started
*/
isStarted(): boolean {
return this.started;
}
/**
* Get server name
*/
getServerName(): string {
return this.serverName;
}
}

View File

@@ -16,6 +16,7 @@ import type { Config } from '../config/config.js';
import { spawn } from 'node:child_process';
import { StringDecoder } from 'node:string_decoder';
import { connectAndDiscover } from './mcp-client.js';
import type { SendSdkMcpMessage } from './mcp-client.js';
import { McpClientManager } from './mcp-client-manager.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import { parse } from 'shell-quote';
@@ -173,7 +174,11 @@ export class ToolRegistry {
private config: Config;
private mcpClientManager: McpClientManager;
constructor(config: Config, eventEmitter?: EventEmitter) {
constructor(
config: Config,
eventEmitter?: EventEmitter,
sendSdkMcpMessage?: SendSdkMcpMessage,
) {
this.config = config;
this.mcpClientManager = new McpClientManager(
this.config.getMcpServers() ?? {},
@@ -183,6 +188,7 @@ export class ToolRegistry {
this.config.getDebugMode(),
this.config.getWorkspaceContext(),
eventEmitter,
sendSdkMcpMessage,
);
}

View File

@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { GenerateContentResponse } from '@google/genai';
export type ThoughtSummary = {
subject: string;
description: string;
@@ -52,3 +54,23 @@ export function parseThought(rawText: string): ThoughtSummary {
return { subject, description };
}
export function getThoughtText(
response: GenerateContentResponse,
): string | null {
if (response.candidates && response.candidates.length > 0) {
const candidate = response.candidates[0];
if (
candidate.content &&
candidate.content.parts &&
candidate.content.parts.length > 0
) {
return candidate.content.parts
.filter((part) => part.thought)
.map((part) => part.text ?? '')
.join('');
}
}
return null;
}

View File

@@ -1,4 +1,4 @@
# @qwen-code/sdk-typescript
# @qwen-code/sdk
A minimum experimental TypeScript SDK for programmatic access to Qwen Code.
@@ -7,20 +7,20 @@ Feel free to submit a feature request/issue/PR.
## Installation
```bash
npm install @qwen-code/sdk-typescript
npm install @qwen-code/sdk
```
## Requirements
- Node.js >= 20.0.0
- [Qwen Code](https://github.com/QwenLM/qwen-code) installed and accessible in PATH
- [Qwen Code](https://github.com/QwenLM/qwen-code) >= 0.4.0 (stable) installed and accessible in PATH
> **Note for nvm users**: If you use nvm to manage Node.js versions, the SDK may not be able to auto-detect the Qwen Code executable. You should explicitly set the `pathToQwenExecutable` option to the full path of the `qwen` binary.
## Quick Start
```typescript
import { query } from '@qwen-code/sdk-typescript';
import { query } from '@qwen-code/sdk';
// Single-turn query
const result = query({
@@ -59,9 +59,9 @@ Creates a new query session with the Qwen Code.
| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. |
| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. |
| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. |
| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 30 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). |
| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). |
| `env` | `Record<string, string>` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. |
| `mcpServers` | `Record<string, ExternalMcpServerConfig>` | - | External MCP (Model Context Protocol) servers to connect. Each server is identified by a unique name and configured with `command`, `args`, and `env`. |
| `mcpServers` | `Record<string, McpServerConfig>` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. |
| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. |
| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. |
| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. A turn consists of a user message and an assistant response. |
@@ -74,12 +74,27 @@ Creates a new query session with the Qwen Code.
### Timeouts
The SDK enforces the following timeouts:
The SDK enforces the following default timeouts:
| Timeout | Duration | Description |
| ------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Permission Callback | 30 seconds | Maximum time for `canUseTool` callback to respond. If exceeded, the tool request is auto-denied. |
| Control Request | 30 seconds | Maximum time for control operations like `initialize()`, `setModel()`, `setPermissionMode()`, and `interrupt()` to complete. |
| Timeout | Default | Description |
| ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `canUseTool` | 1 minute | Maximum time for `canUseTool` callback to respond. If exceeded, the tool request is auto-denied. |
| `mcpRequest` | 1 minute | Maximum time for SDK MCP tool calls to complete. |
| `controlRequest` | 1 minute | Maximum time for control operations like `initialize()`, `setModel()`, `setPermissionMode()`, and `interrupt()` to complete. |
| `streamClose` | 1 minute | Maximum time to wait for initialization to complete before closing CLI stdin in multi-turn mode with SDK MCP servers. |
You can customize these timeouts via the `timeout` option:
```typescript
const query = qwen.query('Your prompt', {
timeout: {
canUseTool: 60000, // 60 seconds for permission callback
mcpRequest: 600000, // 10 minutes for MCP tool calls
controlRequest: 60000, // 60 seconds for control requests
streamClose: 15000, // 15 seconds for stream close wait
},
});
```
### Message Types
@@ -92,7 +107,7 @@ import {
isSDKSystemMessage,
isSDKResultMessage,
isSDKPartialAssistantMessage,
} from '@qwen-code/sdk-typescript';
} from '@qwen-code/sdk';
for await (const message of result) {
if (isSDKAssistantMessage(message)) {
@@ -152,7 +167,7 @@ The SDK supports different permission modes for controlling tool execution:
### Multi-turn Conversation
```typescript
import { query, type SDKUserMessage } from '@qwen-code/sdk-typescript';
import { query, type SDKUserMessage } from '@qwen-code/sdk';
async function* generateMessages(): AsyncIterable<SDKUserMessage> {
yield {
@@ -186,7 +201,7 @@ for await (const message of result) {
### Custom Permission Handler
```typescript
import { query, type CanUseTool } from '@qwen-code/sdk-typescript';
import { query, type CanUseTool } from '@qwen-code/sdk';
const canUseTool: CanUseTool = async (toolName, input, { signal }) => {
// Allow all read operations
@@ -212,10 +227,10 @@ const result = query({
});
```
### With MCP Servers
### With External MCP Servers
```typescript
import { query } from '@qwen-code/sdk-typescript';
import { query } from '@qwen-code/sdk';
const result = query({
prompt: 'Use the custom tool from my MCP server',
@@ -231,10 +246,88 @@ const result = query({
});
```
### With SDK-Embedded MCP Servers
The SDK provides `tool` and `createSdkMcpServer` to create MCP servers that run in the same process as your SDK application. This is useful when you want to expose custom tools to the AI without running a separate server process.
#### `tool(name, description, inputSchema, handler)`
Creates a tool definition with Zod schema type inference.
| Parameter | Type | Description |
| ------------- | ---------------------------------- | ------------------------------------------------------------------------ |
| `name` | `string` | Tool name (1-64 chars, starts with letter, alphanumeric and underscores) |
| `description` | `string` | Human-readable description of what the tool does |
| `inputSchema` | `ZodRawShape` | Zod schema object defining the tool's input parameters |
| `handler` | `(args, extra) => Promise<Result>` | Async function that executes the tool and returns MCP content blocks |
The handler must return a `CallToolResult` object with the following structure:
```typescript
{
content: Array<
| { type: 'text'; text: string }
| { type: 'image'; data: string; mimeType: string }
| { type: 'resource'; uri: string; mimeType?: string; text?: string }
>;
isError?: boolean;
}
```
#### `createSdkMcpServer(options)`
Creates an SDK-embedded MCP server instance.
| Option | Type | Default | Description |
| --------- | ------------------------ | --------- | ------------------------------------ |
| `name` | `string` | Required | Unique name for the MCP server |
| `version` | `string` | `'1.0.0'` | Server version |
| `tools` | `SdkMcpToolDefinition[]` | - | Array of tools created with `tool()` |
Returns a `McpSdkServerConfigWithInstance` object that can be passed directly to the `mcpServers` option.
#### Example
```typescript
import { z } from 'zod';
import { query, tool, createSdkMcpServer } from '@qwen-code/sdk';
// Define a tool with Zod schema
const calculatorTool = tool(
'calculate_sum',
'Add two numbers',
{ a: z.number(), b: z.number() },
async (args) => ({
content: [{ type: 'text', text: String(args.a + args.b) }],
}),
);
// Create the MCP server
const server = createSdkMcpServer({
name: 'calculator',
tools: [calculatorTool],
});
// Use the server in a query
const result = query({
prompt: 'What is 42 + 17?',
options: {
permissionMode: 'yolo',
mcpServers: {
calculator: server,
},
},
});
for await (const message of result) {
console.log(message);
}
```
### Abort a Query
```typescript
import { query, isAbortError } from '@qwen-code/sdk-typescript';
import { query, isAbortError } from '@qwen-code/sdk';
const abortController = new AbortController();
@@ -266,7 +359,7 @@ try {
The SDK provides an `AbortError` class for handling aborted queries:
```typescript
import { AbortError, isAbortError } from '@qwen-code/sdk-typescript';
import { AbortError, isAbortError } from '@qwen-code/sdk';
try {
// ... query operations

View File

@@ -1,5 +1,5 @@
{
"name": "@qwen-code/sdk-typescript",
"name": "@qwen-code/sdk",
"version": "0.1.0",
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "./dist/index.cjs",

View File

@@ -14,7 +14,7 @@ import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PACKAGE_NAME = '@qwen-code/sdk-typescript';
const PACKAGE_NAME = '@qwen-code/sdk';
const TAG_PREFIX = 'sdk-typescript-v';
function readJson(filePath) {

View File

@@ -3,6 +3,17 @@ export { AbortError, isAbortError } from './types/errors.js';
export { Query } from './query/Query.js';
export { SdkLogger } from './utils/logger.js';
// SDK MCP Server exports
export { tool } from './mcp/tool.js';
export { createSdkMcpServer } from './mcp/createSdkMcpServer.js';
export type { SdkMcpToolDefinition } from './mcp/tool.js';
export type {
CreateSdkMcpServerOptions,
McpSdkServerConfigWithInstance,
} from './mcp/createSdkMcpServer.js';
export type { QueryOptions } from './query/createQuery.js';
export type { LogLevel, LoggerConfig, ScopedLogger } from './utils/logger.js';
@@ -18,6 +29,7 @@ export type {
SDKResultMessage,
SDKPartialAssistantMessage,
SDKMessage,
SDKMcpServerConfig,
ControlMessage,
CLIControlRequest,
CLIControlResponse,
@@ -43,6 +55,10 @@ export type {
PermissionMode,
CanUseTool,
PermissionResult,
ExternalMcpServerConfig,
SdkMcpServerConfig,
CLIMcpServerConfig,
McpServerConfig,
McpOAuthConfig,
McpAuthProviderType,
} from './types/types.js';
export { isSdkMcpServerConfig } from './types/types.js';

View File

@@ -103,9 +103,3 @@ export class SdkControlServerTransport {
return this.serverName;
}
}
export function createSdkControlServerTransport(
options: SdkControlServerTransportOptions,
): SdkControlServerTransport {
return new SdkControlServerTransport(options);
}

View File

@@ -1,29 +1,63 @@
/**
* Factory function to create SDK-embedded MCP servers
*
* Creates MCP Server instances that run in the user's Node.js process
* and are proxied to the CLI via the control plane.
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema,
type CallToolResultSchema,
} from '@modelcontextprotocol/sdk/types.js';
import type { ToolDefinition } from '../types/types.js';
import { formatToolResult, formatToolError } from './formatters.js';
/**
* Factory function to create SDK-embedded MCP servers
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { SdkMcpToolDefinition } from './tool.js';
import { validateToolName } from './tool.js';
import type { z } from 'zod';
type CallToolResult = z.infer<typeof CallToolResultSchema>;
/**
* Options for creating an SDK MCP server
*/
export type CreateSdkMcpServerOptions = {
name: string;
version?: string;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
tools?: Array<SdkMcpToolDefinition<any>>;
};
/**
* SDK MCP Server configuration with instance
*/
export type McpSdkServerConfigWithInstance = {
type: 'sdk';
name: string;
instance: McpServer;
};
/**
* Creates an MCP server instance that can be used with the SDK transport.
*
* @example
* ```typescript
* import { z } from 'zod';
* import { tool, createSdkMcpServer } from '@qwen-code/sdk';
*
* const calculatorTool = tool(
* 'calculate_sum',
* 'Add two numbers',
* { a: z.number(), b: z.number() },
* async (args) => ({ content: [{ type: 'text', text: String(args.a + args.b) }] })
* );
*
* const server = createSdkMcpServer({
* name: 'calculator',
* version: '1.0.0',
* tools: [calculatorTool],
* });
* ```
*/
export function createSdkMcpServer(
name: string,
version: string,
tools: ToolDefinition[],
): Server {
// Validate server name
options: CreateSdkMcpServerOptions,
): McpSdkServerConfigWithInstance {
const { name, version = '1.0.0', tools } = options;
if (!name || typeof name !== 'string') {
throw new Error('MCP server name must be a non-empty string');
}
@@ -32,78 +66,42 @@ export function createSdkMcpServer(
throw new Error('MCP server version must be a non-empty string');
}
if (!Array.isArray(tools)) {
if (tools !== undefined && !Array.isArray(tools)) {
throw new Error('Tools must be an array');
}
// Validate tool names are unique
const toolNames = new Set<string>();
for (const tool of tools) {
validateToolName(tool.name);
if (toolNames.has(tool.name)) {
throw new Error(
`Duplicate tool name '${tool.name}' in MCP server '${name}'`,
);
if (tools) {
for (const t of tools) {
validateToolName(t.name);
if (toolNames.has(t.name)) {
throw new Error(
`Duplicate tool name '${t.name}' in MCP server '${name}'`,
);
}
toolNames.add(t.name);
}
toolNames.add(tool.name);
}
// Create MCP Server instance
const server = new Server(
{
name,
version,
},
const server = new McpServer(
{ name, version },
{
capabilities: {
tools: {},
tools: tools ? {} : undefined,
},
},
);
// Create tool map for fast lookup
const toolMap = new Map<string, ToolDefinition>();
for (const tool of tools) {
toolMap.set(tool.name, tool);
if (tools) {
tools.forEach((toolDef) => {
server.tool(
toolDef.name,
toolDef.description,
toolDef.inputSchema,
toolDef.handler,
);
});
}
// Register list_tools handler
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
}));
// Register call_tool handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name: toolName, arguments: toolArgs } = request.params;
// Find tool
const tool = toolMap.get(toolName);
if (!tool) {
return formatToolError(
new Error(`Tool '${toolName}' not found in server '${name}'`),
) as CallToolResult;
}
try {
// Invoke tool handler
const result = await tool.handler(toolArgs);
// Format result
return formatToolResult(result) as CallToolResult;
} catch (error) {
// Handle tool execution error
return formatToolError(
error instanceof Error
? error
: new Error(`Tool '${toolName}' failed: ${String(error)}`),
) as CallToolResult;
}
});
return server;
return { type: 'sdk', name, instance: server };
}

View File

@@ -1,39 +1,76 @@
/**
* Tool definition helper for SDK-embedded MCP servers
*
* Provides type-safe tool definitions with generic input/output types.
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ToolDefinition } from '../types/types.js';
/**
* Tool definition helper for SDK-embedded MCP servers
*/
export function tool<TInput = unknown, TOutput = unknown>(
def: ToolDefinition<TInput, TOutput>,
): ToolDefinition<TInput, TOutput> {
// Validate tool definition
if (!def.name || typeof def.name !== 'string') {
throw new Error('Tool definition must have a name (string)');
import type { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import type { z, ZodRawShape, ZodObject, ZodTypeAny } from 'zod';
type CallToolResult = z.infer<typeof CallToolResultSchema>;
/**
* SDK MCP Tool Definition with Zod schema type inference
*/
export type SdkMcpToolDefinition<Schema extends ZodRawShape = ZodRawShape> = {
name: string;
description: string;
inputSchema: Schema;
handler: (
args: z.infer<ZodObject<Schema, 'strip', ZodTypeAny>>,
extra: unknown,
) => Promise<CallToolResult>;
};
/**
* Create an SDK MCP tool definition with Zod schema inference
*
* @example
* ```typescript
* import { z } from 'zod';
* import { tool } from '@qwen-code/sdk';
*
* const calculatorTool = tool(
* 'calculate_sum',
* 'Calculate the sum of two numbers',
* { a: z.number(), b: z.number() },
* async (args) => {
* // args is inferred as { a: number, b: number }
* return { content: [{ type: 'text', text: String(args.a + args.b) }] };
* }
* );
* ```
*/
export function tool<Schema extends ZodRawShape>(
name: string,
description: string,
inputSchema: Schema,
handler: (
args: z.infer<ZodObject<Schema, 'strip', ZodTypeAny>>,
extra: unknown,
) => Promise<CallToolResult>,
): SdkMcpToolDefinition<Schema> {
if (!name || typeof name !== 'string') {
throw new Error('Tool name must be a non-empty string');
}
if (!def.description || typeof def.description !== 'string') {
throw new Error(
`Tool definition for '${def.name}' must have a description (string)`,
);
if (!description || typeof description !== 'string') {
throw new Error(`Tool '${name}' must have a description (string)`);
}
if (!def.inputSchema || typeof def.inputSchema !== 'object') {
throw new Error(
`Tool definition for '${def.name}' must have an inputSchema (object)`,
);
if (!inputSchema || typeof inputSchema !== 'object') {
throw new Error(`Tool '${name}' must have an inputSchema (object)`);
}
if (!def.handler || typeof def.handler !== 'function') {
throw new Error(
`Tool definition for '${def.name}' must have a handler (function)`,
);
if (!handler || typeof handler !== 'function') {
throw new Error(`Tool '${name}' must have a handler (function)`);
}
// Return definition (pass-through for type safety)
return def;
return { name, description, inputSchema, handler };
}
export function validateToolName(name: string): void {
@@ -53,39 +90,3 @@ export function validateToolName(name: string): void {
);
}
}
export function validateInputSchema(schema: unknown): void {
if (!schema || typeof schema !== 'object') {
throw new Error('Input schema must be an object');
}
const schemaObj = schema as Record<string, unknown>;
if (!schemaObj.type) {
throw new Error('Input schema must have a type field');
}
// For object schemas, validate properties
if (schemaObj.type === 'object') {
if (schemaObj.properties && typeof schemaObj.properties !== 'object') {
throw new Error('Input schema properties must be an object');
}
if (schemaObj.required && !Array.isArray(schemaObj.required)) {
throw new Error('Input schema required must be an array');
}
}
}
export function createTool<TInput = unknown, TOutput = unknown>(
def: ToolDefinition<TInput, TOutput>,
): ToolDefinition<TInput, TOutput> {
// Validate via tool() function
const validated = tool(def);
// Additional validation
validateToolName(validated.name);
validateInputSchema(validated.inputSchema);
return validated;
}

View File

@@ -5,10 +5,10 @@
* Implements AsyncIterator protocol for message consumption.
*/
const PERMISSION_CALLBACK_TIMEOUT = 30000;
const MCP_REQUEST_TIMEOUT = 30000;
const CONTROL_REQUEST_TIMEOUT = 30000;
const STREAM_CLOSE_TIMEOUT = 10000;
const DEFAULT_CAN_USE_TOOL_TIMEOUT = 60_000;
const DEFAULT_MCP_REQUEST_TIMEOUT = 60_000;
const DEFAULT_CONTROL_REQUEST_TIMEOUT = 60_000;
const DEFAULT_STREAM_CLOSE_TIMEOUT = 60_000;
import { randomUUID } from 'node:crypto';
import { SdkLogger } from '../utils/logger.js';
@@ -19,6 +19,7 @@ import type {
CLIControlResponse,
ControlCancelRequest,
PermissionSuggestion,
WireSDKMcpServerConfig,
} from '../types/protocol.js';
import {
isSDKUserMessage,
@@ -31,12 +32,17 @@ import {
isControlCancel,
} from '../types/protocol.js';
import type { Transport } from '../transport/Transport.js';
import type { QueryOptions } from '../types/types.js';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { QueryOptions, CLIMcpServerConfig } from '../types/types.js';
import { isSdkMcpServerConfig } from '../types/types.js';
import { Stream } from '../utils/Stream.js';
import { serializeJsonLine } from '../utils/jsonLines.js';
import { AbortError } from '../types/errors.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { SdkControlServerTransport } from '../mcp/SdkControlServerTransport.js';
import {
SdkControlServerTransport,
type SdkControlServerTransportOptions,
} from '../mcp/SdkControlServerTransport.js';
import { ControlRequestType } from '../types/protocol.js';
interface PendingControlRequest {
@@ -46,6 +52,11 @@ interface PendingControlRequest {
abortController: AbortController;
}
interface PendingMcpResponse {
resolve: (response: JSONRPCMessage) => void;
reject: (error: Error) => void;
}
interface TransportWithEndInput extends Transport {
endInput(): void;
}
@@ -61,7 +72,9 @@ export class Query implements AsyncIterable<SDKMessage> {
private abortController: AbortController;
private pendingControlRequests: Map<string, PendingControlRequest> =
new Map();
private pendingMcpResponses: Map<string, PendingMcpResponse> = new Map();
private sdkMcpTransports: Map<string, SdkControlServerTransport> = new Map();
private sdkMcpServers: Map<string, McpServer> = new Map();
readonly initialized: Promise<void>;
private closed = false;
private messageRouterStarted = false;
@@ -92,6 +105,11 @@ export class Query implements AsyncIterable<SDKMessage> {
*/
this.sdkMessages = this.readSdkMessages();
/**
* Promise that resolves when the first SDKResultMessage is received.
* Used to coordinate endInput() timing - ensures all initialization
* (SDK MCP servers, control responses) is complete before closing CLI stdin.
*/
this.firstResultReceivedPromise = new Promise((resolve) => {
this.firstResultReceivedResolve = resolve;
});
@@ -121,17 +139,152 @@ export class Query implements AsyncIterable<SDKMessage> {
this.startMessageRouter();
}
private async initializeSdkMcpServers(): Promise<void> {
if (!this.options.mcpServers) {
return;
}
const connectionPromises: Array<Promise<void>> = [];
// Extract SDK MCP servers from the unified mcpServers config
for (const [key, config] of Object.entries(this.options.mcpServers)) {
if (!isSdkMcpServerConfig(config)) {
continue; // Skip external MCP servers
}
// Use the name from SDKMcpServerConfig, fallback to key for backwards compatibility
const serverName = config.name || key;
const server = config.instance;
// Create transport options with callback to route MCP server responses
const transportOptions: SdkControlServerTransportOptions = {
sendToQuery: async (message: JSONRPCMessage) => {
this.handleMcpServerResponse(serverName, message);
},
serverName,
};
const sdkTransport = new SdkControlServerTransport(transportOptions);
// Connect server to transport and only register on success
const connectionPromise = server
.connect(sdkTransport)
.then(() => {
// Only add to maps after successful connection
this.sdkMcpServers.set(serverName, server);
this.sdkMcpTransports.set(serverName, sdkTransport);
logger.debug(`SDK MCP server '${serverName}' connected to transport`);
})
.catch((error) => {
logger.error(
`Failed to connect SDK MCP server '${serverName}' to transport:`,
error,
);
// Don't throw - one failed server shouldn't prevent others
});
connectionPromises.push(connectionPromise);
}
// Wait for all connection attempts to complete
await Promise.all(connectionPromises);
if (this.sdkMcpServers.size > 0) {
logger.info(
`Initialized ${this.sdkMcpServers.size} SDK MCP server(s): ${Array.from(this.sdkMcpServers.keys()).join(', ')}`,
);
}
}
/**
* Handle response messages from SDK MCP servers
*
* When an MCP server sends a response via transport.send(), this callback
* routes it back to the pending request that's waiting for it.
*/
private handleMcpServerResponse(
serverName: string,
message: JSONRPCMessage,
): void {
// Check if this is a response with an id
if ('id' in message && message.id !== null && message.id !== undefined) {
const key = `${serverName}:${message.id}`;
const pending = this.pendingMcpResponses.get(key);
if (pending) {
logger.debug(
`Routing MCP response for server '${serverName}', id: ${message.id}`,
);
pending.resolve(message);
this.pendingMcpResponses.delete(key);
return;
}
}
// If no pending request found, log a warning (this shouldn't happen normally)
logger.warn(
`Received MCP server response with no pending request: server='${serverName}'`,
message,
);
}
/**
* Get SDK MCP servers config for CLI initialization
*
* Only SDK servers are sent in the initialize request.
*/
private getSdkMcpServersForCli(): Record<string, WireSDKMcpServerConfig> {
const sdkServers: Record<string, WireSDKMcpServerConfig> = {};
for (const [name] of this.sdkMcpServers.entries()) {
sdkServers[name] = { type: 'sdk', name };
}
return sdkServers;
}
/**
* Get external MCP servers (non-SDK) that should be managed by the CLI
*/
private getMcpServersForCli(): Record<string, CLIMcpServerConfig> {
if (!this.options.mcpServers) {
return {};
}
const externalServers: Record<string, CLIMcpServerConfig> = {};
for (const [name, config] of Object.entries(this.options.mcpServers)) {
if (isSdkMcpServerConfig(config)) {
continue;
}
externalServers[name] = config as CLIMcpServerConfig;
}
return externalServers;
}
private async initialize(): Promise<void> {
try {
logger.debug('Initializing Query');
const sdkMcpServerNames = Array.from(this.sdkMcpTransports.keys());
// Initialize SDK MCP servers and wait for connections
await this.initializeSdkMcpServers();
// Get only successfully connected SDK servers for CLI
const sdkMcpServersForCli = this.getSdkMcpServersForCli();
const mcpServersForCli = this.getMcpServersForCli();
logger.debug('SDK MCP servers for CLI:', sdkMcpServersForCli);
logger.debug('External MCP servers for CLI:', mcpServersForCli);
await this.sendControlRequest(ControlRequestType.INITIALIZE, {
hooks: null,
sdkMcpServers:
sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined,
mcpServers: this.options.mcpServers,
Object.keys(sdkMcpServersForCli).length > 0
? sdkMcpServersForCli
: undefined,
mcpServers:
Object.keys(mcpServersForCli).length > 0
? mcpServersForCli
: undefined,
agents: this.options.agents,
});
logger.info('Query initialized successfully');
@@ -279,10 +432,13 @@ export class Query implements AsyncIterable<SDKMessage> {
}
try {
const canUseToolTimeout =
this.options.timeout?.canUseTool ?? DEFAULT_CAN_USE_TOOL_TIMEOUT;
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
timeoutId = setTimeout(
() => reject(new Error('Permission callback timeout')),
PERMISSION_CALLBACK_TIMEOUT,
canUseToolTimeout,
);
});
@@ -296,6 +452,10 @@ export class Query implements AsyncIterable<SDKMessage> {
timeoutPromise,
]);
if (timeoutId) {
clearTimeout(timeoutId);
}
if (result.behavior === 'allow') {
return {
behavior: 'allow',
@@ -361,32 +521,45 @@ export class Query implements AsyncIterable<SDKMessage> {
}
private handleMcpRequest(
_serverName: string,
serverName: string,
message: JSONRPCMessage,
transport: SdkControlServerTransport,
): Promise<JSONRPCMessage> {
const messageId = 'id' in message ? message.id : null;
const key = `${serverName}:${messageId}`;
return new Promise((resolve, reject) => {
const mcpRequestTimeout =
this.options.timeout?.mcpRequest ?? DEFAULT_MCP_REQUEST_TIMEOUT;
const timeout = setTimeout(() => {
this.pendingMcpResponses.delete(key);
reject(new Error('MCP request timeout'));
}, MCP_REQUEST_TIMEOUT);
}, mcpRequestTimeout);
const messageId = 'id' in message ? message.id : null;
/**
* Hook into transport to capture response.
* Temporarily replace sendToQuery to intercept the response message
* matching this request's ID, then restore the original handler.
*/
const originalSend = transport.sendToQuery;
transport.sendToQuery = async (responseMessage: JSONRPCMessage) => {
if ('id' in responseMessage && responseMessage.id === messageId) {
clearTimeout(timeout);
transport.sendToQuery = originalSend;
resolve(responseMessage);
}
return originalSend(responseMessage);
const cleanup = () => {
clearTimeout(timeout);
this.pendingMcpResponses.delete(key);
};
const resolveAndCleanup = (response: JSONRPCMessage) => {
cleanup();
resolve(response);
};
const rejectAndCleanup = (error: Error) => {
cleanup();
reject(error);
};
// Register pending response handler
this.pendingMcpResponses.set(key, {
resolve: resolveAndCleanup,
reject: rejectAndCleanup,
});
// Deliver message to MCP server via transport.onmessage
// The server will process it and call transport.send() with the response,
// which triggers handleMcpServerResponse to resolve our pending promise
transport.handleMessage(message);
});
}
@@ -452,6 +625,10 @@ export class Query implements AsyncIterable<SDKMessage> {
subtype: string,
data: Record<string, unknown> = {},
): Promise<Record<string, unknown> | null> {
if (this.closed) {
return Promise.reject(new Error('Query is closed'));
}
const requestId = randomUUID();
const request: CLIControlRequest = {
@@ -466,10 +643,13 @@ export class Query implements AsyncIterable<SDKMessage> {
const responsePromise = new Promise<Record<string, unknown> | null>(
(resolve, reject) => {
const abortController = new AbortController();
const controlRequestTimeout =
this.options.timeout?.controlRequest ??
DEFAULT_CONTROL_REQUEST_TIMEOUT;
const timeout = setTimeout(() => {
this.pendingControlRequests.delete(requestId);
reject(new Error(`Control request timeout: ${subtype}`));
}, CONTROL_REQUEST_TIMEOUT);
}, controlRequestTimeout);
this.pendingControlRequests.set(requestId, {
resolve,
@@ -517,9 +697,16 @@ export class Query implements AsyncIterable<SDKMessage> {
for (const pending of this.pendingControlRequests.values()) {
pending.abortController.abort();
clearTimeout(pending.timeout);
pending.reject(new Error('Query is closed'));
}
this.pendingControlRequests.clear();
// Clean up pending MCP responses
for (const pending of this.pendingMcpResponses.values()) {
pending.reject(new Error('Query is closed'));
}
this.pendingMcpResponses.clear();
await this.transport.close();
/**
@@ -542,7 +729,7 @@ export class Query implements AsyncIterable<SDKMessage> {
}
}
this.sdkMcpTransports.clear();
logger.info('Query closed');
logger.info('Query is closed');
}
private async *readSdkMessages(): AsyncGenerator<SDKMessage> {
@@ -588,24 +775,39 @@ export class Query implements AsyncIterable<SDKMessage> {
}
/**
* In multi-turn mode with MCP servers, wait for first result
* to ensure MCP servers have time to process before next input.
* This prevents race conditions where the next input arrives before
* MCP servers have finished processing the current request.
* After all user messages are sent (for-await loop ended), determine when to
* close the CLI's stdin via endInput().
*
* - If a result message was already received: All initialization (SDK MCP servers,
* control responses, etc.) is complete, safe to close stdin immediately.
* - If no result yet: Wait for either the result to arrive, or the timeout to expire.
* This gives pending control_responses from SDK MCP servers or other modules
* time to complete their initialization before we close the input stream.
*
* The timeout ensures we don't hang indefinitely - either the turn proceeds
* normally, or it fails with a timeout, but Promise.race will always resolve.
*/
if (
!this.isSingleTurn &&
this.sdkMcpTransports.size > 0 &&
this.firstResultReceivedPromise
) {
await Promise.race([
this.firstResultReceivedPromise,
new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, STREAM_CLOSE_TIMEOUT);
}),
]);
const streamCloseTimeout =
this.options.timeout?.streamClose ?? DEFAULT_STREAM_CLOSE_TIMEOUT;
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<void>((resolve) => {
timeoutId = setTimeout(() => {
logger.info('streamCloseTimeout resolved');
resolve();
}, streamCloseTimeout);
});
await Promise.race([this.firstResultReceivedPromise, timeoutPromise]);
if (timeoutId) {
clearTimeout(timeoutId);
}
}
this.endInput();
@@ -635,28 +837,16 @@ export class Query implements AsyncIterable<SDKMessage> {
}
async interrupt(): Promise<void> {
if (this.closed) {
throw new Error('Query is closed');
}
await this.sendControlRequest(ControlRequestType.INTERRUPT);
}
async setPermissionMode(mode: string): Promise<void> {
if (this.closed) {
throw new Error('Query is closed');
}
await this.sendControlRequest(ControlRequestType.SET_PERMISSION_MODE, {
mode,
});
}
async setModel(model: string): Promise<void> {
if (this.closed) {
throw new Error('Query is closed');
}
await this.sendControlRequest(ControlRequestType.SET_MODEL, { model });
}
@@ -667,10 +857,6 @@ export class Query implements AsyncIterable<SDKMessage> {
* @throws Error if query is closed
*/
async supportedCommands(): Promise<Record<string, unknown> | null> {
if (this.closed) {
throw new Error('Query is closed');
}
return this.sendControlRequest(ControlRequestType.SUPPORTED_COMMANDS);
}
@@ -681,10 +867,6 @@ export class Query implements AsyncIterable<SDKMessage> {
* @throws Error if query is closed
*/
async mcpServerStatus(): Promise<Record<string, unknown> | null> {
if (this.closed) {
throw new Error('Query is closed');
}
return this.sendControlRequest(ControlRequestType.MCP_SERVER_STATUS);
}

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
export interface Annotation {
type: string;
value: string;
@@ -293,10 +294,44 @@ export interface MCPServerConfig {
targetServiceAccount?: string;
}
/**
* SDK MCP Server configuration
*
* SDK MCP servers run in the SDK process and are connected via in-memory transport.
* Tool calls are routed through the control plane between SDK and CLI.
*/
export interface SDKMcpServerConfig {
/**
* Type identifier for SDK MCP servers
*/
type: 'sdk';
/**
* Server name for identification and routing
*/
name: string;
/**
* The MCP Server instance created by createSdkMcpServer()
*/
instance: McpServer;
}
/**
* Wire format for SDK MCP servers sent to the CLI
*/
export type WireSDKMcpServerConfig = Omit<SDKMcpServerConfig, 'instance'>;
export interface CLIControlInitializeRequest {
subtype: 'initialize';
hooks?: HookRegistration[] | null;
sdkMcpServers?: 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, WireSDKMcpServerConfig>;
/**
* External MCP servers that should be managed by the CLI.
*/
mcpServers?: Record<string, MCPServerConfig>;
agents?: SubagentConfig[];
}

View File

@@ -2,19 +2,98 @@ import { z } from 'zod';
import type { CanUseTool } from './types.js';
import type { SubagentConfig } from './protocol.js';
export const ExternalMcpServerConfigSchema = z.object({
command: z.string().min(1, 'Command must be a non-empty string'),
/**
* OAuth configuration for MCP servers
*/
export const McpOAuthConfigSchema = z
.object({
enabled: z.boolean().optional(),
clientId: z
.string()
.min(1, 'clientId must be a non-empty string')
.optional(),
clientSecret: z.string().optional(),
scopes: z.array(z.string()).optional(),
redirectUri: z.string().optional(),
authorizationUrl: z.string().optional(),
tokenUrl: z.string().optional(),
audiences: z.array(z.string()).optional(),
tokenParamName: z.string().optional(),
registrationUrl: z.string().optional(),
})
.strict();
/**
* CLI MCP Server configuration schema
*
* Supports multiple transport types:
* - stdio: command, args, env, cwd
* - SSE: url
* - Streamable HTTP: httpUrl, headers
* - WebSocket: tcp
*/
export const CLIMcpServerConfigSchema = z.object({
// For stdio transport
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
cwd: z.string().optional(),
// For SSE transport
url: z.string().optional(),
// For streamable HTTP transport
httpUrl: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
// For WebSocket transport
tcp: z.string().optional(),
// Common
timeout: z.number().optional(),
trust: z.boolean().optional(),
// Metadata
description: z.string().optional(),
includeTools: z.array(z.string()).optional(),
excludeTools: z.array(z.string()).optional(),
extensionName: z.string().optional(),
// OAuth configuration
oauth: McpOAuthConfigSchema.optional(),
authProviderType: z
.enum([
'dynamic_discovery',
'google_credentials',
'service_account_impersonation',
])
.optional(),
// Service Account Configuration
targetAudience: z.string().optional(),
targetServiceAccount: z.string().optional(),
});
/**
* SDK MCP Server configuration schema
*/
export const SdkMcpServerConfigSchema = z.object({
connect: z.custom<(transport: unknown) => Promise<void>>(
(val) => typeof val === 'function',
{ message: 'connect must be a function' },
type: z.literal('sdk'),
name: z.string().min(1, 'name must be a non-empty string'),
instance: z.custom<{
connect(transport: unknown): Promise<void>;
close(): Promise<void>;
}>(
(val) =>
val &&
typeof val === 'object' &&
'connect' in val &&
typeof val.connect === 'function',
{ message: 'instance must be an MCP Server with connect method' },
),
});
/**
* Unified MCP Server configuration schema
*/
export const McpServerConfigSchema = z.union([
CLIMcpServerConfigSchema,
SdkMcpServerConfigSchema,
]);
export const ModelConfigSchema = z.object({
model: z.string().optional(),
temp: z.number().optional(),
@@ -37,6 +116,13 @@ export const SubagentConfigSchema = z.object({
isBuiltin: z.boolean().optional(),
});
export const TimeoutConfigSchema = z.object({
canUseTool: z.number().positive().optional(),
mcpRequest: z.number().positive().optional(),
controlRequest: z.number().positive().optional(),
streamClose: z.number().positive().optional(),
});
export const QueryOptionsSchema = z
.object({
cwd: z.string().optional(),
@@ -49,7 +135,7 @@ export const QueryOptionsSchema = z
message: 'canUseTool must be a function',
})
.optional(),
mcpServers: z.record(z.string(), ExternalMcpServerConfigSchema).optional(),
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
abortController: z.instanceof(AbortController).optional(),
debug: z.boolean().optional(),
stderr: z
@@ -78,5 +164,6 @@ export const QueryOptionsSchema = z
)
.optional(),
includePartialMessages: z.boolean().optional(),
timeout: TimeoutConfigSchema.optional(),
})
.strict();

View File

@@ -2,25 +2,11 @@ import type {
PermissionMode,
PermissionSuggestion,
SubagentConfig,
SDKMcpServerConfig,
} from './protocol.js';
export type { PermissionMode };
type JSONSchema = {
type: string;
properties?: Record<string, unknown>;
required?: string[];
description?: string;
[key: string]: unknown;
};
export type ToolDefinition<TInput = unknown, TOutput = unknown> = {
name: string;
description: string;
inputSchema: JSONSchema;
handler: (input: TInput) => Promise<TOutput>;
};
export type TransportOptions = {
pathToQwenExecutable: string;
cwd?: string;
@@ -61,14 +47,115 @@ export type PermissionResult =
interrupt?: boolean;
};
export interface ExternalMcpServerConfig {
command: string;
args?: string[];
env?: Record<string, string>;
/**
* OAuth configuration for MCP servers
*/
export interface McpOAuthConfig {
enabled?: boolean;
clientId?: string;
clientSecret?: string;
scopes?: string[];
redirectUri?: string;
authorizationUrl?: string;
tokenUrl?: string;
audiences?: string[];
tokenParamName?: string;
registrationUrl?: string;
}
export interface SdkMcpServerConfig {
connect: (transport: unknown) => Promise<void>;
/**
* Auth provider type for MCP servers
*/
export type McpAuthProviderType =
| 'dynamic_discovery'
| 'google_credentials'
| 'service_account_impersonation';
/**
* CLI MCP Server configuration
*
* Supports multiple transport types:
* - stdio: command, args, env, cwd
* - SSE: url
* - Streamable HTTP: httpUrl, headers
* - WebSocket: tcp
*
* This interface aligns with MCPServerConfig in @qwen-code/qwen-code-core.
*/
export interface CLIMcpServerConfig {
// For stdio transport
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
// For SSE transport
url?: string;
// For streamable HTTP transport
httpUrl?: string;
headers?: Record<string, string>;
// For WebSocket transport
tcp?: string;
// Common
timeout?: number;
trust?: boolean;
// Metadata
description?: string;
includeTools?: string[];
excludeTools?: string[];
extensionName?: string;
// OAuth configuration
oauth?: McpOAuthConfig;
authProviderType?: McpAuthProviderType;
// Service Account Configuration
/** targetAudience format: CLIENT_ID.apps.googleusercontent.com */
targetAudience?: string;
/** targetServiceAccount format: <service-account-name>@<project-num>.iam.gserviceaccount.com */
targetServiceAccount?: string;
}
/**
* Unified MCP Server configuration
*
* Supports both external MCP servers (stdio/SSE/HTTP/WebSocket) and SDK-embedded MCP servers.
*
* @example External MCP server (stdio)
* ```typescript
* mcpServers: {
* 'my-server': { command: 'node', args: ['server.js'] }
* }
* ```
*
* @example External MCP server (SSE)
* ```typescript
* mcpServers: {
* 'remote-server': { url: 'http://localhost:3000/sse' }
* }
* ```
*
* @example External MCP server (Streamable HTTP)
* ```typescript
* mcpServers: {
* 'http-server': { httpUrl: 'http://localhost:3000/mcp', headers: { 'Authorization': 'Bearer token' } }
* }
* ```
*
* @example SDK MCP server
* ```typescript
* const server = createSdkMcpServer('weather', '1.0.0', [weatherTool]);
* mcpServers: {
* 'weather': { type: 'sdk', name: 'weather', instance: server }
* }
* ```
*/
export type McpServerConfig = CLIMcpServerConfig | SDKMcpServerConfig;
/**
* Type guard to check if a config is an SDK MCP server
*/
export function isSdkMcpServerConfig(
config: McpServerConfig,
): config is SDKMcpServerConfig {
return 'type' in config && config.type === 'sdk';
}
/**
@@ -174,11 +261,36 @@ export interface QueryOptions {
canUseTool?: CanUseTool;
/**
* External MCP (Model Context Protocol) servers to connect to.
* Each server is identified by a unique name and configured with command, args, and environment.
* @example { 'my-server': { command: 'node', args: ['server.js'], env: { PORT: '3000' } } }
* MCP (Model Context Protocol) servers to connect to.
*
* Supports both external MCP servers and SDK-embedded MCP servers:
*
* **External MCP servers** - Run in separate processes, connected via stdio/SSE/HTTP:
* ```typescript
* mcpServers: {
* 'stdio-server': { command: 'node', args: ['server.js'], env: { PORT: '3000' } },
* 'sse-server': { url: 'http://localhost:3000/sse' },
* 'http-server': { httpUrl: 'http://localhost:3000/mcp' }
* }
* ```
*
* **SDK MCP servers** - Run in the SDK process, connected via in-memory transport:
* ```typescript
* const myTool = tool({
* name: 'my_tool',
* description: 'My custom tool',
* inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
* handler: async (input) => ({ result: input.input.toUpperCase() }),
* });
*
* const server = createSdkMcpServer('my-server', '1.0.0', [myTool]);
*
* mcpServers: {
* 'my-server': { type: 'sdk', name: 'my-server', instance: server }
* }
* ```
*/
mcpServers?: Record<string, ExternalMcpServerConfig>;
mcpServers?: Record<string, McpServerConfig>;
/**
* AbortController to cancel the query session.
@@ -204,7 +316,7 @@ export interface QueryOptions {
/**
* Logging level for the SDK.
* Controls the verbosity of log messages output by the SDK.
* @default 'info'
* @default 'error'
*/
logLevel?: 'debug' | 'info' | 'warn' | 'error';
@@ -294,4 +406,43 @@ export interface QueryOptions {
* @default false
*/
includePartialMessages?: boolean;
/**
* Timeout configuration for various SDK operations.
* All values are in milliseconds.
*/
timeout?: {
/**
* Timeout for the `canUseTool` callback.
* If the callback doesn't resolve within this time, the permission request
* will be denied with a timeout error (fail-safe behavior).
* @default 60000 (1 minute)
*/
canUseTool?: number;
/**
* Timeout for SDK MCP tool calls.
* This applies to tool calls made to SDK-embedded MCP servers.
* @default 60000 (1 minute)
*/
mcpRequest?: number;
/**
* Timeout for SDK→CLI control requests.
* This applies to internal control operations like initialize, interrupt,
* setPermissionMode, setModel, etc.
* @default 60000 (1 minute)
*/
controlRequest?: number;
/**
* Timeout for waiting before closing CLI's stdin after user messages are sent.
* In multi-turn mode with SDK MCP servers, after all user messages are processed,
* the SDK waits for the first result message to ensure all initialization
* (control responses, MCP server setup, etc.) is complete before closing stdin.
* This timeout is a fallback to avoid hanging indefinitely.
* @default 60000 (1 minute)
*/
streamClose?: number;
};
}

View File

@@ -22,7 +22,7 @@ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
export class SdkLogger {
private static config: LoggerConfig = {};
private static effectiveLevel: LogLevel = 'info';
private static effectiveLevel: LogLevel = 'error';
static configure(config: LoggerConfig): void {
this.config = config;
@@ -47,7 +47,7 @@ export class SdkLogger {
return 'debug';
}
return 'info';
return 'error';
}
private static isValidLogLevel(level: string): boolean {

View File

@@ -542,13 +542,16 @@ describe('Query', () => {
const canUseTool = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ behavior: 'allow' }), 35000); // Exceeds 30s timeout
setTimeout(() => resolve({ behavior: 'allow' }), 15000);
}),
);
const query = new Query(transport, {
cwd: '/test',
canUseTool,
timeout: {
canUseTool: 10000,
},
});
const controlReq = createControlRequest('can_use_tool', 'perm-req-4');
@@ -567,7 +570,7 @@ describe('Query', () => {
});
}
},
{ timeout: 35000 },
{ timeout: 15000 },
);
await query.close();
@@ -1204,7 +1207,12 @@ describe('Query', () => {
});
it('should handle control request timeout', async () => {
const query = new Query(transport, { cwd: '/test' });
const query = new Query(transport, {
cwd: '/test',
timeout: {
controlRequest: 10000,
},
});
// Respond to initialize
await vi.waitFor(() => {
@@ -1224,7 +1232,7 @@ describe('Query', () => {
await expect(interruptPromise).rejects.toThrow(/timeout/i);
await query.close();
}, 35000);
}, 15000);
it('should handle malformed control responses', async () => {
const query = new Query(transport, { cwd: '/test' });

View File

@@ -1,3 +1,9 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Unit tests for createSdkMcpServer
*
@@ -5,93 +11,112 @@
*/
import { describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import { createSdkMcpServer } from '../../src/mcp/createSdkMcpServer.js';
import { tool } from '../../src/mcp/tool.js';
import type { ToolDefinition } from '../../src/types/config.js';
import type { SdkMcpToolDefinition } from '../../src/mcp/tool.js';
describe('createSdkMcpServer', () => {
describe('Server Creation', () => {
it('should create server with name and version', () => {
const server = createSdkMcpServer('test-server', '1.0.0', []);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [],
});
expect(server).toBeDefined();
expect(server.type).toBe('sdk');
expect(server.name).toBe('test-server');
expect(server.instance).toBeDefined();
});
it('should create server with default version', () => {
const server = createSdkMcpServer({
name: 'test-server',
});
expect(server).toBeDefined();
expect(server.name).toBe('test-server');
});
it('should throw error with invalid name', () => {
expect(() => createSdkMcpServer('', '1.0.0', [])).toThrow(
'name must be a non-empty string',
expect(() => createSdkMcpServer({ name: '', version: '1.0.0' })).toThrow(
'MCP server name must be a non-empty string',
);
});
it('should throw error with invalid version', () => {
expect(() => createSdkMcpServer('test', '', [])).toThrow(
'version must be a non-empty string',
expect(() => createSdkMcpServer({ name: 'test', version: '' })).toThrow(
'MCP server version must be a non-empty string',
);
});
it('should throw error with non-array tools', () => {
expect(() =>
createSdkMcpServer('test', '1.0.0', {} as unknown as ToolDefinition[]),
createSdkMcpServer({
name: 'test',
version: '1.0.0',
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
tools: {} as unknown as SdkMcpToolDefinition<any>[],
}),
).toThrow('Tools must be an array');
});
});
describe('Tool Registration', () => {
it('should register single tool', () => {
const testTool = tool({
name: 'test_tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {
input: { type: 'string' },
},
},
handler: async () => 'result',
});
const testTool = tool(
'test_tool',
'A test tool',
{ input: z.string() },
async () => ({
content: [{ type: 'text', text: 'result' }],
}),
);
const server = createSdkMcpServer('test-server', '1.0.0', [testTool]);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [testTool],
});
expect(server).toBeDefined();
});
it('should register multiple tools', () => {
const tool1 = tool({
name: 'tool1',
description: 'Tool 1',
inputSchema: { type: 'object' },
handler: async () => 'result1',
});
const tool1 = tool('tool1', 'Tool 1', {}, async () => ({
content: [{ type: 'text', text: 'result1' }],
}));
const tool2 = tool({
name: 'tool2',
description: 'Tool 2',
inputSchema: { type: 'object' },
handler: async () => 'result2',
});
const tool2 = tool('tool2', 'Tool 2', {}, async () => ({
content: [{ type: 'text', text: 'result2' }],
}));
const server = createSdkMcpServer('test-server', '1.0.0', [tool1, tool2]);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [tool1, tool2],
});
expect(server).toBeDefined();
});
it('should throw error for duplicate tool names', () => {
const tool1 = tool({
name: 'duplicate',
description: 'Tool 1',
inputSchema: { type: 'object' },
handler: async () => 'result1',
});
const tool1 = tool('duplicate', 'Tool 1', {}, async () => ({
content: [{ type: 'text', text: 'result1' }],
}));
const tool2 = tool({
name: 'duplicate',
description: 'Tool 2',
inputSchema: { type: 'object' },
handler: async () => 'result2',
});
const tool2 = tool('duplicate', 'Tool 2', {}, async () => ({
content: [{ type: 'text', text: 'result2' }],
}));
expect(() =>
createSdkMcpServer('test-server', '1.0.0', [tool1, tool2]),
createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [tool1, tool2],
}),
).toThrow("Duplicate tool name 'duplicate'");
});
@@ -99,36 +124,41 @@ describe('createSdkMcpServer', () => {
const invalidTool = {
name: '123invalid', // Starts with number
description: 'Invalid tool',
inputSchema: { type: 'object' },
handler: async () => 'result',
inputSchema: {},
handler: async () => ({
content: [{ type: 'text' as const, text: 'result' }],
}),
};
expect(() =>
createSdkMcpServer('test-server', '1.0.0', [
invalidTool as unknown as ToolDefinition,
]),
createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
tools: [invalidTool as unknown as SdkMcpToolDefinition<any>],
}),
).toThrow('Tool name');
});
});
describe('Tool Handler Invocation', () => {
it('should invoke tool handler with correct input', async () => {
const handler = vi.fn().mockResolvedValue({ result: 'success' });
const testTool = tool({
name: 'test_tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {
value: { type: 'string' },
},
required: ['value'],
},
handler,
const handler = vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'success' }],
});
createSdkMcpServer('test-server', '1.0.0', [testTool]);
const testTool = tool(
'test_tool',
'A test tool',
{ value: z.string() },
handler,
);
createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [testTool],
});
// Note: Actual invocation testing requires MCP SDK integration
// This test verifies the handler was properly registered
@@ -140,17 +170,18 @@ describe('createSdkMcpServer', () => {
.fn()
.mockImplementation(async (input: { value: string }) => {
await new Promise((resolve) => setTimeout(resolve, 10));
return { processed: input.value };
return {
content: [{ type: 'text', text: `processed: ${input.value}` }],
};
});
const testTool = tool({
name: 'async_tool',
description: 'An async tool',
inputSchema: { type: 'object' },
handler,
});
const testTool = tool('async_tool', 'An async tool', {}, handler);
const server = createSdkMcpServer('test-server', '1.0.0', [testTool]);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [testTool],
});
expect(server).toBeDefined();
});
@@ -158,40 +189,29 @@ describe('createSdkMcpServer', () => {
describe('Type Safety', () => {
it('should preserve input type in handler', async () => {
type ToolInput = {
name: string;
age: number;
};
type ToolOutput = {
greeting: string;
};
const handler = vi
.fn()
.mockImplementation(async (input: ToolInput): Promise<ToolOutput> => {
return {
greeting: `Hello ${input.name}, age ${input.age}`,
};
});
const typedTool = tool<ToolInput, ToolOutput>({
name: 'typed_tool',
description: 'A typed tool',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name', 'age'],
},
handler,
const handler = vi.fn().mockImplementation(async (input) => {
return {
content: [
{ type: 'text', text: `Hello ${input.name}, age ${input.age}` },
],
};
});
const server = createSdkMcpServer('test-server', '1.0.0', [
typedTool as ToolDefinition,
]);
const typedTool = tool(
'typed_tool',
'A typed tool',
{
name: z.string(),
age: z.number(),
},
handler,
);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [typedTool],
});
expect(server).toBeDefined();
});
@@ -201,14 +221,13 @@ describe('createSdkMcpServer', () => {
it('should handle tool handler errors gracefully', async () => {
const handler = vi.fn().mockRejectedValue(new Error('Tool failed'));
const errorTool = tool({
name: 'error_tool',
description: 'A tool that errors',
inputSchema: { type: 'object' },
handler,
});
const errorTool = tool('error_tool', 'A tool that errors', {}, handler);
const server = createSdkMcpServer('test-server', '1.0.0', [errorTool]);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [errorTool],
});
expect(server).toBeDefined();
// Error handling occurs during tool invocation
@@ -219,14 +238,18 @@ describe('createSdkMcpServer', () => {
throw new Error('Sync error');
});
const errorTool = tool({
name: 'sync_error_tool',
description: 'A tool that errors synchronously',
inputSchema: { type: 'object' },
const errorTool = tool(
'sync_error_tool',
'A tool that errors synchronously',
{},
handler,
});
);
const server = createSdkMcpServer('test-server', '1.0.0', [errorTool]);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [errorTool],
});
expect(server).toBeDefined();
});
@@ -234,69 +257,76 @@ describe('createSdkMcpServer', () => {
describe('Complex Tool Scenarios', () => {
it('should support tool with complex input schema', () => {
const complexTool = tool({
name: 'complex_tool',
description: 'A tool with complex schema',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
filters: {
type: 'object',
properties: {
category: { type: 'string' },
minPrice: { type: 'number' },
},
},
options: {
type: 'array',
items: { type: 'string' },
},
},
required: ['query'],
const complexTool = tool(
'complex_tool',
'A tool with complex schema',
{
query: z.string(),
filters: z
.object({
category: z.string().optional(),
minPrice: z.number().optional(),
})
.optional(),
options: z.array(z.string()).optional(),
},
handler: async (input: { filters?: unknown[] }) => {
async (input) => {
return {
results: [],
filters: input.filters,
content: [
{
type: 'text',
text: JSON.stringify({ results: [], filters: input.filters }),
},
],
};
},
});
);
const server = createSdkMcpServer('test-server', '1.0.0', [
complexTool as ToolDefinition,
]);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [complexTool],
});
expect(server).toBeDefined();
});
it('should support tool returning complex output', () => {
const complexOutputTool = tool({
name: 'complex_output_tool',
description: 'Returns complex data',
inputSchema: { type: 'object' },
handler: async () => {
const complexOutputTool = tool(
'complex_output_tool',
'Returns complex data',
{},
async () => {
return {
data: [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
],
metadata: {
total: 2,
page: 1,
},
nested: {
deep: {
value: 'test',
content: [
{
type: 'text',
text: JSON.stringify({
data: [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
],
metadata: {
total: 2,
page: 1,
},
nested: {
deep: {
value: 'test',
},
},
}),
},
},
],
};
},
});
);
const server = createSdkMcpServer('test-server', '1.0.0', [
complexOutputTool,
]);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [complexOutputTool],
});
expect(server).toBeDefined();
});
@@ -304,44 +334,50 @@ describe('createSdkMcpServer', () => {
describe('Multiple Servers', () => {
it('should create multiple independent servers', () => {
const tool1 = tool({
name: 'tool1',
description: 'Tool in server 1',
inputSchema: { type: 'object' },
handler: async () => 'result1',
});
const tool1 = tool('tool1', 'Tool in server 1', {}, async () => ({
content: [{ type: 'text', text: 'result1' }],
}));
const tool2 = tool({
name: 'tool2',
description: 'Tool in server 2',
inputSchema: { type: 'object' },
handler: async () => 'result2',
});
const tool2 = tool('tool2', 'Tool in server 2', {}, async () => ({
content: [{ type: 'text', text: 'result2' }],
}));
const server1 = createSdkMcpServer('server1', '1.0.0', [tool1]);
const server2 = createSdkMcpServer('server2', '1.0.0', [tool2]);
const server1 = createSdkMcpServer({
name: 'server1',
version: '1.0.0',
tools: [tool1],
});
const server2 = createSdkMcpServer({
name: 'server2',
version: '1.0.0',
tools: [tool2],
});
expect(server1).toBeDefined();
expect(server2).toBeDefined();
expect(server1.name).toBe('server1');
expect(server2.name).toBe('server2');
});
it('should allow same tool name in different servers', () => {
const tool1 = tool({
name: 'shared_name',
description: 'Tool in server 1',
inputSchema: { type: 'object' },
handler: async () => 'result1',
});
const tool1 = tool('shared_name', 'Tool in server 1', {}, async () => ({
content: [{ type: 'text', text: 'result1' }],
}));
const tool2 = tool({
name: 'shared_name',
description: 'Tool in server 2',
inputSchema: { type: 'object' },
handler: async () => 'result2',
});
const tool2 = tool('shared_name', 'Tool in server 2', {}, async () => ({
content: [{ type: 'text', text: 'result2' }],
}));
const server1 = createSdkMcpServer('server1', '1.0.0', [tool1]);
const server2 = createSdkMcpServer('server2', '1.0.0', [tool2]);
const server1 = createSdkMcpServer({
name: 'server1',
version: '1.0.0',
tools: [tool1],
});
const server2 = createSdkMcpServer({
name: 'server2',
version: '1.0.0',
tools: [tool2],
});
expect(server1).toBeDefined();
expect(server2).toBeDefined();