mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-22 16:56:19 +00:00
Compare commits
60 Commits
chore/rele
...
v0.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40959d6cb1 | ||
|
|
5c884fd395 | ||
|
|
0073c77267 | ||
|
|
418aeb069d | ||
|
|
570ec432af | ||
|
|
bfc3bbfa9c | ||
|
|
91af9bf6c8 | ||
|
|
f6771c0858 | ||
|
|
2c8be05029 | ||
|
|
4744af1ea8 | ||
|
|
2c285394c7 | ||
|
|
9b2dfe1e06 | ||
|
|
3e695cd82b | ||
|
|
177a91f1d5 | ||
|
|
870d207f18 | ||
|
|
3f512528cb | ||
|
|
0878ee4cbd | ||
|
|
bfe7298858 | ||
|
|
2f2937aafe | ||
|
|
8fcdd86b91 | ||
|
|
d7d7bf0c39 | ||
|
|
b95d9a8d2d | ||
|
|
6f39ae120c | ||
|
|
627857621a | ||
|
|
65c7cf5d8f | ||
|
|
7a823060ac | ||
|
|
2c88ea6dc1 | ||
|
|
ad3086f7dd | ||
|
|
8f3bbef575 | ||
|
|
e2d6ab9b7e | ||
|
|
35bf5ef4d0 | ||
|
|
1d16513e27 | ||
|
|
731fd99800 | ||
|
|
c6ae0a8be7 | ||
|
|
19f8f631b4 | ||
|
|
e5cced8813 | ||
|
|
aaa66b3172 | ||
|
|
15912892f2 | ||
|
|
e3c20b03bd | ||
|
|
4db50d4158 | ||
|
|
61aad5a162 | ||
|
|
98c043bf50 | ||
|
|
f610133660 | ||
|
|
fe7ff5b148 | ||
|
|
5417de4219 | ||
|
|
43e0815def | ||
|
|
0c14f4ce08 | ||
|
|
34d8dbf9b2 | ||
|
|
b3b2bc6ad5 | ||
|
|
6ca54beba2 | ||
|
|
8673426d5c | ||
|
|
b272ac0119 | ||
|
|
574d89da14 | ||
|
|
16939c0bc8 | ||
|
|
6fc09a82fb | ||
|
|
d622f8d1bf | ||
|
|
28d178b5c1 | ||
|
|
4c69d536ac | ||
|
|
403fd06117 | ||
|
|
d9928eab66 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ package-lock.json
|
||||
.idea
|
||||
*.iml
|
||||
.cursor
|
||||
.qoder
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
|
||||
@@ -11,6 +11,7 @@ export default {
|
||||
type: 'separator',
|
||||
},
|
||||
'sdk-typescript': 'Typescript SDK',
|
||||
'sdk-java': 'Java SDK(alpha)',
|
||||
'Dive Into Qwen Code': {
|
||||
title: 'Dive Into Qwen Code',
|
||||
type: 'separator',
|
||||
|
||||
312
docs/developers/sdk-java.md
Normal file
312
docs/developers/sdk-java.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Qwen Code Java SDK
|
||||
|
||||
The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java >= 1.8
|
||||
- Maven >= 3.6.0 (for building from source)
|
||||
- qwen-code >= 0.5.0
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **Logging**: ch.qos.logback:logback-classic
|
||||
- **Utilities**: org.apache.commons:commons-lang3
|
||||
- **JSON Processing**: com.alibaba.fastjson2:fastjson2
|
||||
- **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter)
|
||||
|
||||
## Installation
|
||||
|
||||
Add the following dependency to your Maven `pom.xml`:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>qwencode-sdk</artifactId>
|
||||
<version>{$version}</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Or if using Gradle, add to your `build.gradle`:
|
||||
|
||||
```gradle
|
||||
implementation 'com.alibaba:qwencode-sdk:{$version}'
|
||||
```
|
||||
|
||||
## Building and Running
|
||||
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
# Compile the project
|
||||
mvn compile
|
||||
|
||||
# Run tests
|
||||
mvn test
|
||||
|
||||
# Package the JAR
|
||||
mvn package
|
||||
|
||||
# Install to local repository
|
||||
mvn install
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
The simplest way to use the SDK is through the `QwenCodeCli.simpleQuery()` method:
|
||||
|
||||
```java
|
||||
public static void runSimpleExample() {
|
||||
List<String> result = QwenCodeCli.simpleQuery("hello world");
|
||||
result.forEach(logger::info);
|
||||
}
|
||||
```
|
||||
|
||||
For more advanced usage with custom transport options:
|
||||
|
||||
```java
|
||||
public static void runTransportOptionsExample() {
|
||||
TransportOptions options = new TransportOptions()
|
||||
.setModel("qwen3-coder-flash")
|
||||
.setPermissionMode(PermissionMode.AUTO_EDIT)
|
||||
.setCwd("./")
|
||||
.setEnv(new HashMap<String, String>() {{put("CUSTOM_VAR", "value");}})
|
||||
.setIncludePartialMessages(true)
|
||||
.setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS))
|
||||
.setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS))
|
||||
.setAllowedTools(Arrays.asList("read_file", "write_file", "list_directory"));
|
||||
|
||||
List<String> result = QwenCodeCli.simpleQuery("who are you, what are your capabilities?", options);
|
||||
result.forEach(logger::info);
|
||||
}
|
||||
```
|
||||
|
||||
For streaming content handling with custom content consumers:
|
||||
|
||||
```java
|
||||
public static void runStreamingExample() {
|
||||
QwenCodeCli.simpleQuery("who are you, what are your capabilities?",
|
||||
new TransportOptions().setMessageTimeout(new Timeout(10L, TimeUnit.SECONDS)), new AssistantContentSimpleConsumers() {
|
||||
|
||||
@Override
|
||||
public void onText(Session session, TextAssistantContent textAssistantContent) {
|
||||
logger.info("Text content received: {}", textAssistantContent.getText());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) {
|
||||
logger.info("Thinking content received: {}", thingkingAssistantContent.getThinking());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onToolUse(Session session, ToolUseAssistantContent toolUseContent) {
|
||||
logger.info("Tool use content received: {} with arguments: {}",
|
||||
toolUseContent, toolUseContent.getInput());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onToolResult(Session session, ToolResultAssistantContent toolResultContent) {
|
||||
logger.info("Tool result content received: {}", toolResultContent.getContent());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOtherContent(Session session, AssistantContent<?> other) {
|
||||
logger.info("Other content received: {}", other);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUsage(Session session, AssistantUsage assistantUsage) {
|
||||
logger.info("Usage information received: Input tokens: {}, Output tokens: {}",
|
||||
assistantUsage.getUsage().getInputTokens(), assistantUsage.getUsage().getOutputTokens());
|
||||
}
|
||||
}.setDefaultPermissionOperation(Operation.allow));
|
||||
logger.info("Streaming example completed.");
|
||||
}
|
||||
```
|
||||
|
||||
other examples see src/test/java/com/alibaba/qwen/code/cli/example
|
||||
|
||||
## Architecture
|
||||
|
||||
The SDK follows a layered architecture:
|
||||
|
||||
- **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage
|
||||
- **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class
|
||||
- **Transport Layer**: Handles the communication mechanism between the SDK and CLI process (currently using process transport via `ProcessTransport`)
|
||||
- **Protocol Layer**: Defines data structures for communication based on the CLI protocol
|
||||
- **Utils**: Common utilities for concurrent execution, timeout handling, and error management
|
||||
|
||||
## Key Features
|
||||
|
||||
### Permission Modes
|
||||
|
||||
The SDK supports different permission modes for controlling tool execution:
|
||||
|
||||
- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation.
|
||||
- **`plan`**: Blocks all write tools, instructing AI to present a plan first.
|
||||
- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation.
|
||||
- **`yolo`**: All tools execute automatically without confirmation.
|
||||
|
||||
### Session Event Consumers and Assistant Content Consumers
|
||||
|
||||
The SDK provides two key interfaces for handling events and content from the CLI:
|
||||
|
||||
#### SessionEventConsumers Interface
|
||||
|
||||
The `SessionEventConsumers` interface provides callbacks for different types of messages during a session:
|
||||
|
||||
- `onSystemMessage`: Handles system messages from the CLI (receives Session and SDKSystemMessage)
|
||||
- `onResultMessage`: Handles result messages from the CLI (receives Session and SDKResultMessage)
|
||||
- `onAssistantMessage`: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage)
|
||||
- `onPartialAssistantMessage`: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage)
|
||||
- `onUserMessage`: Handles user messages (receives Session and SDKUserMessage)
|
||||
- `onOtherMessage`: Handles other types of messages (receives Session and String message)
|
||||
- `onControlResponse`: Handles control responses (receives Session and CLIControlResponse)
|
||||
- `onControlRequest`: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse)
|
||||
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlRequest<CLIControlPermissionRequest>, returns Behavior)
|
||||
|
||||
#### AssistantContentConsumers Interface
|
||||
|
||||
The `AssistantContentConsumers` interface handles different types of content within assistant messages:
|
||||
|
||||
- `onText`: Handles text content (receives Session and TextAssistantContent)
|
||||
- `onThinking`: Handles thinking content (receives Session and ThingkingAssistantContent)
|
||||
- `onToolUse`: Handles tool use content (receives Session and ToolUseAssistantContent)
|
||||
- `onToolResult`: Handles tool result content (receives Session and ToolResultAssistantContent)
|
||||
- `onOtherContent`: Handles other content types (receives Session and AssistantContent)
|
||||
- `onUsage`: Handles usage information (receives Session and AssistantUsage)
|
||||
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlPermissionRequest, returns Behavior)
|
||||
- `onOtherControlRequest`: Handles other control requests (receives Session and ControlRequestPayload, returns ControlResponsePayload)
|
||||
|
||||
#### Relationship Between the Interfaces
|
||||
|
||||
**Important Note on Event Hierarchy:**
|
||||
|
||||
- `SessionEventConsumers` is the **high-level** event processor that handles different message types (system, assistant, user, etc.)
|
||||
- `AssistantContentConsumers` is the **low-level** content processor that handles different types of content within assistant messages (text, tools, thinking, etc.)
|
||||
|
||||
**Processor Relationship:**
|
||||
|
||||
- `SessionEventConsumers` → `AssistantContentConsumers` (SessionEventConsumers uses AssistantContentConsumers to process content within assistant messages)
|
||||
|
||||
**Event Derivation Relationships:**
|
||||
|
||||
- `onAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`, `onUsage`
|
||||
- `onPartialAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`
|
||||
- `onControlRequest` → `onPermissionRequest`, `onOtherControlRequest`
|
||||
|
||||
**Event Timeout Relationships:**
|
||||
|
||||
Each event handler method has a corresponding timeout method that allows customizing the timeout behavior for that specific event:
|
||||
|
||||
- `onSystemMessage` ↔ `onSystemMessageTimeout`
|
||||
- `onResultMessage` ↔ `onResultMessageTimeout`
|
||||
- `onAssistantMessage` ↔ `onAssistantMessageTimeout`
|
||||
- `onPartialAssistantMessage` ↔ `onPartialAssistantMessageTimeout`
|
||||
- `onUserMessage` ↔ `onUserMessageTimeout`
|
||||
- `onOtherMessage` ↔ `onOtherMessageTimeout`
|
||||
- `onControlResponse` ↔ `onControlResponseTimeout`
|
||||
- `onControlRequest` ↔ `onControlRequestTimeout`
|
||||
|
||||
For AssistantContentConsumers timeout methods:
|
||||
|
||||
- `onText` ↔ `onTextTimeout`
|
||||
- `onThinking` ↔ `onThinkingTimeout`
|
||||
- `onToolUse` ↔ `onToolUseTimeout`
|
||||
- `onToolResult` ↔ `onToolResultTimeout`
|
||||
- `onOtherContent` ↔ `onOtherContentTimeout`
|
||||
- `onPermissionRequest` ↔ `onPermissionRequestTimeout`
|
||||
- `onOtherControlRequest` ↔ `onOtherControlRequestTimeout`
|
||||
|
||||
**Default Timeout Values:**
|
||||
|
||||
- `SessionEventSimpleConsumers` default timeout: 180 seconds (Timeout.TIMEOUT_180_SECONDS)
|
||||
- `AssistantContentSimpleConsumers` default timeout: 60 seconds (Timeout.TIMEOUT_60_SECONDS)
|
||||
|
||||
**Timeout Hierarchy Requirements:**
|
||||
|
||||
For proper operation, the following timeout relationships should be maintained:
|
||||
|
||||
- `onAssistantMessageTimeout` return value should be greater than `onTextTimeout`, `onThinkingTimeout`, `onToolUseTimeout`, `onToolResultTimeout`, and `onOtherContentTimeout` return values
|
||||
- `onControlRequestTimeout` return value should be greater than `onPermissionRequestTimeout` and `onOtherControlRequestTimeout` return values
|
||||
|
||||
### Transport Options
|
||||
|
||||
The `TransportOptions` class allows configuration of how the SDK communicates with the Qwen Code CLI:
|
||||
|
||||
- `pathToQwenExecutable`: Path to the Qwen Code CLI executable
|
||||
- `cwd`: Working directory for the CLI process
|
||||
- `model`: AI model to use for the session
|
||||
- `permissionMode`: Permission mode that controls tool execution
|
||||
- `env`: Environment variables to pass to the CLI process
|
||||
- `maxSessionTurns`: Limits the number of conversation turns in a session
|
||||
- `coreTools`: List of core tools that should be available to the AI
|
||||
- `excludeTools`: List of tools to exclude from being available to the AI
|
||||
- `allowedTools`: List of tools that are pre-approved for use without additional confirmation
|
||||
- `authType`: Authentication type to use for the session
|
||||
- `includePartialMessages`: Enables receiving partial messages during streaming responses
|
||||
- `skillsEnable`: Enables or disables skills functionality for the session
|
||||
- `turnTimeout`: Timeout for a complete turn of conversation
|
||||
- `messageTimeout`: Timeout for individual messages within a turn
|
||||
- `resumeSessionId`: ID of a previous session to resume
|
||||
- `otherOptions`: Additional command-line options to pass to the CLI
|
||||
|
||||
### Session Control Features
|
||||
|
||||
- **Session creation**: Use `QwenCodeCli.newSession()` to create a new session with custom options
|
||||
- **Session management**: The `Session` class provides methods to send prompts, handle responses, and manage session state
|
||||
- **Session cleanup**: Always close sessions using `session.close()` to properly terminate the CLI process
|
||||
- **Session resumption**: Use `setResumeSessionId()` in `TransportOptions` to resume a previous session
|
||||
- **Session interruption**: Use `session.interrupt()` to interrupt a currently running prompt
|
||||
- **Dynamic model switching**: Use `session.setModel()` to change the model during a session
|
||||
- **Dynamic permission mode switching**: Use `session.setPermissionMode()` to change the permission mode during a session
|
||||
|
||||
### Thread Pool Configuration
|
||||
|
||||
The SDK uses a thread pool for managing concurrent operations with the following default configuration:
|
||||
|
||||
- **Core Pool Size**: 30 threads
|
||||
- **Maximum Pool Size**: 100 threads
|
||||
- **Keep-Alive Time**: 60 seconds
|
||||
- **Queue Capacity**: 300 tasks (using LinkedBlockingQueue)
|
||||
- **Thread Naming**: "qwen_code_cli-pool-{number}"
|
||||
- **Daemon Threads**: false
|
||||
- **Rejected Execution Handler**: CallerRunsPolicy
|
||||
|
||||
## Error Handling
|
||||
|
||||
The SDK provides specific exception types for different error scenarios:
|
||||
|
||||
- `SessionControlException`: Thrown when there's an issue with session control (creation, initialization, etc.)
|
||||
- `SessionSendPromptException`: Thrown when there's an issue sending a prompt or receiving a response
|
||||
- `SessionClosedException`: Thrown when attempting to use a closed session
|
||||
|
||||
## FAQ / Troubleshooting
|
||||
|
||||
### Q: Do I need to install the Qwen CLI separately?
|
||||
|
||||
A: yes, requires Qwen CLI 0.5.5 or higher.
|
||||
|
||||
### Q: What Java versions are supported?
|
||||
|
||||
A: The SDK requires Java 1.8 or higher.
|
||||
|
||||
### Q: How do I handle long-running requests?
|
||||
|
||||
A: The SDK includes timeout utilities. You can configure timeouts using the `Timeout` class in `TransportOptions`.
|
||||
|
||||
### Q: Why are some tools not executing?
|
||||
|
||||
A: This is likely due to permission modes. Check your permission mode settings and consider using `allowedTools` to pre-approve certain tools.
|
||||
|
||||
### Q: How do I resume a previous session?
|
||||
|
||||
A: Use the `setResumeSessionId()` method in `TransportOptions` to resume a previous session.
|
||||
|
||||
### Q: Can I customize the environment for the CLI process?
|
||||
|
||||
A: Yes, use the `setEnv()` method in `TransportOptions` to pass environment variables to the CLI process.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 - see [LICENSE](./LICENSE) for details.
|
||||
@@ -381,7 +381,7 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
|
||||
| `--experimental-acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Experimental. |
|
||||
| `--acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. |
|
||||
| `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. |
|
||||
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
|
||||
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"Qwen Code": {
|
||||
"type": "custom",
|
||||
"command": "qwen",
|
||||
"args": ["--experimental-acp"],
|
||||
"args": ["--acp"],
|
||||
"env": {}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -80,10 +80,11 @@ type PermissionHandler = (
|
||||
|
||||
/**
|
||||
* Sets up an ACP test environment with all necessary utilities.
|
||||
* @param useNewFlag - If true, uses --acp; if false, uses --experimental-acp (for backward compatibility testing)
|
||||
*/
|
||||
function setupAcpTest(
|
||||
rig: TestRig,
|
||||
options?: { permissionHandler?: PermissionHandler },
|
||||
options?: { permissionHandler?: PermissionHandler; useNewFlag?: boolean },
|
||||
) {
|
||||
const pending = new Map<number, PendingRequest>();
|
||||
let nextRequestId = 1;
|
||||
@@ -95,9 +96,13 @@ function setupAcpTest(
|
||||
const permissionHandler =
|
||||
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' }));
|
||||
|
||||
// Use --acp by default, but allow testing with --experimental-acp for backward compatibility
|
||||
const acpFlag =
|
||||
options?.useNewFlag !== false ? '--acp' : '--experimental-acp';
|
||||
|
||||
const agent = spawn(
|
||||
'node',
|
||||
[rig.bundlePath, '--experimental-acp', '--no-chat-recording'],
|
||||
[rig.bundlePath, acpFlag, '--no-chat-recording'],
|
||||
{
|
||||
cwd: rig.testDir!,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -621,3 +626,99 @@ function setupAcpTest(
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(IS_SANDBOX ? describe.skip : describe)(
|
||||
'acp flag backward compatibility',
|
||||
() => {
|
||||
it('should work with deprecated --experimental-acp flag and show warning', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp backward compatibility');
|
||||
|
||||
const { sendRequest, cleanup, stderr } = setupAcpTest(rig, {
|
||||
useNewFlag: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const initResult = await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
},
|
||||
});
|
||||
expect(initResult).toBeDefined();
|
||||
|
||||
// Verify deprecation warning is shown
|
||||
const stderrOutput = stderr.join('');
|
||||
expect(stderrOutput).toContain('--experimental-acp is deprecated');
|
||||
expect(stderrOutput).toContain('Please use --acp instead');
|
||||
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
expect(newSession.sessionId).toBeTruthy();
|
||||
|
||||
// Verify functionality still works
|
||||
const promptResult = await sendRequest('session/prompt', {
|
||||
sessionId: newSession.sessionId,
|
||||
prompt: [{ type: 'text', text: 'Say hello.' }],
|
||||
});
|
||||
expect(promptResult).toBeDefined();
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('should work with new --acp flag without warnings', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp new flag');
|
||||
|
||||
const { sendRequest, cleanup, stderr } = setupAcpTest(rig, {
|
||||
useNewFlag: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const initResult = await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
},
|
||||
});
|
||||
expect(initResult).toBeDefined();
|
||||
|
||||
// Verify no deprecation warning is shown
|
||||
const stderrOutput = stderr.join('');
|
||||
expect(stderrOutput).not.toContain('--experimental-acp is deprecated');
|
||||
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
expect(newSession.sessionId).toBeTruthy();
|
||||
|
||||
// Verify functionality works
|
||||
const promptResult = await sendRequest('session/prompt', {
|
||||
sessionId: newSession.sessionId,
|
||||
prompt: [{ type: 'text', text: 'Say hello.' }],
|
||||
});
|
||||
expect(promptResult).toBeDefined();
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -17316,7 +17316,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -17953,7 +17953,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.36.1",
|
||||
@@ -21413,7 +21413,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -21425,7 +21425,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,7 +33,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
|
||||
@@ -1597,6 +1597,58 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
});
|
||||
|
||||
it('should not exclude a tool explicitly allowed in tools.allowed', async () => {
|
||||
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
allowed: [ShellTool.Name],
|
||||
},
|
||||
};
|
||||
const extensions: Extension[] = [];
|
||||
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
extensions,
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
argv,
|
||||
);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
expect(excludedTools).not.toContain(ShellTool.Name);
|
||||
expect(excludedTools).toContain(EditTool.Name);
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
});
|
||||
|
||||
it('should not exclude a tool explicitly allowed in tools.core', async () => {
|
||||
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
core: [ShellTool.Name],
|
||||
},
|
||||
};
|
||||
const extensions: Extension[] = [];
|
||||
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
extensions,
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
argv,
|
||||
);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
expect(excludedTools).not.toContain(ShellTool.Name);
|
||||
expect(excludedTools).toContain(EditTool.Name);
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
});
|
||||
|
||||
it('should exclude only shell tools in non-interactive mode with auto-edit approval mode', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
|
||||
@@ -10,22 +10,24 @@ import {
|
||||
Config,
|
||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
EditTool,
|
||||
FileDiscoveryService,
|
||||
getCurrentGeminiMdFilename,
|
||||
loadServerHierarchicalMemory,
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
ShellTool,
|
||||
WriteFileTool,
|
||||
resolveTelemetrySettings,
|
||||
FatalConfigError,
|
||||
Storage,
|
||||
InputFormat,
|
||||
OutputFormat,
|
||||
isToolEnabled,
|
||||
SessionService,
|
||||
type ResumedSessionData,
|
||||
type FileFilteringOptions,
|
||||
type MCPServerConfig,
|
||||
type ToolName,
|
||||
EditTool,
|
||||
ShellTool,
|
||||
WriteFileTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import type { Settings } from './settings.js';
|
||||
@@ -111,6 +113,7 @@ export interface CliArgs {
|
||||
telemetryOutfile: string | undefined;
|
||||
allowedMcpServerNames: string[] | undefined;
|
||||
allowedTools: string[] | undefined;
|
||||
acp: boolean | undefined;
|
||||
experimentalAcp: boolean | undefined;
|
||||
experimentalSkills: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
@@ -304,10 +307,16 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
description: 'Enables checkpointing of file edits',
|
||||
default: false,
|
||||
})
|
||||
.option('experimental-acp', {
|
||||
.option('acp', {
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
})
|
||||
.option('experimental-acp', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Starts the agent in ACP mode (deprecated, use --acp instead)',
|
||||
hidden: true,
|
||||
})
|
||||
.option('experimental-skills', {
|
||||
type: 'boolean',
|
||||
description: 'Enable experimental Skills feature',
|
||||
@@ -589,8 +598,19 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
|
||||
// Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
|
||||
if (result['experimentalAcp'] && !result['channel']) {
|
||||
// Handle deprecated --experimental-acp flag
|
||||
if (result['experimentalAcp']) {
|
||||
console.warn(
|
||||
'\x1b[33m⚠ Warning: --experimental-acp is deprecated and will be removed in a future release. Please use --acp instead.\x1b[0m',
|
||||
);
|
||||
// Map experimental-acp to acp if acp is not explicitly set
|
||||
if (!result['acp']) {
|
||||
(result as Record<string, unknown>)['acp'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply ACP fallback: if acp or experimental-acp is present but no explicit --channel, treat as ACP
|
||||
if ((result['acp'] || result['experimentalAcp']) && !result['channel']) {
|
||||
(result as Record<string, unknown>)['channel'] = 'ACP';
|
||||
}
|
||||
|
||||
@@ -818,6 +838,28 @@ export async function loadCliConfig(
|
||||
// However, if stream-json input is used, control can be requested via JSON messages,
|
||||
// so tools should not be excluded in that case.
|
||||
const extraExcludes: string[] = [];
|
||||
const resolvedCoreTools = argv.coreTools || settings.tools?.core || [];
|
||||
const resolvedAllowedTools =
|
||||
argv.allowedTools || settings.tools?.allowed || [];
|
||||
const isExplicitlyEnabled = (toolName: ToolName): boolean => {
|
||||
if (resolvedCoreTools.length > 0) {
|
||||
if (isToolEnabled(toolName, resolvedCoreTools, [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (resolvedAllowedTools.length > 0) {
|
||||
if (isToolEnabled(toolName, resolvedAllowedTools, [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const excludeUnlessExplicit = (toolName: ToolName): void => {
|
||||
if (!isExplicitlyEnabled(toolName)) {
|
||||
extraExcludes.push(toolName);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
!interactive &&
|
||||
!argv.experimentalAcp &&
|
||||
@@ -826,12 +868,15 @@ export async function loadCliConfig(
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.PLAN:
|
||||
case ApprovalMode.DEFAULT:
|
||||
// In default non-interactive mode, all tools that require approval are excluded.
|
||||
extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name);
|
||||
// In default non-interactive mode, all tools that require approval are excluded,
|
||||
// unless explicitly enabled via coreTools/allowedTools.
|
||||
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
||||
excludeUnlessExplicit(EditTool.Name as ToolName);
|
||||
excludeUnlessExplicit(WriteFileTool.Name as ToolName);
|
||||
break;
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
|
||||
extraExcludes.push(ShellTool.Name);
|
||||
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
||||
break;
|
||||
case ApprovalMode.YOLO:
|
||||
// No extra excludes for YOLO mode.
|
||||
@@ -981,7 +1026,7 @@ export async function loadCliConfig(
|
||||
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
|
||||
maxSessionTurns:
|
||||
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
||||
experimentalZedIntegration: argv.experimentalAcp || false,
|
||||
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
|
||||
experimentalSkills: argv.experimentalSkills || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
extensions: allExtensions,
|
||||
|
||||
@@ -460,6 +460,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
telemetryOutfile: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
acp: undefined,
|
||||
experimentalAcp: undefined,
|
||||
experimentalSkills: undefined,
|
||||
extensions: undefined,
|
||||
|
||||
@@ -89,6 +89,9 @@ export default {
|
||||
'No tools available': 'No tools available',
|
||||
'View or change the approval mode for tool usage':
|
||||
'View or change the approval mode for tool usage',
|
||||
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
|
||||
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}',
|
||||
'Approval mode set to "{{mode}}"': 'Approval mode set to "{{mode}}"',
|
||||
'View or change the language setting': 'View or change the language setting',
|
||||
'change the theme': 'change the theme',
|
||||
'Select Theme': 'Select Theme',
|
||||
|
||||
@@ -89,6 +89,10 @@ export default {
|
||||
'No tools available': 'Нет доступных инструментов',
|
||||
'View or change the approval mode for tool usage':
|
||||
'Просмотр или изменение режима подтверждения для использования инструментов',
|
||||
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
|
||||
'Недопустимый режим подтверждения "{{arg}}". Допустимые режимы: {{modes}}',
|
||||
'Approval mode set to "{{mode}}"':
|
||||
'Режим подтверждения установлен на "{{mode}}"',
|
||||
'View or change the language setting':
|
||||
'Просмотр или изменение настроек языка',
|
||||
'change the theme': 'Изменение темы',
|
||||
|
||||
@@ -88,6 +88,9 @@ export default {
|
||||
'No tools available': '没有可用工具',
|
||||
'View or change the approval mode for tool usage':
|
||||
'查看或更改工具使用的审批模式',
|
||||
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
|
||||
'无效的审批模式 "{{arg}}"。有效模式:{{modes}}',
|
||||
'Approval mode set to "{{mode}}"': '审批模式已设置为 "{{mode}}"',
|
||||
'View or change the language setting': '查看或更改语言设置',
|
||||
'change the theme': '更改主题',
|
||||
'Select Theme': '选择主题',
|
||||
|
||||
@@ -630,6 +630,67 @@ describe('BaseJsonOutputAdapter', () => {
|
||||
|
||||
expect(state.blocks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should preserve whitespace in thinking content', () => {
|
||||
const state = adapter.exposeCreateMessageState();
|
||||
adapter.startAssistantMessage();
|
||||
|
||||
adapter.exposeAppendThinking(
|
||||
state,
|
||||
'',
|
||||
'The user just said "Hello"',
|
||||
null,
|
||||
);
|
||||
|
||||
expect(state.blocks).toHaveLength(1);
|
||||
expect(state.blocks[0]).toMatchObject({
|
||||
type: 'thinking',
|
||||
thinking: 'The user just said "Hello"',
|
||||
});
|
||||
// Verify spaces are preserved
|
||||
const block = state.blocks[0] as { thinking: string };
|
||||
expect(block.thinking).toContain('user just');
|
||||
expect(block.thinking).not.toContain('userjust');
|
||||
});
|
||||
|
||||
it('should preserve whitespace when appending multiple thinking fragments', () => {
|
||||
const state = adapter.exposeCreateMessageState();
|
||||
adapter.startAssistantMessage();
|
||||
|
||||
// Simulate streaming thinking content in fragments
|
||||
adapter.exposeAppendThinking(state, '', 'The user just', null);
|
||||
adapter.exposeAppendThinking(state, '', ' said "Hello"', null);
|
||||
adapter.exposeAppendThinking(
|
||||
state,
|
||||
'',
|
||||
'. This is a simple greeting',
|
||||
null,
|
||||
);
|
||||
|
||||
expect(state.blocks).toHaveLength(1);
|
||||
const block = state.blocks[0] as { thinking: string };
|
||||
// Verify the complete text with all spaces preserved
|
||||
expect(block.thinking).toBe(
|
||||
'The user just said "Hello". This is a simple greeting',
|
||||
);
|
||||
// Verify specific space preservation
|
||||
expect(block.thinking).toContain('user just ');
|
||||
expect(block.thinking).toContain(' said');
|
||||
expect(block.thinking).toContain('". This');
|
||||
expect(block.thinking).not.toContain('userjust');
|
||||
expect(block.thinking).not.toContain('justsaid');
|
||||
});
|
||||
|
||||
it('should preserve leading and trailing whitespace in description', () => {
|
||||
const state = adapter.exposeCreateMessageState();
|
||||
adapter.startAssistantMessage();
|
||||
|
||||
adapter.exposeAppendThinking(state, '', ' content with spaces ', null);
|
||||
|
||||
expect(state.blocks).toHaveLength(1);
|
||||
const block = state.blocks[0] as { thinking: string };
|
||||
expect(block.thinking).toBe(' content with spaces ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendToolUse', () => {
|
||||
|
||||
@@ -816,9 +816,18 @@ export abstract class BaseJsonOutputAdapter {
|
||||
parentToolUseId?: string | null,
|
||||
): void {
|
||||
const actualParentToolUseId = parentToolUseId ?? null;
|
||||
const fragment = [subject?.trim(), description?.trim()]
|
||||
.filter((value) => value && value.length > 0)
|
||||
.join(': ');
|
||||
|
||||
// Build fragment without trimming to preserve whitespace in streaming content
|
||||
// Only filter out null/undefined/empty values
|
||||
const parts: string[] = [];
|
||||
if (subject && subject.length > 0) {
|
||||
parts.push(subject);
|
||||
}
|
||||
if (description && description.length > 0) {
|
||||
parts.push(description);
|
||||
}
|
||||
|
||||
const fragment = parts.join(': ');
|
||||
if (!fragment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -323,6 +323,68 @@ describe('StreamJsonOutputAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve whitespace in thinking content (issue #1356)', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: '',
|
||||
description: 'The user just said "Hello"',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
const block = message.message.content[0] as {
|
||||
type: string;
|
||||
thinking: string;
|
||||
};
|
||||
expect(block.type).toBe('thinking');
|
||||
expect(block.thinking).toBe('The user just said "Hello"');
|
||||
// Verify spaces are preserved
|
||||
expect(block.thinking).toContain('user just');
|
||||
expect(block.thinking).not.toContain('userjust');
|
||||
});
|
||||
|
||||
it('should preserve whitespace when streaming multiple thinking fragments (issue #1356)', () => {
|
||||
// Simulate streaming thinking content in multiple events
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: '',
|
||||
description: 'The user just',
|
||||
},
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: '',
|
||||
description: ' said "Hello"',
|
||||
},
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: '',
|
||||
description: '. This is a simple greeting',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
const block = message.message.content[0] as {
|
||||
type: string;
|
||||
thinking: string;
|
||||
};
|
||||
expect(block.thinking).toBe(
|
||||
'The user just said "Hello". This is a simple greeting',
|
||||
);
|
||||
// Verify specific spaces are preserved
|
||||
expect(block.thinking).toContain('user just ');
|
||||
expect(block.thinking).toContain(' said');
|
||||
expect(block.thinking).not.toContain('userjust');
|
||||
expect(block.thinking).not.toContain('justsaid');
|
||||
});
|
||||
|
||||
it('should append tool use from ToolCallRequest events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
|
||||
@@ -298,7 +298,9 @@ describe('runNonInteractive', () => {
|
||||
mockConfig,
|
||||
expect.objectContaining({ name: 'testTool' }),
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
outputUpdateHandler: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
// Verify first call has isContinuation: false
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
@@ -771,6 +773,52 @@ describe('runNonInteractive', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API errors in text mode and exit with error code', async () => {
|
||||
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.TEXT);
|
||||
setupMetricsMock();
|
||||
|
||||
// Simulate an API error event (like 401 unauthorized)
|
||||
const apiErrorEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Error,
|
||||
value: {
|
||||
error: {
|
||||
message: '401 Incorrect API key provided',
|
||||
status: 401,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents([apiErrorEvent]),
|
||||
);
|
||||
|
||||
let thrownError: Error | null = null;
|
||||
try {
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'Test input',
|
||||
'prompt-id-api-error',
|
||||
);
|
||||
// Should not reach here
|
||||
expect.fail('Expected error to be thrown');
|
||||
} catch (error) {
|
||||
thrownError = error as Error;
|
||||
}
|
||||
|
||||
// Should throw with the API error message
|
||||
expect(thrownError).toBeTruthy();
|
||||
expect(thrownError?.message).toContain('401');
|
||||
expect(thrownError?.message).toContain('Incorrect API key provided');
|
||||
|
||||
// Verify error was written to stderr
|
||||
expect(processStderrSpy).toHaveBeenCalled();
|
||||
const stderrCalls = processStderrSpy.mock.calls;
|
||||
const errorOutput = stderrCalls.map((call) => call[0]).join('');
|
||||
expect(errorOutput).toContain('401');
|
||||
expect(errorOutput).toContain('Incorrect API key provided');
|
||||
});
|
||||
|
||||
it('should handle FatalInputError with custom exit code in JSON format', async () => {
|
||||
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON);
|
||||
setupMetricsMock();
|
||||
@@ -1777,4 +1825,84 @@ describe('runNonInteractive', () => {
|
||||
{ isContinuation: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('should print tool output to console in text mode (non-Task tools)', async () => {
|
||||
// Test that tool output is printed to stdout in text mode
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-1',
|
||||
name: 'run_in_terminal',
|
||||
args: { command: 'npm outdated' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-tool-output',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock tool execution with outputUpdateHandler being called
|
||||
mockCoreExecuteToolCall.mockImplementation(
|
||||
async (_config, _request, _signal, options) => {
|
||||
// Simulate tool calling outputUpdateHandler with output chunks
|
||||
if (options?.outputUpdateHandler) {
|
||||
options.outputUpdateHandler('tool-1', 'Package outdated\n');
|
||||
options.outputUpdateHandler('tool-1', 'npm@1.0.0 -> npm@2.0.0\n');
|
||||
}
|
||||
return {
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'tool-1',
|
||||
name: 'run_in_terminal',
|
||||
response: {
|
||||
output: 'Package outdated\nnpm@1.0.0 -> npm@2.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [
|
||||
toolCallEvent,
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||
},
|
||||
];
|
||||
|
||||
const secondCallEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Dependencies checked' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream
|
||||
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
|
||||
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
|
||||
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'Check dependencies',
|
||||
'prompt-id-tool-output',
|
||||
);
|
||||
|
||||
// Verify that executeToolCall was called with outputUpdateHandler
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.objectContaining({ name: 'run_in_terminal' }),
|
||||
expect.any(AbortSignal),
|
||||
expect.objectContaining({
|
||||
outputUpdateHandler: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify tool output was written to stdout
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated\n');
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0\n');
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Dependencies checked');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
ToolResultDisplay,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
@@ -308,6 +312,8 @@ export async function runNonInteractive(
|
||||
config.getContentGeneratorConfig()?.authType,
|
||||
);
|
||||
process.stderr.write(`${errorText}\n`);
|
||||
// Throw error to exit with non-zero code
|
||||
throw new Error(errorText);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,7 +339,7 @@ export async function runNonInteractive(
|
||||
? options.controlService.permission.getToolCallUpdateCallback()
|
||||
: undefined;
|
||||
|
||||
// Only pass outputUpdateHandler for Task tool
|
||||
// Create output handler for Task tool (for subagent execution)
|
||||
const isTaskTool = finalRequestInfo.name === 'task';
|
||||
const taskToolProgress = isTaskTool
|
||||
? createTaskToolProgressHandler(
|
||||
@@ -343,20 +349,41 @@ export async function runNonInteractive(
|
||||
)
|
||||
: undefined;
|
||||
const taskToolProgressHandler = taskToolProgress?.handler;
|
||||
|
||||
// Create output handler for non-Task tools in text mode (for console output)
|
||||
const nonTaskOutputHandler =
|
||||
!isTaskTool && !adapter
|
||||
? (callId: string, outputChunk: ToolResultDisplay) => {
|
||||
// Print tool output to console in text mode
|
||||
if (typeof outputChunk === 'string') {
|
||||
process.stdout.write(outputChunk);
|
||||
} else if (
|
||||
outputChunk &&
|
||||
typeof outputChunk === 'object' &&
|
||||
'ansiOutput' in outputChunk
|
||||
) {
|
||||
// Handle ANSI output - just print as string for now
|
||||
process.stdout.write(String(outputChunk.ansiOutput));
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Combine output handlers
|
||||
const outputUpdateHandler =
|
||||
taskToolProgressHandler || nonTaskOutputHandler;
|
||||
|
||||
const toolResponse = await executeToolCall(
|
||||
config,
|
||||
finalRequestInfo,
|
||||
abortController.signal,
|
||||
isTaskTool && taskToolProgressHandler
|
||||
outputUpdateHandler || toolCallUpdateCallback
|
||||
? {
|
||||
outputUpdateHandler: taskToolProgressHandler,
|
||||
onToolCallsUpdate: toolCallUpdateCallback,
|
||||
}
|
||||
: toolCallUpdateCallback
|
||||
? {
|
||||
...(outputUpdateHandler && { outputUpdateHandler }),
|
||||
...(toolCallUpdateCallback && {
|
||||
onToolCallsUpdate: toolCallUpdateCallback,
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Note: In JSON mode, subagent messages are automatically added to the main
|
||||
|
||||
@@ -72,6 +72,7 @@ describe('ShellProcessor', () => {
|
||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
getAllowedTools: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
|
||||
context = createMockCommandContext({
|
||||
@@ -196,6 +197,35 @@ describe('ShellProcessor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT throw ConfirmationRequiredError when a command matches allowedTools', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt: PromptPipelineContent = createPromptPipelineContent(
|
||||
'Do something dangerous: !{rm -rf /}',
|
||||
);
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: false,
|
||||
disallowedCommands: ['rm -rf /'],
|
||||
});
|
||||
(mockConfig.getAllowedTools as Mock).mockReturnValue([
|
||||
'ShellTool(rm -rf /)',
|
||||
]);
|
||||
mockShellExecute.mockReturnValue({
|
||||
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }),
|
||||
});
|
||||
|
||||
const result = await processor.process(prompt, context);
|
||||
|
||||
expect(mockShellExecute).toHaveBeenCalledWith(
|
||||
'rm -rf /',
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result).toEqual([{ text: 'Do something dangerous: deleted' }]);
|
||||
});
|
||||
|
||||
it('should NOT throw ConfirmationRequiredError if a command is not allowed but approval mode is YOLO', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt: PromptPipelineContent = createPromptPipelineContent(
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
import {
|
||||
ApprovalMode,
|
||||
checkCommandPermissions,
|
||||
doesToolInvocationMatch,
|
||||
escapeShellArg,
|
||||
getShellConfiguration,
|
||||
ShellExecutionService,
|
||||
flatMapTextParts,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { AnyToolInvocation } from '@qwen-code/qwen-code-core';
|
||||
|
||||
import type { CommandContext } from '../../ui/commands/types.js';
|
||||
import type { IPromptProcessor, PromptPipelineContent } from './types.js';
|
||||
@@ -124,6 +126,15 @@ export class ShellProcessor implements IPromptProcessor {
|
||||
// Security check on the final, escaped command string.
|
||||
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
|
||||
checkCommandPermissions(command, config, sessionShellAllowlist);
|
||||
const allowedTools = config.getAllowedTools() || [];
|
||||
const invocation = {
|
||||
params: { command },
|
||||
} as AnyToolInvocation;
|
||||
const isAllowedBySettings = doesToolInvocationMatch(
|
||||
'run_shell_command',
|
||||
invocation,
|
||||
allowedTools,
|
||||
);
|
||||
|
||||
if (!allAllowed) {
|
||||
if (isHardDenial) {
|
||||
@@ -132,10 +143,17 @@ export class ShellProcessor implements IPromptProcessor {
|
||||
);
|
||||
}
|
||||
|
||||
// If not a hard denial, respect YOLO mode and auto-approve.
|
||||
if (config.getApprovalMode() !== ApprovalMode.YOLO) {
|
||||
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
|
||||
// If the command is allowed by settings, skip confirmation.
|
||||
if (isAllowedBySettings) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If not a hard denial, respect YOLO mode and auto-approve.
|
||||
if (config.getApprovalMode() === ApprovalMode.YOLO) {
|
||||
continue;
|
||||
}
|
||||
|
||||
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -925,7 +925,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const handleIdePromptComplete = useCallback(
|
||||
(result: IdeIntegrationNudgeResult) => {
|
||||
if (result.userSelection === 'yes') {
|
||||
handleSlashCommand('/ide install');
|
||||
// Check whether the extension has been pre-installed
|
||||
if (result.isExtensionPreInstalled) {
|
||||
handleSlashCommand('/ide enable');
|
||||
} else {
|
||||
handleSlashCommand('/ide install');
|
||||
}
|
||||
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||
} else if (result.userSelection === 'dismiss') {
|
||||
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||
|
||||
@@ -38,6 +38,7 @@ export function IdeIntegrationNudge({
|
||||
);
|
||||
|
||||
const { displayName: ideName } = ide;
|
||||
const isInSandbox = !!process.env['SANDBOX'];
|
||||
// Assume extension is already installed if the env variables are set.
|
||||
const isExtensionPreInstalled =
|
||||
!!process.env['QWEN_CODE_IDE_SERVER_PORT'] &&
|
||||
@@ -70,13 +71,15 @@ export function IdeIntegrationNudge({
|
||||
},
|
||||
];
|
||||
|
||||
const installText = isExtensionPreInstalled
|
||||
? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${
|
||||
ideName ?? 'your editor'
|
||||
}.`
|
||||
: `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${
|
||||
ideName ?? 'your editor'
|
||||
}.`;
|
||||
const installText = isInSandbox
|
||||
? `Note: In sandbox environments, IDE integration requires manual setup on the host system. If you select Yes, you'll receive instructions on how to set this up.`
|
||||
: isExtensionPreInstalled
|
||||
? `If you select Yes, the CLI will connect to your ${
|
||||
ideName ?? 'editor'
|
||||
} and have access to your open files and display diffs directly.`
|
||||
: `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${
|
||||
ideName ?? 'your editor'
|
||||
}.`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -4,31 +4,28 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { approvalModeCommand } from './approvalModeCommand.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
CommandKind,
|
||||
type OpenDialogActionReturn,
|
||||
type MessageActionReturn,
|
||||
} from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
describe('approvalModeCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
let mockSetApprovalMode: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetApprovalMode = vi.fn();
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getApprovalMode: () => 'default',
|
||||
setApprovalMode: () => {},
|
||||
setApprovalMode: mockSetApprovalMode,
|
||||
},
|
||||
settings: {
|
||||
merged: {},
|
||||
setValue: () => {},
|
||||
forScope: () => ({}),
|
||||
} as unknown as LoadedSettings,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -41,7 +38,7 @@ describe('approvalModeCommand', () => {
|
||||
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
it('should open approval mode dialog when invoked', async () => {
|
||||
it('should open approval mode dialog when invoked without arguments', async () => {
|
||||
const result = (await approvalModeCommand.action?.(
|
||||
mockContext,
|
||||
'',
|
||||
@@ -51,16 +48,123 @@ describe('approvalModeCommand', () => {
|
||||
expect(result.dialog).toBe('approval-mode');
|
||||
});
|
||||
|
||||
it('should open approval mode dialog with arguments (ignored)', async () => {
|
||||
it('should open approval mode dialog when invoked with whitespace only', async () => {
|
||||
const result = (await approvalModeCommand.action?.(
|
||||
mockContext,
|
||||
'some arguments',
|
||||
' ',
|
||||
)) as OpenDialogActionReturn;
|
||||
|
||||
expect(result.type).toBe('dialog');
|
||||
expect(result.dialog).toBe('approval-mode');
|
||||
});
|
||||
|
||||
describe('direct mode setting (session-only)', () => {
|
||||
it('should set approval mode to "plan" when argument is "plan"', async () => {
|
||||
const result = (await approvalModeCommand.action?.(
|
||||
mockContext,
|
||||
'plan',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('plan');
|
||||
expect(mockSetApprovalMode).toHaveBeenCalledWith('plan');
|
||||
});
|
||||
|
||||
it('should set approval mode to "yolo" when argument is "yolo"', async () => {
|
||||
const result = (await approvalModeCommand.action?.(
|
||||
mockContext,
|
||||
'yolo',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('yolo');
|
||||
expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo');
|
||||
});
|
||||
|
||||
it('should set approval mode to "auto-edit" when argument is "auto-edit"', async () => {
|
||||
const result = (await approvalModeCommand.action?.(
|
||||
mockContext,
|
||||
'auto-edit',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('auto-edit');
|
||||
expect(mockSetApprovalMode).toHaveBeenCalledWith('auto-edit');
|
||||
});
|
||||
|
||||
it('should set approval mode to "default" when argument is "default"', async () => {
|
||||
const result = (await approvalModeCommand.action?.(
|
||||
mockContext,
|
||||
'default',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('default');
|
||||
expect(mockSetApprovalMode).toHaveBeenCalledWith('default');
|
||||
});
|
||||
|
||||
it('should be case-insensitive for mode argument', async () => {
|
||||
const result = (await approvalModeCommand.action?.(
|
||||
mockContext,
|
||||
'YOLO',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo');
|
||||
});
|
||||
|
||||
it('should handle argument with leading/trailing whitespace', async () => {
|
||||
const result = (await approvalModeCommand.action?.(
|
||||
mockContext,
|
||||
' plan ',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(mockSetApprovalMode).toHaveBeenCalledWith('plan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid mode argument', () => {
|
||||
it('should return error for invalid mode', async () => {
|
||||
const result = (await approvalModeCommand.action?.(
|
||||
mockContext,
|
||||
'invalid-mode',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toContain('invalid-mode');
|
||||
expect(result.content).toContain('plan');
|
||||
expect(result.content).toContain('yolo');
|
||||
expect(mockSetApprovalMode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('untrusted folder handling', () => {
|
||||
it('should return error when setApprovalMode throws (e.g., untrusted folder)', async () => {
|
||||
const errorMessage =
|
||||
'Cannot enable privileged approval modes in an untrusted folder.';
|
||||
mockSetApprovalMode.mockImplementation(() => {
|
||||
throw new Error(errorMessage);
|
||||
});
|
||||
|
||||
const result = (await approvalModeCommand.action?.(
|
||||
mockContext,
|
||||
'yolo',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toBe(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not have subcommands', () => {
|
||||
expect(approvalModeCommand.subCommands).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -8,9 +8,25 @@ import type {
|
||||
SlashCommand,
|
||||
CommandContext,
|
||||
OpenDialogActionReturn,
|
||||
MessageActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import type { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import { APPROVAL_MODES } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Parses the argument string and returns the corresponding ApprovalMode if valid.
|
||||
* Returns undefined if the argument is empty or not a valid mode.
|
||||
*/
|
||||
function parseApprovalModeArg(arg: string): ApprovalMode | undefined {
|
||||
const trimmed = arg.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
// Match against valid approval modes (case-insensitive)
|
||||
return APPROVAL_MODES.find((mode) => mode.toLowerCase() === trimmed);
|
||||
}
|
||||
|
||||
export const approvalModeCommand: SlashCommand = {
|
||||
name: 'approval-mode',
|
||||
@@ -19,10 +35,49 @@ export const approvalModeCommand: SlashCommand = {
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
_context: CommandContext,
|
||||
_args: string,
|
||||
): Promise<OpenDialogActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'approval-mode',
|
||||
}),
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<OpenDialogActionReturn | MessageActionReturn> => {
|
||||
const mode = parseApprovalModeArg(args);
|
||||
|
||||
// If no argument provided, open the dialog
|
||||
if (!args.trim()) {
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'approval-mode',
|
||||
};
|
||||
}
|
||||
|
||||
// If invalid argument, return error message with valid options
|
||||
if (!mode) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Invalid approval mode "{{arg}}". Valid modes: {{modes}}', {
|
||||
arg: args.trim(),
|
||||
modes: APPROVAL_MODES.join(', '),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Set the mode for current session only (not persisted)
|
||||
const { config } = context.services;
|
||||
if (config) {
|
||||
try {
|
||||
config.setApprovalMode(mode);
|
||||
} catch (e) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: (e as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('Approval mode set to "{{mode}}"', { mode }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -191,11 +191,23 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const installer = getIdeInstaller(currentIDE);
|
||||
const isSandBox = !!process.env['SANDBOX'];
|
||||
if (isSandBox) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `IDE integration needs to be installed on the host. If you have already installed it, you can directly connect the ide`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!installer) {
|
||||
const ideName = ideClient.getDetectedIdeDisplayName();
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'error',
|
||||
text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
|
||||
text: `Automatic installation is not supported for ${ideName}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -87,7 +87,13 @@ export async function showResumeSessionPicker(
|
||||
let selectedId: string | undefined;
|
||||
|
||||
const { unmount, waitUntilExit } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<KeypressProvider
|
||||
kittyProtocolEnabled={false}
|
||||
pasteWorkaround={
|
||||
process.platform === 'win32' ||
|
||||
parseInt(process.versions.node.split('.')[0], 10) < 20
|
||||
}
|
||||
>
|
||||
<StandalonePickerScreen
|
||||
sessionService={sessionService}
|
||||
onSelect={(id) => {
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
|
||||
import { vi, type Mock, type MockInstance } from 'vitest';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
OutputFormat,
|
||||
FatalInputError,
|
||||
ToolErrorType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
getErrorMessage,
|
||||
handleError,
|
||||
@@ -65,6 +69,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
describe('errors', () => {
|
||||
let mockConfig: Config;
|
||||
let processExitSpy: MockInstance;
|
||||
let processStderrWriteSpy: MockInstance;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -74,6 +79,11 @@ describe('errors', () => {
|
||||
// Mock console.error
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Mock process.stderr.write
|
||||
processStderrWriteSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
|
||||
// Mock process.exit to throw instead of actually exiting
|
||||
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit called with code: ${code}`);
|
||||
@@ -84,11 +94,13 @@ describe('errors', () => {
|
||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
|
||||
getDebugMode: vi.fn().mockReturnValue(true),
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
processStderrWriteSpy.mockRestore();
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -432,6 +444,87 @@ describe('errors', () => {
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission denied warnings', () => {
|
||||
it('should show warning when EXECUTION_DENIED in non-interactive text mode', () => {
|
||||
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
||||
(mockConfig.isInteractive as Mock).mockReturnValue(false);
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.TEXT);
|
||||
|
||||
handleToolError(
|
||||
toolName,
|
||||
toolError,
|
||||
mockConfig,
|
||||
ToolErrorType.EXECUTION_DENIED,
|
||||
);
|
||||
|
||||
expect(processStderrWriteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Warning: Tool "test-tool" requires user approval',
|
||||
),
|
||||
);
|
||||
expect(processStderrWriteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('use the -y flag (YOLO mode)'),
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show warning when EXECUTION_DENIED in interactive mode', () => {
|
||||
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
||||
(mockConfig.isInteractive as Mock).mockReturnValue(true);
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.TEXT);
|
||||
|
||||
handleToolError(
|
||||
toolName,
|
||||
toolError,
|
||||
mockConfig,
|
||||
ToolErrorType.EXECUTION_DENIED,
|
||||
);
|
||||
|
||||
expect(processStderrWriteSpy).not.toHaveBeenCalled();
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show warning when EXECUTION_DENIED in JSON mode', () => {
|
||||
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
||||
(mockConfig.isInteractive as Mock).mockReturnValue(false);
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
handleToolError(
|
||||
toolName,
|
||||
toolError,
|
||||
mockConfig,
|
||||
ToolErrorType.EXECUTION_DENIED,
|
||||
);
|
||||
|
||||
expect(processStderrWriteSpy).not.toHaveBeenCalled();
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show warning for non-EXECUTION_DENIED errors', () => {
|
||||
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
||||
(mockConfig.isInteractive as Mock).mockReturnValue(false);
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.TEXT);
|
||||
|
||||
handleToolError(
|
||||
toolName,
|
||||
toolError,
|
||||
mockConfig,
|
||||
ToolErrorType.FILE_NOT_FOUND,
|
||||
);
|
||||
|
||||
expect(processStderrWriteSpy).not.toHaveBeenCalled();
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCancellationError', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
parseAndFormatApiError,
|
||||
FatalTurnLimitedError,
|
||||
FatalCancellationError,
|
||||
ToolErrorType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
@@ -102,10 +103,24 @@ export function handleToolError(
|
||||
toolName: string,
|
||||
toolError: Error,
|
||||
config: Config,
|
||||
_errorCode?: string | number,
|
||||
errorCode?: string | number,
|
||||
resultDisplay?: string,
|
||||
): void {
|
||||
// Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere
|
||||
// Check if this is a permission denied error in non-interactive mode
|
||||
const isExecutionDenied = errorCode === ToolErrorType.EXECUTION_DENIED;
|
||||
const isNonInteractive = !config.isInteractive();
|
||||
const isTextMode = config.getOutputFormat() === OutputFormat.TEXT;
|
||||
|
||||
// Show warning for permission denied errors in non-interactive text mode
|
||||
if (isExecutionDenied && isNonInteractive && isTextMode) {
|
||||
const warningMessage =
|
||||
`Warning: Tool "${toolName}" requires user approval but cannot execute in non-interactive mode.\n` +
|
||||
`To enable automatic tool execution, use the -y flag (YOLO mode):\n` +
|
||||
`Example: qwen -p 'your prompt' -y\n\n`;
|
||||
process.stderr.write(warningMessage);
|
||||
}
|
||||
|
||||
// Always log detailed error in debug mode
|
||||
if (config.getDebugMode()) {
|
||||
console.error(
|
||||
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -824,7 +824,6 @@ export class CoreToolScheduler {
|
||||
*/
|
||||
const shouldAutoDeny =
|
||||
!this.config.isInteractive() &&
|
||||
!this.config.getIdeMode() &&
|
||||
!this.config.getExperimentalZedIntegration() &&
|
||||
this.config.getInputFormat() !== InputFormat.STREAM_JSON;
|
||||
|
||||
|
||||
@@ -207,6 +207,27 @@ describe('OpenAIContentConverter', () => {
|
||||
expect.objectContaining({ text: 'visible text' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw when streaming chunk has no delta', () => {
|
||||
const chunk = converter.convertOpenAIChunkToGemini({
|
||||
object: 'chat.completion.chunk',
|
||||
id: 'chunk-2',
|
||||
created: 456,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
// Some OpenAI-compatible providers may omit delta entirely.
|
||||
delta: undefined,
|
||||
finish_reason: null,
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
model: 'gpt-test',
|
||||
} as unknown as OpenAI.Chat.ChatCompletionChunk);
|
||||
|
||||
const parts = chunk.candidates?.[0]?.content?.parts;
|
||||
expect(parts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertGeminiToolsToOpenAI', () => {
|
||||
|
||||
@@ -752,6 +752,8 @@ export class OpenAIContentConverter {
|
||||
usage.prompt_tokens_details?.cached_tokens ??
|
||||
extendedUsage.cached_tokens ??
|
||||
0;
|
||||
const thinkingTokens =
|
||||
usage.completion_tokens_details?.reasoning_tokens || 0;
|
||||
|
||||
// If we only have total tokens but no breakdown, estimate the split
|
||||
// Typically input is ~70% and output is ~30% for most conversations
|
||||
@@ -769,6 +771,7 @@ export class OpenAIContentConverter {
|
||||
candidatesTokenCount: finalCompletionTokens,
|
||||
totalTokenCount: totalTokens,
|
||||
cachedContentTokenCount: cachedTokens,
|
||||
thoughtsTokenCount: thinkingTokens,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -788,7 +791,7 @@ export class OpenAIContentConverter {
|
||||
const parts: Part[] = [];
|
||||
|
||||
const reasoningText = (choice.delta as ExtendedCompletionChunkDelta)
|
||||
.reasoning_content;
|
||||
?.reasoning_content;
|
||||
if (reasoningText) {
|
||||
parts.push({ text: reasoningText, thought: true });
|
||||
}
|
||||
|
||||
@@ -317,15 +317,22 @@ export class ContentGenerationPipeline {
|
||||
}
|
||||
|
||||
private buildReasoningConfig(): Record<string, unknown> {
|
||||
const reasoning = this.contentGeneratorConfig.reasoning;
|
||||
// Reasoning configuration for OpenAI-compatible endpoints is highly fragmented.
|
||||
// For example, across common providers and models:
|
||||
//
|
||||
// - deepseek-reasoner — thinking is enabled by default and cannot be disabled
|
||||
// - glm-4.7 — thinking is enabled by default; can be disabled via `extra_body.thinking.enabled`
|
||||
// - kimi-k2-thinking — thinking is enabled by default and cannot be disabled
|
||||
// - gpt-5.x series — thinking is enabled by default; can be disabled via `reasoning.effort`
|
||||
// - qwen3 series — model-dependent; can be manually disabled via `extra_body.enable_thinking`
|
||||
//
|
||||
// Given this inconsistency, we choose not to set any reasoning config here and
|
||||
// instead rely on each model’s default behavior.
|
||||
|
||||
if (reasoning === false) {
|
||||
return {};
|
||||
}
|
||||
// We plan to introduce provider- and model-specific settings to enable more
|
||||
// fine-grained control over reasoning configuration.
|
||||
|
||||
return {
|
||||
reasoning_effort: reasoning?.effort ?? 'medium',
|
||||
};
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,8 +58,6 @@ export class DefaultOpenAICompatibleProvider
|
||||
}
|
||||
|
||||
getDefaultGenerationConfig(): GenerateContentConfig {
|
||||
return {
|
||||
topP: 0.95,
|
||||
};
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export * from './utils/quotaErrorDetection.js';
|
||||
export * from './utils/fileUtils.js';
|
||||
export * from './utils/retry.js';
|
||||
export * from './utils/shell-utils.js';
|
||||
export * from './utils/tool-utils.js';
|
||||
export * from './utils/terminalSerializer.js';
|
||||
export * from './utils/systemEncoding.js';
|
||||
export * from './utils/textUtils.js';
|
||||
|
||||
@@ -589,7 +589,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(result.output).toBe('file1.txt\na warning');
|
||||
expect(handle.pid).toBe(undefined);
|
||||
expect(handle.pid).toBe(12345);
|
||||
|
||||
expect(onOutputEventMock).toHaveBeenCalledWith({
|
||||
type: 'data',
|
||||
@@ -818,7 +818,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
});
|
||||
|
||||
describe('Platform-Specific Behavior', () => {
|
||||
it('should use cmd.exe on Windows', async () => {
|
||||
it('should use cmd.exe and hide window on Windows', async () => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
await simulateExecution('dir "foo bar"', (cp) =>
|
||||
cp.emit('exit', 0, null),
|
||||
@@ -830,6 +830,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
expect.objectContaining({
|
||||
shell: true,
|
||||
detached: false,
|
||||
windowsHide: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import type { PtyImplementation } from '../utils/getPty.js';
|
||||
import { getPty } from '../utils/getPty.js';
|
||||
import { spawn as cpSpawn } from 'node:child_process';
|
||||
import { spawn as cpSpawn, spawnSync } from 'node:child_process';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import os from 'node:os';
|
||||
import type { IPty } from '@lydell/node-pty';
|
||||
@@ -98,6 +98,48 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
return lines.join('\n').trimEnd();
|
||||
};
|
||||
|
||||
interface ProcessCleanupStrategy {
|
||||
killPty(pid: number, pty: ActivePty): void;
|
||||
killChildProcesses(pids: Set<number>): void;
|
||||
}
|
||||
|
||||
const windowsStrategy: ProcessCleanupStrategy = {
|
||||
killPty: (_pid, pty) => {
|
||||
pty.ptyProcess.kill();
|
||||
},
|
||||
killChildProcesses: (pids) => {
|
||||
if (pids.size > 0) {
|
||||
try {
|
||||
const args = ['/f', '/t'];
|
||||
for (const pid of pids) {
|
||||
args.push('/pid', pid.toString());
|
||||
}
|
||||
spawnSync('taskkill', args);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const posixStrategy: ProcessCleanupStrategy = {
|
||||
killPty: (pid, _pty) => {
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
},
|
||||
killChildProcesses: (pids) => {
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getCleanupStrategy = () =>
|
||||
os.platform() === 'win32' ? windowsStrategy : posixStrategy;
|
||||
|
||||
/**
|
||||
* A centralized service for executing shell commands with robust process
|
||||
* management, cross-platform compatibility, and streaming output capabilities.
|
||||
@@ -106,6 +148,29 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
|
||||
export class ShellExecutionService {
|
||||
private static activePtys = new Map<number, ActivePty>();
|
||||
private static activeChildProcesses = new Set<number>();
|
||||
|
||||
static cleanup() {
|
||||
const strategy = getCleanupStrategy();
|
||||
// Cleanup PTYs
|
||||
for (const [pid, pty] of this.activePtys) {
|
||||
try {
|
||||
strategy.killPty(pid, pty);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup child processes
|
||||
strategy.killChildProcesses(this.activeChildProcesses);
|
||||
}
|
||||
|
||||
static {
|
||||
process.on('exit', () => {
|
||||
ShellExecutionService.cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a shell command using `node-pty`, capturing all output and lifecycle events.
|
||||
*
|
||||
@@ -165,6 +230,7 @@ export class ShellExecutionService {
|
||||
windowsVerbatimArguments: true,
|
||||
shell: isWindows ? true : 'bash',
|
||||
detached: !isWindows,
|
||||
windowsHide: isWindows,
|
||||
env: {
|
||||
...process.env,
|
||||
QWEN_CODE: '1',
|
||||
@@ -281,9 +347,13 @@ export class ShellExecutionService {
|
||||
|
||||
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
||||
|
||||
if (child.pid) {
|
||||
this.activeChildProcesses.add(child.pid);
|
||||
}
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (child.pid) {
|
||||
this.activePtys.delete(child.pid);
|
||||
this.activeChildProcesses.delete(child.pid);
|
||||
}
|
||||
handleExit(code, signal);
|
||||
});
|
||||
@@ -310,7 +380,7 @@ export class ShellExecutionService {
|
||||
}
|
||||
});
|
||||
|
||||
return { pid: undefined, result };
|
||||
return { pid: child.pid, result };
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
return {
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('ShellTool', () => {
|
||||
wrappedCommand,
|
||||
'/test/dir',
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -275,7 +275,7 @@ describe('ShellTool', () => {
|
||||
wrappedCommand,
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -300,7 +300,7 @@ describe('ShellTool', () => {
|
||||
wrappedCommand,
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -325,7 +325,7 @@ describe('ShellTool', () => {
|
||||
wrappedCommand,
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -350,7 +350,7 @@ describe('ShellTool', () => {
|
||||
wrappedCommand,
|
||||
'/test/dir/subdir',
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -378,7 +378,7 @@ describe('ShellTool', () => {
|
||||
'dir',
|
||||
'/test/dir',
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -471,7 +471,7 @@ describe('ShellTool', () => {
|
||||
expect(summarizer.summarizeToolOutput).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
mockConfig.getGeminiClient(),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
1000,
|
||||
);
|
||||
expect(result.llmContent).toBe('summarized output');
|
||||
@@ -580,7 +580,7 @@ describe('ShellTool', () => {
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -610,7 +610,7 @@ describe('ShellTool', () => {
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -640,7 +640,7 @@ describe('ShellTool', () => {
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -699,7 +699,7 @@ describe('ShellTool', () => {
|
||||
expect.stringContaining('npm install'),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -728,7 +728,7 @@ describe('ShellTool', () => {
|
||||
expect.stringContaining('git commit'),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -758,7 +758,7 @@ describe('ShellTool', () => {
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -794,7 +794,7 @@ describe('ShellTool', () => {
|
||||
expect.stringContaining('git commit -m "Initial commit"'),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -831,7 +831,7 @@ describe('ShellTool', () => {
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
@@ -962,4 +962,41 @@ spanning multiple lines"`;
|
||||
expect(shellTool.description).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows background execution', () => {
|
||||
it('should clean up trailing ampersand on Windows for background tasks', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
const mockAbortSignal = new AbortController().signal;
|
||||
|
||||
const invocation = shellTool.build({
|
||||
command: 'npm start &',
|
||||
is_background: true,
|
||||
});
|
||||
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
// Simulate immediate success (process started)
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
'npm start',
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,11 +143,24 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
const shouldRunInBackground = this.params.is_background;
|
||||
let finalCommand = processedCommand;
|
||||
|
||||
// If explicitly marked as background and doesn't already end with &, add it
|
||||
if (shouldRunInBackground && !finalCommand.trim().endsWith('&')) {
|
||||
// On non-Windows, use & to run in background.
|
||||
// On Windows, we don't use start /B because it creates a detached process that
|
||||
// doesn't die when the parent dies. Instead, we rely on the race logic below
|
||||
// to return early while keeping the process attached (detached: false).
|
||||
if (
|
||||
!isWindows &&
|
||||
shouldRunInBackground &&
|
||||
!finalCommand.trim().endsWith('&')
|
||||
) {
|
||||
finalCommand = finalCommand.trim() + ' &';
|
||||
}
|
||||
|
||||
// On Windows, we rely on the race logic below to handle background tasks.
|
||||
// We just ensure the command string is clean.
|
||||
if (isWindows && shouldRunInBackground) {
|
||||
finalCommand = finalCommand.trim().replace(/&+$/, '').trim();
|
||||
}
|
||||
|
||||
// pgrep is not available on Windows, so we can't get background PIDs
|
||||
const commandToExecute = isWindows
|
||||
? finalCommand
|
||||
@@ -169,10 +182,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
commandToExecute,
|
||||
cwd,
|
||||
(event: ShellOutputEvent) => {
|
||||
if (!updateOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shouldUpdate = false;
|
||||
|
||||
switch (event.type) {
|
||||
@@ -201,7 +210,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
if (shouldUpdate && updateOutput) {
|
||||
updateOutput(
|
||||
typeof cumulativeOutput === 'string'
|
||||
? cumulativeOutput
|
||||
@@ -219,6 +228,21 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
setPidCallback(pid);
|
||||
}
|
||||
|
||||
if (shouldRunInBackground) {
|
||||
// For background tasks, return immediately with PID info
|
||||
// Note: We cannot reliably detect startup errors for background processes
|
||||
// since their stdio is typically detached/ignored
|
||||
const pidMsg = pid ? ` PID: ${pid}` : '';
|
||||
const killHint = isWindows
|
||||
? ' (Use taskkill /F /T /PID <pid> to stop)'
|
||||
: ' (Use kill <pid> to stop)';
|
||||
|
||||
return {
|
||||
llmContent: `Background command started.${pidMsg}${killHint}`,
|
||||
returnDisplay: `Background command started.${pidMsg}${killHint}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
const backgroundPIDs: number[] = [];
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
<logback-classic.version>1.3.16</logback-classic.version>
|
||||
<fastjson2.version>2.0.60</fastjson2.version>
|
||||
<maven-compiler-plugin.version>3.13.0</maven-compiler-plugin.version>
|
||||
<central-publishing-maven-plugin.version>9</central-publishing-maven-plugin.version>
|
||||
<maven-source-plugin.version>2</maven-source-plugin.version>
|
||||
<central-publishing-maven-plugin.version>0.8.0</central-publishing-maven-plugin.version>
|
||||
<maven-source-plugin.version>2.2.1</maven-source-plugin.version>
|
||||
<maven-javadoc-plugin.version>2.9.1</maven-javadoc-plugin.version>
|
||||
<maven-gpg-plugin.version>1.5</maven-gpg-plugin.version>
|
||||
</properties>
|
||||
@@ -112,7 +112,7 @@
|
||||
<plugin>
|
||||
<groupId>org.sonatype.central</groupId>
|
||||
<artifactId>central-publishing-maven-plugin</artifactId>
|
||||
<version>0.${central-publishing-maven-plugin.version}.0</version>
|
||||
<version>${central-publishing-maven-plugin.version}</version>
|
||||
<extensions>true</extensions>
|
||||
<configuration>
|
||||
<publishingServerId>central</publishingServerId>
|
||||
@@ -122,7 +122,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-source-plugin</artifactId>
|
||||
<version>${maven-source-plugin.version}.2.1</version>
|
||||
<version>${maven-source-plugin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>attach-sources</id>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"displayName": "Qwen Code Companion",
|
||||
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -95,7 +95,7 @@ export class AcpConnection {
|
||||
const spawnCommand: string = process.execPath;
|
||||
const spawnArgs: string[] = [
|
||||
cliEntryPath,
|
||||
'--experimental-acp',
|
||||
'--acp',
|
||||
'--channel=VSCode',
|
||||
...extraArgs,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user