mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-09 10:29:22 +00:00
Compare commits
1 Commits
main
...
mingholy/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea287fae98 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,7 +23,6 @@ package-lock.json
|
|||||||
.idea
|
.idea
|
||||||
*.iml
|
*.iml
|
||||||
.cursor
|
.cursor
|
||||||
.qoder
|
|
||||||
|
|
||||||
# OS metadata
|
# OS metadata
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -191,7 +191,6 @@ See [settings](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/se
|
|||||||
|
|
||||||
Looking for a graphical interface?
|
Looking for a graphical interface?
|
||||||
|
|
||||||
- [**AionUi**](https://github.com/iOfficeAI/AionUi) A modern GUI for command-line AI tools including Qwen Code
|
|
||||||
- [**Gemini CLI Desktop**](https://github.com/Piebald-AI/gemini-cli-desktop) A cross-platform desktop/web/mobile UI for Qwen Code
|
- [**Gemini CLI Desktop**](https://github.com/Piebald-AI/gemini-cli-desktop) A cross-platform desktop/web/mobile UI for Qwen Code
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export default {
|
|||||||
type: 'separator',
|
type: 'separator',
|
||||||
},
|
},
|
||||||
'sdk-typescript': 'Typescript SDK',
|
'sdk-typescript': 'Typescript SDK',
|
||||||
'sdk-java': 'Java SDK(alpha)',
|
|
||||||
'Dive Into Qwen Code': {
|
'Dive Into Qwen Code': {
|
||||||
title: 'Dive Into Qwen Code',
|
title: 'Dive Into Qwen Code',
|
||||||
type: 'separator',
|
type: 'separator',
|
||||||
|
|||||||
@@ -1,312 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -124,107 +124,44 @@ Settings are organized into categories. All settings should be placed within the
|
|||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"top_p": 0.8,
|
"top_p": 0.8,
|
||||||
"max_tokens": 1024
|
"max_tokens": 1024
|
||||||
|
},
|
||||||
|
"reasoning": {
|
||||||
|
"effort": "medium"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Reasoning Configuration:**
|
||||||
|
|
||||||
|
The `reasoning` field controls reasoning behavior for models that support it:
|
||||||
|
|
||||||
|
- Set to `false` to disable reasoning entirely
|
||||||
|
- Set to an object with `effort` field to enable reasoning with a specific effort level:
|
||||||
|
- `"low"`: Minimal reasoning effort
|
||||||
|
- `"medium"`: Balanced reasoning effort (default)
|
||||||
|
- `"high"`: Maximum reasoning effort
|
||||||
|
- Optionally include `budget_tokens` to limit reasoning token usage
|
||||||
|
|
||||||
|
Example to disable reasoning:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": {
|
||||||
|
"generationConfig": {
|
||||||
|
"reasoning": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**model.openAILoggingDir examples:**
|
**model.openAILoggingDir examples:**
|
||||||
|
|
||||||
- `"~/qwen-logs"` - Logs to `~/qwen-logs` directory
|
- `"~/qwen-logs"` - Logs to `~/qwen-logs` directory
|
||||||
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
|
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
|
||||||
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs`
|
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs`
|
||||||
|
|
||||||
#### modelProviders
|
|
||||||
|
|
||||||
Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden.
|
|
||||||
|
|
||||||
##### Example
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"modelProviders": {
|
|
||||||
"openai": [
|
|
||||||
{
|
|
||||||
"id": "gpt-4o",
|
|
||||||
"name": "GPT-4o",
|
|
||||||
"envKey": "OPENAI_API_KEY",
|
|
||||||
"baseUrl": "https://api.openai.com/v1",
|
|
||||||
"generationConfig": {
|
|
||||||
"timeout": 60000,
|
|
||||||
"maxRetries": 3,
|
|
||||||
"samplingParams": { "temperature": 0.2 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"anthropic": [
|
|
||||||
{
|
|
||||||
"id": "claude-3-5-sonnet",
|
|
||||||
"envKey": "ANTHROPIC_API_KEY",
|
|
||||||
"baseUrl": "https://api.anthropic.com/v1"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"gemini": [
|
|
||||||
{
|
|
||||||
"id": "gemini-2.0-flash",
|
|
||||||
"name": "Gemini 2.0 Flash",
|
|
||||||
"envKey": "GEMINI_API_KEY",
|
|
||||||
"baseUrl": "https://generativelanguage.googleapis.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"vertex-ai": [
|
|
||||||
{
|
|
||||||
"id": "gemini-1.5-pro-vertex",
|
|
||||||
"envKey": "GOOGLE_API_KEY",
|
|
||||||
"baseUrl": "https://generativelanguage.googleapis.com"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!note]
|
|
||||||
> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows.
|
|
||||||
|
|
||||||
##### Resolution layers and atomicity
|
|
||||||
|
|
||||||
The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers.
|
|
||||||
|
|
||||||
| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy |
|
|
||||||
| -------------------------- | ----------------------------------- | ----------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | ---------------------- | --------------------------------- |
|
|
||||||
| Programmatic overrides | `/auth ` | `/auth` input | `/auth` input | `/auth` input | — | — |
|
|
||||||
| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — |
|
|
||||||
| CLI arguments | `--auth-type` | `--model` | `--openaiApiKey` (or provider-specific equivalents) | `--openaiBaseUrl` (or provider-specific equivalents) | — | — |
|
|
||||||
| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — |
|
|
||||||
| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — |
|
|
||||||
| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3-coder-plus`) | — | — | — | `Config.getProxy()` if configured |
|
|
||||||
|
|
||||||
\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration.
|
|
||||||
|
|
||||||
Model-provider sourced values are applied atomically: once a provider model is active, every field it defines is protected from lower layers until you manually clear credentials via `/auth`. The final `generationConfig` is the projection across all layers—lower layers only fill gaps left by higher ones, and the provider layer remains impenetrable.
|
|
||||||
|
|
||||||
The merge strategy for `modelProviders` is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two.
|
|
||||||
|
|
||||||
##### Generation config layering
|
|
||||||
|
|
||||||
Per-field precedence for `generationConfig`:
|
|
||||||
|
|
||||||
1. Programmatic overrides (e.g. runtime `/model`, `/auth` changes)
|
|
||||||
2. `modelProviders[authType][].generationConfig`
|
|
||||||
3. `settings.model.generationConfig`
|
|
||||||
4. Content-generator defaults (`getDefaultGenerationConfig` for OpenAI, `getParameterValue` for Gemini, etc.)
|
|
||||||
|
|
||||||
`samplingParams` is treated atomically; provider values replace the entire object. Defaults from the content generator apply last so each provider retains its tuned baseline.
|
|
||||||
|
|
||||||
##### Selection persistence and recommendations
|
|
||||||
|
|
||||||
> [!important]
|
|
||||||
> Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope.
|
|
||||||
|
|
||||||
- `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog.
|
|
||||||
- Without `modelProviders`, the resolver mixes CLI/env/settings layers, which is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable.
|
|
||||||
|
|
||||||
#### context
|
#### context
|
||||||
|
|
||||||
| Setting | Type | Description | Default |
|
| Setting | Type | Description | Default |
|
||||||
@@ -470,7 +407,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-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. |
|
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||||
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
|
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
|
||||||
| `--acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. |
|
| `--experimental-acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Experimental. |
|
||||||
| `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. |
|
| `--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` |
|
| `--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. | | |
|
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
|
||||||
|
|||||||
@@ -10,5 +10,4 @@ export default {
|
|||||||
mcp: 'MCP',
|
mcp: 'MCP',
|
||||||
'token-caching': 'Token Caching',
|
'token-caching': 'Token Caching',
|
||||||
sandbox: 'Sandboxing',
|
sandbox: 'Sandboxing',
|
||||||
language: 'i18n',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ Commands specifically for controlling interface and output language.
|
|||||||
| → `ui [language]` | Set UI interface language | `/language ui zh-CN` |
|
| → `ui [language]` | Set UI interface language | `/language ui zh-CN` |
|
||||||
| → `output [language]` | Set LLM output language | `/language output Chinese` |
|
| → `output [language]` | Set LLM output language | `/language output Chinese` |
|
||||||
|
|
||||||
- Available built-in UI languages: `zh-CN` (Simplified Chinese), `en-US` (English), `ru-RU` (Russian), `de-DE` (German)
|
- Available UI languages: `zh-CN` (Simplified Chinese), `en-US` (English)
|
||||||
- Output language examples: `Chinese`, `English`, `Japanese`, etc.
|
- Output language examples: `Chinese`, `English`, `Japanese`, etc.
|
||||||
|
|
||||||
### 1.4 Tool and Model Management
|
### 1.4 Tool and Model Management
|
||||||
@@ -72,16 +72,17 @@ Commands for managing AI tools and models.
|
|||||||
|
|
||||||
Commands for obtaining information and performing system settings.
|
Commands for obtaining information and performing system settings.
|
||||||
|
|
||||||
| Command | Description | Usage Examples |
|
| Command | Description | Usage Examples |
|
||||||
| ----------- | ----------------------------------------------- | -------------------------------- |
|
| --------------- | ----------------------------------------------- | ------------------------------------------------ |
|
||||||
| `/help` | Display help information for available commands | `/help` or `/?` |
|
| `/help` | Display help information for available commands | `/help` or `/?` |
|
||||||
| `/about` | Display version information | `/about` |
|
| `/about` | Display version information | `/about` |
|
||||||
| `/stats` | Display detailed statistics for current session | `/stats` |
|
| `/stats` | Display detailed statistics for current session | `/stats` |
|
||||||
| `/settings` | Open settings editor | `/settings` |
|
| `/settings` | Open settings editor | `/settings` |
|
||||||
| `/auth` | Change authentication method | `/auth` |
|
| `/auth` | Change authentication method | `/auth` |
|
||||||
| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` |
|
| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` |
|
||||||
| `/copy` | Copy last output content to clipboard | `/copy` |
|
| `/copy` | Copy last output content to clipboard | `/copy` |
|
||||||
| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` |
|
| `/quit-confirm` | Show confirmation dialog before quitting | `/quit-confirm` (shortcut: press `Ctrl+C` twice) |
|
||||||
|
| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` |
|
||||||
|
|
||||||
### 1.6 Common Shortcuts
|
### 1.6 Common Shortcuts
|
||||||
|
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
# Internationalization (i18n) & Language
|
|
||||||
|
|
||||||
Qwen Code is built for multilingual workflows: it supports UI localization (i18n/l10n) in the CLI, lets you choose the assistant output language, and allows custom UI language packs.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
From a user point of view, Qwen Code’s “internationalization” spans multiple layers:
|
|
||||||
|
|
||||||
| Capability / Setting | What it controls | Where stored |
|
|
||||||
| ------------------------ | ---------------------------------------------------------------------- | ---------------------------- |
|
|
||||||
| `/language ui` | Terminal UI text (menus, system messages, prompts) | `~/.qwen/settings.json` |
|
|
||||||
| `/language output` | Language the AI responds in (an output preference, not UI translation) | `~/.qwen/output-language.md` |
|
|
||||||
| Custom UI language packs | Overrides/extends built-in UI translations | `~/.qwen/locales/*.js` |
|
|
||||||
|
|
||||||
## UI Language
|
|
||||||
|
|
||||||
This is the CLI’s UI localization layer (i18n/l10n): it controls the language of menus, prompts, and system messages.
|
|
||||||
|
|
||||||
### Setting the UI Language
|
|
||||||
|
|
||||||
Use the `/language ui` command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/language ui zh-CN # Chinese
|
|
||||||
/language ui en-US # English
|
|
||||||
/language ui ru-RU # Russian
|
|
||||||
/language ui de-DE # German
|
|
||||||
```
|
|
||||||
|
|
||||||
Aliases are also supported:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/language ui zh # Chinese
|
|
||||||
/language ui en # English
|
|
||||||
/language ui ru # Russian
|
|
||||||
/language ui de # German
|
|
||||||
```
|
|
||||||
|
|
||||||
### Auto-detection
|
|
||||||
|
|
||||||
On first startup, Qwen Code detects your system locale and sets the UI language automatically.
|
|
||||||
|
|
||||||
Detection priority:
|
|
||||||
|
|
||||||
1. `QWEN_CODE_LANG` environment variable
|
|
||||||
2. `LANG` environment variable
|
|
||||||
3. System locale via JavaScript Intl API
|
|
||||||
4. Default: English
|
|
||||||
|
|
||||||
## LLM Output Language
|
|
||||||
|
|
||||||
The LLM output language controls what language the AI assistant responds in, regardless of what language you type your questions in.
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
The LLM output language is controlled by a rule file at `~/.qwen/output-language.md`. This file is automatically included in the LLM's context during startup, instructing it to respond in the specified language.
|
|
||||||
|
|
||||||
### Auto-detection
|
|
||||||
|
|
||||||
On first startup, if no `output-language.md` file exists, Qwen Code automatically creates one based on your system locale. For example:
|
|
||||||
|
|
||||||
- System locale `zh` creates a rule for Chinese responses
|
|
||||||
- System locale `en` creates a rule for English responses
|
|
||||||
- System locale `ru` creates a rule for Russian responses
|
|
||||||
- System locale `de` creates a rule for German responses
|
|
||||||
|
|
||||||
### Manual Setting
|
|
||||||
|
|
||||||
Use `/language output <language>` to change:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/language output Chinese
|
|
||||||
/language output English
|
|
||||||
/language output Japanese
|
|
||||||
/language output German
|
|
||||||
```
|
|
||||||
|
|
||||||
Any language name works. The LLM will be instructed to respond in that language.
|
|
||||||
|
|
||||||
> [!note]
|
|
||||||
>
|
|
||||||
> After changing the output language, restart Qwen Code for the change to take effect.
|
|
||||||
|
|
||||||
### File Location
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.qwen/output-language.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Via Settings Dialog
|
|
||||||
|
|
||||||
1. Run `/settings`
|
|
||||||
2. Find "Language" under General
|
|
||||||
3. Select your preferred UI language
|
|
||||||
|
|
||||||
### Via Environment Variable
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export QWEN_CODE_LANG=zh
|
|
||||||
```
|
|
||||||
|
|
||||||
This influences auto-detection on first startup (if you haven’t set a UI language and no `output-language.md` file exists yet).
|
|
||||||
|
|
||||||
## Custom Language Packs
|
|
||||||
|
|
||||||
For UI translations, you can create custom language packs in `~/.qwen/locales/`:
|
|
||||||
|
|
||||||
- Example: `~/.qwen/locales/es.js` for Spanish
|
|
||||||
- Example: `~/.qwen/locales/fr.js` for French
|
|
||||||
|
|
||||||
User directory takes precedence over built-in translations.
|
|
||||||
|
|
||||||
> [!tip]
|
|
||||||
>
|
|
||||||
> Contributions are welcome! If you’d like to improve built-in translations or add new languages.
|
|
||||||
> For a concrete example, see [PR #1238: feat(i18n): add Russian language support](https://github.com/QwenLM/qwen-code/pull/1238).
|
|
||||||
|
|
||||||
### Language Pack Format
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ~/.qwen/locales/es.js
|
|
||||||
export default {
|
|
||||||
Hello: 'Hola',
|
|
||||||
Settings: 'Configuracion',
|
|
||||||
// ... more translations
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Related Commands
|
|
||||||
|
|
||||||
- `/language` - Show current language settings
|
|
||||||
- `/language ui [lang]` - Set UI language
|
|
||||||
- `/language output <language>` - Set LLM output language
|
|
||||||
- `/settings` - Open settings dialog
|
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"Qwen Code": {
|
"Qwen Code": {
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"command": "qwen",
|
"command": "qwen",
|
||||||
"args": ["--acp"],
|
"args": ["--experimental-acp"],
|
||||||
"env": {}
|
"env": {}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Qwen Code overview
|
# Qwen Code overview
|
||||||
|
[](https://npm-compare.com/@qwen-code/qwen-code)
|
||||||
[](https://npm-compare.com/@qwen-code/qwen-code)
|
|
||||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||||
|
|
||||||
> Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.
|
> Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ export default tseslint.config(
|
|||||||
'.integration-tests/**',
|
'.integration-tests/**',
|
||||||
'packages/**/.integration-test/**',
|
'packages/**/.integration-test/**',
|
||||||
'dist/**',
|
'dist/**',
|
||||||
'docs-site/.next/**',
|
|
||||||
'docs-site/out/**',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
|
|||||||
@@ -80,11 +80,10 @@ type PermissionHandler = (
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up an ACP test environment with all necessary utilities.
|
* 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(
|
function setupAcpTest(
|
||||||
rig: TestRig,
|
rig: TestRig,
|
||||||
options?: { permissionHandler?: PermissionHandler; useNewFlag?: boolean },
|
options?: { permissionHandler?: PermissionHandler },
|
||||||
) {
|
) {
|
||||||
const pending = new Map<number, PendingRequest>();
|
const pending = new Map<number, PendingRequest>();
|
||||||
let nextRequestId = 1;
|
let nextRequestId = 1;
|
||||||
@@ -96,13 +95,9 @@ function setupAcpTest(
|
|||||||
const permissionHandler =
|
const permissionHandler =
|
||||||
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' }));
|
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(
|
const agent = spawn(
|
||||||
'node',
|
'node',
|
||||||
[rig.bundlePath, acpFlag, '--no-chat-recording'],
|
[rig.bundlePath, '--experimental-acp', '--no-chat-recording'],
|
||||||
{
|
{
|
||||||
cwd: rig.testDir!,
|
cwd: rig.testDir!,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
@@ -626,99 +621,3 @@ 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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -314,88 +314,4 @@ describe('System Control (E2E)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('supportedCommands API', () => {
|
|
||||||
it('should return list of supported slash commands', async () => {
|
|
||||||
const sessionId = crypto.randomUUID();
|
|
||||||
const generator = (async function* () {
|
|
||||||
yield {
|
|
||||||
type: 'user',
|
|
||||||
session_id: sessionId,
|
|
||||||
message: { role: 'user', content: 'Hello' },
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
} as SDKUserMessage;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const q = query({
|
|
||||||
prompt: generator,
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
cwd: testDir,
|
|
||||||
model: 'qwen3-max',
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await q.supportedCommands();
|
|
||||||
// Start consuming messages to trigger initialization
|
|
||||||
const messageConsumer = (async () => {
|
|
||||||
try {
|
|
||||||
for await (const _message of q) {
|
|
||||||
// Just consume messages
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore errors from query being closed
|
|
||||||
if (error instanceof Error && error.message !== 'Query is closed') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Verify result structure
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result).toHaveProperty('commands');
|
|
||||||
expect(Array.isArray(result?.['commands'])).toBe(true);
|
|
||||||
|
|
||||||
const commands = result?.['commands'] as string[];
|
|
||||||
|
|
||||||
// Verify default allowed built-in commands are present
|
|
||||||
expect(commands).toContain('init');
|
|
||||||
expect(commands).toContain('summary');
|
|
||||||
expect(commands).toContain('compress');
|
|
||||||
|
|
||||||
// Verify commands are sorted
|
|
||||||
const sortedCommands = [...commands].sort();
|
|
||||||
expect(commands).toEqual(sortedCommands);
|
|
||||||
|
|
||||||
// Verify all commands are strings
|
|
||||||
commands.forEach((cmd) => {
|
|
||||||
expect(typeof cmd).toBe('string');
|
|
||||||
expect(cmd.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
await q.close();
|
|
||||||
await messageConsumer;
|
|
||||||
} catch (error) {
|
|
||||||
await q.close();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error when supportedCommands is called on closed query', async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'Hello',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
cwd: testDir,
|
|
||||||
model: 'qwen3-max',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await q.close();
|
|
||||||
|
|
||||||
await expect(q.supportedCommands()).rejects.toThrow('Query is closed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.7.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.7.0",
|
"version": "0.6.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
@@ -17316,7 +17316,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.7.0",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "1.30.0",
|
"@google/genai": "1.30.0",
|
||||||
"@iarna/toml": "^2.2.5",
|
"@iarna/toml": "^2.2.5",
|
||||||
@@ -17953,7 +17953,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@qwen-code/qwen-code-core",
|
"name": "@qwen-code/qwen-code-core",
|
||||||
"version": "0.7.0",
|
"version": "0.6.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.36.1",
|
"@anthropic-ai/sdk": "^0.36.1",
|
||||||
@@ -21413,7 +21413,7 @@
|
|||||||
},
|
},
|
||||||
"packages/test-utils": {
|
"packages/test-utils": {
|
||||||
"name": "@qwen-code/qwen-code-test-utils",
|
"name": "@qwen-code/qwen-code-test-utils",
|
||||||
"version": "0.7.0",
|
"version": "0.6.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -21425,7 +21425,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode-ide-companion": {
|
"packages/vscode-ide-companion": {
|
||||||
"name": "qwen-code-vscode-ide-companion",
|
"name": "qwen-code-vscode-ide-companion",
|
||||||
"version": "0.7.0",
|
"version": "0.6.0",
|
||||||
"license": "LICENSE",
|
"license": "LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.7.0",
|
"version": "0.6.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
|
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "cross-env node scripts/start.js",
|
"start": "cross-env node scripts/start.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.7.0",
|
"version": "0.6.0",
|
||||||
"description": "Qwen Code",
|
"description": "Qwen Code",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"config": {
|
"config": {
|
||||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
|
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "1.30.0",
|
"@google/genai": "1.30.0",
|
||||||
|
|||||||
@@ -98,14 +98,6 @@ export class AgentSideConnection implements Client {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a custom notification to the client.
|
|
||||||
* Used for extension-specific notifications that are not part of the core ACP protocol.
|
|
||||||
*/
|
|
||||||
async sendCustomNotification<T>(method: string, params: T): Promise<void> {
|
|
||||||
return await this.#connection.sendNotification(method, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request permission before running a tool
|
* Request permission before running a tool
|
||||||
*
|
*
|
||||||
@@ -382,7 +374,6 @@ export interface Client {
|
|||||||
): Promise<schema.RequestPermissionResponse>;
|
): Promise<schema.RequestPermissionResponse>;
|
||||||
sessionUpdate(params: schema.SessionNotification): Promise<void>;
|
sessionUpdate(params: schema.SessionNotification): Promise<void>;
|
||||||
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
|
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
|
||||||
sendCustomNotification<T>(method: string, params: T): Promise<void>;
|
|
||||||
writeTextFile(
|
writeTextFile(
|
||||||
params: schema.WriteTextFileRequest,
|
params: schema.WriteTextFileRequest,
|
||||||
): Promise<schema.WriteTextFileResponse>;
|
): Promise<schema.WriteTextFileResponse>;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
qwenOAuth2Events,
|
qwenOAuth2Events,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
SessionService,
|
SessionService,
|
||||||
|
buildApiHistoryFromConversation,
|
||||||
type Config,
|
type Config,
|
||||||
type ConversationRecord,
|
type ConversationRecord,
|
||||||
type DeviceAuthorizationData,
|
type DeviceAuthorizationData,
|
||||||
@@ -348,20 +349,12 @@ class GeminiAgent {
|
|||||||
const sessionId = config.getSessionId();
|
const sessionId = config.getSessionId();
|
||||||
const geminiClient = config.getGeminiClient();
|
const geminiClient = config.getGeminiClient();
|
||||||
|
|
||||||
// Use GeminiClient to manage chat lifecycle properly
|
const history = conversation
|
||||||
// This ensures geminiClient.chat is in sync with the session's chat
|
? buildApiHistoryFromConversation(conversation)
|
||||||
//
|
: undefined;
|
||||||
// Note: When loading a session, config.initialize() has already been called
|
const chat = history
|
||||||
// in newSessionConfig(), which in turn calls geminiClient.initialize().
|
? await geminiClient.startChat(history)
|
||||||
// The GeminiClient.initialize() method checks config.getResumedSessionData()
|
: await geminiClient.startChat();
|
||||||
// and automatically loads the conversation history into the chat instance.
|
|
||||||
// So we only need to initialize if it hasn't been done yet.
|
|
||||||
if (!geminiClient.isInitialized()) {
|
|
||||||
await geminiClient.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now get the chat instance that's managed by GeminiClient
|
|
||||||
const chat = geminiClient.getChat();
|
|
||||||
|
|
||||||
const session = new Session(
|
const session = new Session(
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -41,11 +41,9 @@ import * as fs from 'node:fs/promises';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getErrorMessage } from '../../utils/errors.js';
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
import { normalizePartList } from '../../utils/nonInteractiveHelpers.js';
|
|
||||||
import {
|
import {
|
||||||
handleSlashCommand,
|
handleSlashCommand,
|
||||||
getAvailableCommands,
|
getAvailableCommands,
|
||||||
type NonInteractiveSlashCommandResult,
|
|
||||||
} from '../../nonInteractiveCliCommands.js';
|
} from '../../nonInteractiveCliCommands.js';
|
||||||
import type {
|
import type {
|
||||||
AvailableCommand,
|
AvailableCommand,
|
||||||
@@ -65,6 +63,12 @@ import { PlanEmitter } from './emitters/PlanEmitter.js';
|
|||||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||||
import { SubAgentTracker } from './SubAgentTracker.js';
|
import { SubAgentTracker } from './SubAgentTracker.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in commands that are allowed in ACP integration mode.
|
||||||
|
* Only safe, read-only commands that don't require interactive UI.
|
||||||
|
*/
|
||||||
|
export const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session represents an active conversation session with the AI model.
|
* Session represents an active conversation session with the AI model.
|
||||||
* It uses modular components for consistent event emission:
|
* It uses modular components for consistent event emission:
|
||||||
@@ -163,26 +167,24 @@ export class Session implements SessionContext {
|
|||||||
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
|
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
|
||||||
const inputText = firstTextBlock?.text || '';
|
const inputText = firstTextBlock?.text || '';
|
||||||
|
|
||||||
let parts: Part[] | null;
|
let parts: Part[];
|
||||||
|
|
||||||
if (isSlashCommand(inputText)) {
|
if (isSlashCommand(inputText)) {
|
||||||
// Handle slash command - uses default allowed commands (init, summary, compress)
|
// Handle slash command - allow specific built-in commands for ACP integration
|
||||||
const slashCommandResult = await handleSlashCommand(
|
const slashCommandResult = await handleSlashCommand(
|
||||||
inputText,
|
inputText,
|
||||||
pendingSend,
|
pendingSend,
|
||||||
this.config,
|
this.config,
|
||||||
this.settings,
|
this.settings,
|
||||||
|
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
|
||||||
);
|
);
|
||||||
|
|
||||||
parts = await this.#processSlashCommandResult(
|
if (slashCommandResult) {
|
||||||
slashCommandResult,
|
// Use the result from the slash command
|
||||||
params.prompt,
|
parts = slashCommandResult as Part[];
|
||||||
);
|
} else {
|
||||||
|
// Slash command didn't return a prompt, continue with normal processing
|
||||||
// If parts is null, the command was fully handled (e.g., /summary completed)
|
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||||
// Return early without sending to the model
|
|
||||||
if (parts === null) {
|
|
||||||
return { stopReason: 'end_turn' };
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal processing for non-slash commands
|
// Normal processing for non-slash commands
|
||||||
@@ -293,10 +295,11 @@ export class Session implements SessionContext {
|
|||||||
async sendAvailableCommandsUpdate(): Promise<void> {
|
async sendAvailableCommandsUpdate(): Promise<void> {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
try {
|
try {
|
||||||
// Use default allowed commands from getAvailableCommands
|
|
||||||
const slashCommands = await getAvailableCommands(
|
const slashCommands = await getAvailableCommands(
|
||||||
this.config,
|
this.config,
|
||||||
|
this.settings,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
|
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
|
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
|
||||||
@@ -644,103 +647,6 @@ export class Session implements SessionContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes the result of a slash command execution.
|
|
||||||
*
|
|
||||||
* Supported result types in ACP mode:
|
|
||||||
* - submit_prompt: Submits content to the model
|
|
||||||
* - stream_messages: Streams multiple messages to the client (ACP-specific)
|
|
||||||
* - unsupported: Command cannot be executed in ACP mode
|
|
||||||
* - no_command: No command was found, use original prompt
|
|
||||||
*
|
|
||||||
* Note: 'message' type is not supported in ACP mode - commands should use
|
|
||||||
* 'stream_messages' instead for consistent async handling.
|
|
||||||
*
|
|
||||||
* @param result The result from handleSlashCommand
|
|
||||||
* @param originalPrompt The original prompt blocks
|
|
||||||
* @returns Parts to use for the prompt, or null if command was handled without needing model interaction
|
|
||||||
*/
|
|
||||||
async #processSlashCommandResult(
|
|
||||||
result: NonInteractiveSlashCommandResult,
|
|
||||||
originalPrompt: acp.ContentBlock[],
|
|
||||||
): Promise<Part[] | null> {
|
|
||||||
switch (result.type) {
|
|
||||||
case 'submit_prompt':
|
|
||||||
// Command wants to submit a prompt to the model
|
|
||||||
// Convert PartListUnion to Part[]
|
|
||||||
return normalizePartList(result.content);
|
|
||||||
|
|
||||||
case 'message': {
|
|
||||||
// 'message' type is not ideal for ACP mode, but we handle it for compatibility
|
|
||||||
// by converting it to a stream_messages-like notification
|
|
||||||
await this.client.sendCustomNotification('_qwencode/slash_command', {
|
|
||||||
sessionId: this.sessionId,
|
|
||||||
command: originalPrompt
|
|
||||||
.filter((block) => block.type === 'text')
|
|
||||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
|
||||||
.join(' '),
|
|
||||||
messageType: result.messageType,
|
|
||||||
message: result.content || '',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.messageType === 'error') {
|
|
||||||
// Throw error to stop execution
|
|
||||||
throw new Error(result.content || 'Slash command failed.');
|
|
||||||
}
|
|
||||||
// For info messages, return null to indicate command was handled
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'stream_messages': {
|
|
||||||
// Command returns multiple messages via async generator (ACP-preferred)
|
|
||||||
const command = originalPrompt
|
|
||||||
.filter((block) => block.type === 'text')
|
|
||||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
// Stream all messages to the client
|
|
||||||
for await (const msg of result.messages) {
|
|
||||||
await this.client.sendCustomNotification('_qwencode/slash_command', {
|
|
||||||
sessionId: this.sessionId,
|
|
||||||
command,
|
|
||||||
messageType: msg.messageType,
|
|
||||||
message: msg.content,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we encounter an error message, throw after sending
|
|
||||||
if (msg.messageType === 'error') {
|
|
||||||
throw new Error(msg.content || 'Slash command failed.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// All messages sent successfully, return null to indicate command was handled
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'unsupported': {
|
|
||||||
// Command returned an unsupported result type
|
|
||||||
const unsupportedError = `Slash command not supported in ACP integration: ${result.reason}`;
|
|
||||||
throw new Error(unsupportedError);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'no_command':
|
|
||||||
// No command was found or executed, use original prompt
|
|
||||||
return originalPrompt.map((block) => {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
return { text: block.text };
|
|
||||||
}
|
|
||||||
throw new Error(`Unsupported block type: ${block.type}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
default: {
|
|
||||||
// Exhaustiveness check
|
|
||||||
const _exhaustive: never = result;
|
|
||||||
const unknownError = `Unknown slash command result type: ${(_exhaustive as NonInteractiveSlashCommandResult).type}`;
|
|
||||||
throw new Error(unknownError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async #resolvePrompt(
|
async #resolvePrompt(
|
||||||
message: acp.ContentBlock[],
|
message: acp.ContentBlock[],
|
||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
|
|||||||
@@ -1,112 +1,41 @@
|
|||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
* Copyright 2025 Qwen Team
|
* Copyright 2025 Google LLC
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { validateAuthMethod } from './auth.js';
|
import { validateAuthMethod } from './auth.js';
|
||||||
import * as settings from './settings.js';
|
|
||||||
|
|
||||||
vi.mock('./settings.js', () => ({
|
vi.mock('./settings.js', () => ({
|
||||||
loadEnvironment: vi.fn(),
|
loadEnvironment: vi.fn(),
|
||||||
loadSettings: vi.fn().mockReturnValue({
|
loadSettings: vi.fn().mockReturnValue({
|
||||||
merged: {},
|
merged: vi.fn().mockReturnValue({}),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('validateAuthMethod', () => {
|
describe('validateAuthMethod', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
// Reset mock to default
|
|
||||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
|
||||||
merged: {},
|
|
||||||
} as ReturnType<typeof settings.loadSettings>);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
delete process.env['OPENAI_API_KEY'];
|
|
||||||
delete process.env['CUSTOM_API_KEY'];
|
|
||||||
delete process.env['GEMINI_API_KEY'];
|
|
||||||
delete process.env['GEMINI_API_KEY_ALTERED'];
|
|
||||||
delete process.env['ANTHROPIC_API_KEY'];
|
|
||||||
delete process.env['ANTHROPIC_BASE_URL'];
|
|
||||||
delete process.env['GOOGLE_API_KEY'];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null for USE_OPENAI with default env key', () => {
|
it('should return null for USE_OPENAI', () => {
|
||||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
|
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return an error message for USE_OPENAI if no API key is available', () => {
|
it('should return an error message for USE_OPENAI if OPENAI_API_KEY is not set', () => {
|
||||||
|
delete process.env['OPENAI_API_KEY'];
|
||||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe(
|
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe(
|
||||||
"Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable.",
|
'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null for USE_OPENAI with custom envKey from modelProviders', () => {
|
|
||||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
|
||||||
merged: {
|
|
||||||
model: { name: 'custom-model' },
|
|
||||||
modelProviders: {
|
|
||||||
openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
|
||||||
process.env['CUSTOM_API_KEY'] = 'custom-key';
|
|
||||||
|
|
||||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return error with custom envKey hint when modelProviders envKey is set but env var is missing', () => {
|
|
||||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
|
||||||
merged: {
|
|
||||||
model: { name: 'custom-model' },
|
|
||||||
modelProviders: {
|
|
||||||
openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
|
||||||
|
|
||||||
const result = validateAuthMethod(AuthType.USE_OPENAI);
|
|
||||||
expect(result).toContain('CUSTOM_API_KEY');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for USE_GEMINI with custom envKey', () => {
|
|
||||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
|
||||||
merged: {
|
|
||||||
model: { name: 'gemini-1.5-flash' },
|
|
||||||
modelProviders: {
|
|
||||||
gemini: [
|
|
||||||
{ id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
|
||||||
process.env['GEMINI_API_KEY_ALTERED'] = 'altered-key';
|
|
||||||
|
|
||||||
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return error with custom envKey for USE_GEMINI when env var is missing', () => {
|
|
||||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
|
||||||
merged: {
|
|
||||||
model: { name: 'gemini-1.5-flash' },
|
|
||||||
modelProviders: {
|
|
||||||
gemini: [
|
|
||||||
{ id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
|
||||||
|
|
||||||
const result = validateAuthMethod(AuthType.USE_GEMINI);
|
|
||||||
expect(result).toContain('GEMINI_API_KEY_ALTERED');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for QWEN_OAUTH', () => {
|
it('should return null for QWEN_OAUTH', () => {
|
||||||
expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull();
|
expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -116,115 +45,4 @@ describe('validateAuthMethod', () => {
|
|||||||
'Invalid auth method selected.',
|
'Invalid auth method selected.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null for USE_ANTHROPIC with custom envKey and baseUrl', () => {
|
|
||||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
|
||||||
merged: {
|
|
||||||
model: { name: 'claude-3' },
|
|
||||||
modelProviders: {
|
|
||||||
anthropic: [
|
|
||||||
{
|
|
||||||
id: 'claude-3',
|
|
||||||
envKey: 'CUSTOM_ANTHROPIC_KEY',
|
|
||||||
baseUrl: 'https://api.anthropic.com',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
|
||||||
process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-anthropic-key';
|
|
||||||
|
|
||||||
expect(validateAuthMethod(AuthType.USE_ANTHROPIC)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return error for USE_ANTHROPIC when baseUrl is missing', () => {
|
|
||||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
|
||||||
merged: {
|
|
||||||
model: { name: 'claude-3' },
|
|
||||||
modelProviders: {
|
|
||||||
anthropic: [{ id: 'claude-3', envKey: 'CUSTOM_ANTHROPIC_KEY' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
|
||||||
process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-key';
|
|
||||||
|
|
||||||
const result = validateAuthMethod(AuthType.USE_ANTHROPIC);
|
|
||||||
expect(result).toContain('modelProviders[].baseUrl');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for USE_VERTEX_AI with custom envKey', () => {
|
|
||||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
|
||||||
merged: {
|
|
||||||
model: { name: 'vertex-model' },
|
|
||||||
modelProviders: {
|
|
||||||
'vertex-ai': [
|
|
||||||
{ id: 'vertex-model', envKey: 'GOOGLE_API_KEY_VERTEX' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
|
||||||
process.env['GOOGLE_API_KEY_VERTEX'] = 'vertex-key';
|
|
||||||
|
|
||||||
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use config.modelsConfig.getModel() when Config is provided', () => {
|
|
||||||
// Settings has a different model
|
|
||||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
|
||||||
merged: {
|
|
||||||
model: { name: 'settings-model' },
|
|
||||||
modelProviders: {
|
|
||||||
openai: [
|
|
||||||
{ id: 'settings-model', envKey: 'SETTINGS_API_KEY' },
|
|
||||||
{ id: 'cli-model', envKey: 'CLI_API_KEY' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
|
||||||
|
|
||||||
// Mock Config object that returns a different model (e.g., from CLI args)
|
|
||||||
const mockConfig = {
|
|
||||||
modelsConfig: {
|
|
||||||
getModel: vi.fn().mockReturnValue('cli-model'),
|
|
||||||
},
|
|
||||||
} as unknown as import('@qwen-code/qwen-code-core').Config;
|
|
||||||
|
|
||||||
// Set the env key for the CLI model, not the settings model
|
|
||||||
process.env['CLI_API_KEY'] = 'cli-key';
|
|
||||||
|
|
||||||
// Should use 'cli-model' from config.modelsConfig.getModel(), not 'settings-model'
|
|
||||||
const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(mockConfig.modelsConfig.getModel).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fail validation when Config provides different model without matching env key', () => {
|
|
||||||
// Clean up any existing env keys first
|
|
||||||
delete process.env['CLI_API_KEY'];
|
|
||||||
delete process.env['SETTINGS_API_KEY'];
|
|
||||||
delete process.env['OPENAI_API_KEY'];
|
|
||||||
|
|
||||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
|
||||||
merged: {
|
|
||||||
model: { name: 'settings-model' },
|
|
||||||
modelProviders: {
|
|
||||||
openai: [
|
|
||||||
{ id: 'settings-model', envKey: 'SETTINGS_API_KEY' },
|
|
||||||
{ id: 'cli-model', envKey: 'CLI_API_KEY' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
|
||||||
|
|
||||||
const mockConfig = {
|
|
||||||
modelsConfig: {
|
|
||||||
getModel: vi.fn().mockReturnValue('cli-model'),
|
|
||||||
},
|
|
||||||
} as unknown as import('@qwen-code/qwen-code-core').Config;
|
|
||||||
|
|
||||||
// Don't set CLI_API_KEY - validation should fail
|
|
||||||
const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result).toContain('CLI_API_KEY');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,169 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
* Copyright 2025 Qwen Team
|
* Copyright 2025 Google LLC
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||||
AuthType,
|
import { loadEnvironment, loadSettings } from './settings.js';
|
||||||
type Config,
|
|
||||||
type ModelProvidersConfig,
|
|
||||||
type ProviderModelConfig,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import { loadEnvironment, loadSettings, type Settings } from './settings.js';
|
|
||||||
import { t } from '../i18n/index.js';
|
|
||||||
|
|
||||||
/**
|
export function validateAuthMethod(authMethod: string): string | null {
|
||||||
* Default environment variable names for each auth type
|
|
||||||
*/
|
|
||||||
const DEFAULT_ENV_KEYS: Record<string, string> = {
|
|
||||||
[AuthType.USE_OPENAI]: 'OPENAI_API_KEY',
|
|
||||||
[AuthType.USE_ANTHROPIC]: 'ANTHROPIC_API_KEY',
|
|
||||||
[AuthType.USE_GEMINI]: 'GEMINI_API_KEY',
|
|
||||||
[AuthType.USE_VERTEX_AI]: 'GOOGLE_API_KEY',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find model configuration from modelProviders by authType and modelId
|
|
||||||
*/
|
|
||||||
function findModelConfig(
|
|
||||||
modelProviders: ModelProvidersConfig | undefined,
|
|
||||||
authType: string,
|
|
||||||
modelId: string | undefined,
|
|
||||||
): ProviderModelConfig | undefined {
|
|
||||||
if (!modelProviders || !modelId) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const models = modelProviders[authType];
|
|
||||||
if (!Array.isArray(models)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return models.find((m) => m.id === modelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if API key is available for the given auth type and model configuration.
|
|
||||||
* Prioritizes custom envKey from modelProviders over default environment variables.
|
|
||||||
*/
|
|
||||||
function hasApiKeyForAuth(
|
|
||||||
authType: string,
|
|
||||||
settings: Settings,
|
|
||||||
config?: Config,
|
|
||||||
): {
|
|
||||||
hasKey: boolean;
|
|
||||||
checkedEnvKey: string | undefined;
|
|
||||||
isExplicitEnvKey: boolean;
|
|
||||||
} {
|
|
||||||
const modelProviders = settings.modelProviders as
|
|
||||||
| ModelProvidersConfig
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
// Use config.modelsConfig.getModel() if available for accurate model ID resolution
|
|
||||||
// that accounts for CLI args, env vars, and settings. Fall back to settings.model.name.
|
|
||||||
const modelId = config?.modelsConfig.getModel() ?? settings.model?.name;
|
|
||||||
|
|
||||||
// Try to find model-specific envKey from modelProviders
|
|
||||||
const modelConfig = findModelConfig(modelProviders, authType, modelId);
|
|
||||||
if (modelConfig?.envKey) {
|
|
||||||
// Explicit envKey configured - only check this env var, no apiKey fallback
|
|
||||||
const hasKey = !!process.env[modelConfig.envKey];
|
|
||||||
return {
|
|
||||||
hasKey,
|
|
||||||
checkedEnvKey: modelConfig.envKey,
|
|
||||||
isExplicitEnvKey: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Using default environment variable - apiKey fallback is allowed
|
|
||||||
const defaultEnvKey = DEFAULT_ENV_KEYS[authType];
|
|
||||||
if (defaultEnvKey) {
|
|
||||||
const hasKey = !!process.env[defaultEnvKey];
|
|
||||||
if (hasKey) {
|
|
||||||
return { hasKey, checkedEnvKey: defaultEnvKey, isExplicitEnvKey: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check settings.security.auth.apiKey as fallback (only for default env key)
|
|
||||||
if (settings.security?.auth?.apiKey) {
|
|
||||||
return {
|
|
||||||
hasKey: true,
|
|
||||||
checkedEnvKey: defaultEnvKey || undefined,
|
|
||||||
isExplicitEnvKey: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasKey: false,
|
|
||||||
checkedEnvKey: defaultEnvKey,
|
|
||||||
isExplicitEnvKey: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate API key error message based on auth check result.
|
|
||||||
* Returns null if API key is present, otherwise returns the appropriate error message.
|
|
||||||
*/
|
|
||||||
function getApiKeyError(
|
|
||||||
authMethod: string,
|
|
||||||
settings: Settings,
|
|
||||||
config?: Config,
|
|
||||||
): string | null {
|
|
||||||
const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth(
|
|
||||||
authMethod,
|
|
||||||
settings,
|
|
||||||
config,
|
|
||||||
);
|
|
||||||
if (hasKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const envKeyHint = checkedEnvKey || DEFAULT_ENV_KEYS[authMethod];
|
|
||||||
if (isExplicitEnvKey) {
|
|
||||||
return t(
|
|
||||||
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.',
|
|
||||||
{ envKeyHint },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return t(
|
|
||||||
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.',
|
|
||||||
{ envKeyHint },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that the required credentials and configuration exist for the given auth method.
|
|
||||||
*/
|
|
||||||
export function validateAuthMethod(
|
|
||||||
authMethod: string,
|
|
||||||
config?: Config,
|
|
||||||
): string | null {
|
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
loadEnvironment(settings.merged);
|
loadEnvironment(settings.merged);
|
||||||
|
|
||||||
if (authMethod === AuthType.USE_OPENAI) {
|
if (authMethod === AuthType.USE_OPENAI) {
|
||||||
const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth(
|
const hasApiKey =
|
||||||
authMethod,
|
process.env['OPENAI_API_KEY'] || settings.merged.security?.auth?.apiKey;
|
||||||
settings.merged,
|
if (!hasApiKey) {
|
||||||
config,
|
return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.';
|
||||||
);
|
|
||||||
if (!hasKey) {
|
|
||||||
const envKeyHint = checkedEnvKey
|
|
||||||
? `'${checkedEnvKey}'`
|
|
||||||
: "'OPENAI_API_KEY'";
|
|
||||||
if (isExplicitEnvKey) {
|
|
||||||
// Explicit envKey configured - only suggest setting the env var
|
|
||||||
return t(
|
|
||||||
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.',
|
|
||||||
{ envKeyHint },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Default env key - can use either apiKey or env var
|
|
||||||
return t(
|
|
||||||
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.',
|
|
||||||
{ envKeyHint },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -175,49 +27,36 @@ export function validateAuthMethod(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (authMethod === AuthType.USE_ANTHROPIC) {
|
if (authMethod === AuthType.USE_ANTHROPIC) {
|
||||||
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
|
const hasApiKey = process.env['ANTHROPIC_API_KEY'];
|
||||||
if (apiKeyError) {
|
if (!hasApiKey) {
|
||||||
return apiKeyError;
|
return 'ANTHROPIC_API_KEY environment variable not found.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check baseUrl - can come from modelProviders or environment
|
const hasBaseUrl = process.env['ANTHROPIC_BASE_URL'];
|
||||||
const modelProviders = settings.merged.modelProviders as
|
if (!hasBaseUrl) {
|
||||||
| ModelProvidersConfig
|
return 'ANTHROPIC_BASE_URL environment variable not found.';
|
||||||
| undefined;
|
|
||||||
// Use config.modelsConfig.getModel() if available for accurate model ID
|
|
||||||
const modelId =
|
|
||||||
config?.modelsConfig.getModel() ?? settings.merged.model?.name;
|
|
||||||
const modelConfig = findModelConfig(modelProviders, authMethod, modelId);
|
|
||||||
|
|
||||||
if (modelConfig && !modelConfig.baseUrl) {
|
|
||||||
return t(
|
|
||||||
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!modelConfig && !process.env['ANTHROPIC_BASE_URL']) {
|
|
||||||
return t('ANTHROPIC_BASE_URL environment variable not found.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authMethod === AuthType.USE_GEMINI) {
|
if (authMethod === AuthType.USE_GEMINI) {
|
||||||
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
|
const hasApiKey = process.env['GEMINI_API_KEY'];
|
||||||
if (apiKeyError) {
|
if (!hasApiKey) {
|
||||||
return apiKeyError;
|
return 'GEMINI_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authMethod === AuthType.USE_VERTEX_AI) {
|
if (authMethod === AuthType.USE_VERTEX_AI) {
|
||||||
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
|
const hasApiKey = process.env['GOOGLE_API_KEY'];
|
||||||
if (apiKeyError) {
|
if (!hasApiKey) {
|
||||||
return apiKeyError;
|
return 'GOOGLE_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
|
||||||
}
|
}
|
||||||
|
|
||||||
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
|
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return t('Invalid auth method selected.');
|
return 'Invalid auth method selected.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,8 +77,10 @@ vi.mock('read-package-up', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||||
const actualServer = await importOriginal<typeof ServerConfig>();
|
const actualServer = await vi.importActual<typeof ServerConfig>(
|
||||||
|
'@qwen-code/qwen-code-core',
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...actualServer,
|
...actualServer,
|
||||||
IdeClient: {
|
IdeClient: {
|
||||||
@@ -1595,58 +1597,6 @@ describe('Approval mode tool exclusion logic', () => {
|
|||||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
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 () => {
|
it('should exclude only shell tools in non-interactive mode with auto-edit approval mode', async () => {
|
||||||
process.argv = [
|
process.argv = [
|
||||||
'node',
|
'node',
|
||||||
|
|||||||
@@ -10,31 +10,25 @@ import {
|
|||||||
Config,
|
Config,
|
||||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||||
|
EditTool,
|
||||||
FileDiscoveryService,
|
FileDiscoveryService,
|
||||||
getCurrentGeminiMdFilename,
|
getCurrentGeminiMdFilename,
|
||||||
loadServerHierarchicalMemory,
|
loadServerHierarchicalMemory,
|
||||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||||
|
ShellTool,
|
||||||
|
WriteFileTool,
|
||||||
resolveTelemetrySettings,
|
resolveTelemetrySettings,
|
||||||
FatalConfigError,
|
FatalConfigError,
|
||||||
Storage,
|
Storage,
|
||||||
InputFormat,
|
InputFormat,
|
||||||
OutputFormat,
|
OutputFormat,
|
||||||
isToolEnabled,
|
|
||||||
SessionService,
|
SessionService,
|
||||||
type ResumedSessionData,
|
type ResumedSessionData,
|
||||||
type FileFilteringOptions,
|
type FileFilteringOptions,
|
||||||
type MCPServerConfig,
|
type MCPServerConfig,
|
||||||
type ToolName,
|
|
||||||
EditTool,
|
|
||||||
ShellTool,
|
|
||||||
WriteFileTool,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { extensionsCommand } from '../commands/extensions.js';
|
import { extensionsCommand } from '../commands/extensions.js';
|
||||||
import type { Settings } from './settings.js';
|
import type { Settings } from './settings.js';
|
||||||
import {
|
|
||||||
resolveCliGenerationConfig,
|
|
||||||
getAuthTypeFromEnv,
|
|
||||||
} from '../utils/modelConfigUtils.js';
|
|
||||||
import yargs, { type Argv } from 'yargs';
|
import yargs, { type Argv } from 'yargs';
|
||||||
import { hideBin } from 'yargs/helpers';
|
import { hideBin } from 'yargs/helpers';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
@@ -117,7 +111,6 @@ export interface CliArgs {
|
|||||||
telemetryOutfile: string | undefined;
|
telemetryOutfile: string | undefined;
|
||||||
allowedMcpServerNames: string[] | undefined;
|
allowedMcpServerNames: string[] | undefined;
|
||||||
allowedTools: string[] | undefined;
|
allowedTools: string[] | undefined;
|
||||||
acp: boolean | undefined;
|
|
||||||
experimentalAcp: boolean | undefined;
|
experimentalAcp: boolean | undefined;
|
||||||
experimentalSkills: boolean | undefined;
|
experimentalSkills: boolean | undefined;
|
||||||
extensions: string[] | undefined;
|
extensions: string[] | undefined;
|
||||||
@@ -311,15 +304,9 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
|||||||
description: 'Enables checkpointing of file edits',
|
description: 'Enables checkpointing of file edits',
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
.option('acp', {
|
|
||||||
type: 'boolean',
|
|
||||||
description: 'Starts the agent in ACP mode',
|
|
||||||
})
|
|
||||||
.option('experimental-acp', {
|
.option('experimental-acp', {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description:
|
description: 'Starts the agent in ACP mode',
|
||||||
'Starts the agent in ACP mode (deprecated, use --acp instead)',
|
|
||||||
hidden: true,
|
|
||||||
})
|
})
|
||||||
.option('experimental-skills', {
|
.option('experimental-skills', {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
@@ -602,19 +589,8 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
|||||||
// The import format is now only controlled by settings.memoryImportFormat
|
// The import format is now only controlled by settings.memoryImportFormat
|
||||||
// We no longer accept it as a CLI argument
|
// We no longer accept it as a CLI argument
|
||||||
|
|
||||||
// Handle deprecated --experimental-acp flag
|
// Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
|
||||||
if (result['experimentalAcp']) {
|
if (result['experimentalAcp'] && !result['channel']) {
|
||||||
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';
|
(result as Record<string, unknown>)['channel'] = 'ACP';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -842,28 +818,6 @@ export async function loadCliConfig(
|
|||||||
// However, if stream-json input is used, control can be requested via JSON messages,
|
// However, if stream-json input is used, control can be requested via JSON messages,
|
||||||
// so tools should not be excluded in that case.
|
// so tools should not be excluded in that case.
|
||||||
const extraExcludes: string[] = [];
|
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 (
|
if (
|
||||||
!interactive &&
|
!interactive &&
|
||||||
!argv.experimentalAcp &&
|
!argv.experimentalAcp &&
|
||||||
@@ -872,15 +826,12 @@ export async function loadCliConfig(
|
|||||||
switch (approvalMode) {
|
switch (approvalMode) {
|
||||||
case ApprovalMode.PLAN:
|
case ApprovalMode.PLAN:
|
||||||
case ApprovalMode.DEFAULT:
|
case ApprovalMode.DEFAULT:
|
||||||
// In default non-interactive mode, all tools that require approval are excluded,
|
// In default non-interactive mode, all tools that require approval are excluded.
|
||||||
// unless explicitly enabled via coreTools/allowedTools.
|
extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name);
|
||||||
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
|
||||||
excludeUnlessExplicit(EditTool.Name as ToolName);
|
|
||||||
excludeUnlessExplicit(WriteFileTool.Name as ToolName);
|
|
||||||
break;
|
break;
|
||||||
case ApprovalMode.AUTO_EDIT:
|
case ApprovalMode.AUTO_EDIT:
|
||||||
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
|
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
|
||||||
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
extraExcludes.push(ShellTool.Name);
|
||||||
break;
|
break;
|
||||||
case ApprovalMode.YOLO:
|
case ApprovalMode.YOLO:
|
||||||
// No extra excludes for YOLO mode.
|
// No extra excludes for YOLO mode.
|
||||||
@@ -928,25 +879,28 @@ export async function loadCliConfig(
|
|||||||
|
|
||||||
const selectedAuthType =
|
const selectedAuthType =
|
||||||
(argv.authType as AuthType | undefined) ||
|
(argv.authType as AuthType | undefined) ||
|
||||||
settings.security?.auth?.selectedType ||
|
settings.security?.auth?.selectedType;
|
||||||
/* getAuthTypeFromEnv means no authType was explicitly provided, we infer the authType from env vars */
|
|
||||||
getAuthTypeFromEnv();
|
|
||||||
|
|
||||||
// Unified resolution of generation config with source attribution
|
const apiKey =
|
||||||
const resolvedCliConfig = resolveCliGenerationConfig({
|
(selectedAuthType === AuthType.USE_OPENAI
|
||||||
argv: {
|
? argv.openaiApiKey ||
|
||||||
model: argv.model,
|
process.env['OPENAI_API_KEY'] ||
|
||||||
openaiApiKey: argv.openaiApiKey,
|
settings.security?.auth?.apiKey
|
||||||
openaiBaseUrl: argv.openaiBaseUrl,
|
: '') || '';
|
||||||
openaiLogging: argv.openaiLogging,
|
const baseUrl =
|
||||||
openaiLoggingDir: argv.openaiLoggingDir,
|
(selectedAuthType === AuthType.USE_OPENAI
|
||||||
},
|
? argv.openaiBaseUrl ||
|
||||||
settings,
|
process.env['OPENAI_BASE_URL'] ||
|
||||||
selectedAuthType,
|
settings.security?.auth?.baseUrl
|
||||||
env: process.env as Record<string, string | undefined>,
|
: '') || '';
|
||||||
});
|
const resolvedModel =
|
||||||
|
argv.model ||
|
||||||
const { model: resolvedModel } = resolvedCliConfig;
|
(selectedAuthType === AuthType.USE_OPENAI
|
||||||
|
? process.env['OPENAI_MODEL'] ||
|
||||||
|
process.env['QWEN_MODEL'] ||
|
||||||
|
settings.model?.name
|
||||||
|
: '') ||
|
||||||
|
'';
|
||||||
|
|
||||||
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
||||||
const screenReader =
|
const screenReader =
|
||||||
@@ -980,8 +934,6 @@ export async function loadCliConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelProvidersConfig = settings.modelProviders;
|
|
||||||
|
|
||||||
return new Config({
|
return new Config({
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionData,
|
sessionData,
|
||||||
@@ -1029,7 +981,7 @@ export async function loadCliConfig(
|
|||||||
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
|
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
|
||||||
maxSessionTurns:
|
maxSessionTurns:
|
||||||
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
||||||
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
|
experimentalZedIntegration: argv.experimentalAcp || false,
|
||||||
experimentalSkills: argv.experimentalSkills || false,
|
experimentalSkills: argv.experimentalSkills || false,
|
||||||
listExtensions: argv.listExtensions || false,
|
listExtensions: argv.listExtensions || false,
|
||||||
extensions: allExtensions,
|
extensions: allExtensions,
|
||||||
@@ -1039,11 +991,24 @@ export async function loadCliConfig(
|
|||||||
inputFormat,
|
inputFormat,
|
||||||
outputFormat,
|
outputFormat,
|
||||||
includePartialMessages,
|
includePartialMessages,
|
||||||
modelProvidersConfig,
|
generationConfig: {
|
||||||
generationConfigSources: resolvedCliConfig.sources,
|
...(settings.model?.generationConfig || {}),
|
||||||
generationConfig: resolvedCliConfig.generationConfig,
|
model: resolvedModel,
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
enableOpenAILogging:
|
||||||
|
(typeof argv.openaiLogging === 'undefined'
|
||||||
|
? settings.model?.enableOpenAILogging
|
||||||
|
: argv.openaiLogging) ?? false,
|
||||||
|
openAILoggingDir:
|
||||||
|
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
|
||||||
|
},
|
||||||
cliVersion: await getCliVersion(),
|
cliVersion: await getCliVersion(),
|
||||||
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType),
|
webSearch: buildWebSearchConfig(
|
||||||
|
argv,
|
||||||
|
settings,
|
||||||
|
settings.security?.auth?.selectedType,
|
||||||
|
),
|
||||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||||
ideMode,
|
ideMode,
|
||||||
chatCompression: settings.model?.chatCompression,
|
chatCompression: settings.model?.chatCompression,
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Qwen Team
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { SettingScope } from './settings.js';
|
|
||||||
import { getPersistScopeForModelSelection } from './modelProvidersScope.js';
|
|
||||||
|
|
||||||
function makeSettings({
|
|
||||||
isTrusted,
|
|
||||||
userModelProviders,
|
|
||||||
workspaceModelProviders,
|
|
||||||
}: {
|
|
||||||
isTrusted: boolean;
|
|
||||||
userModelProviders?: unknown;
|
|
||||||
workspaceModelProviders?: unknown;
|
|
||||||
}) {
|
|
||||||
const userSettings: Record<string, unknown> = {};
|
|
||||||
const workspaceSettings: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
// When undefined, treat as "not present in this scope" (the key is omitted),
|
|
||||||
// matching how LoadedSettings is shaped when a settings file doesn't define it.
|
|
||||||
if (userModelProviders !== undefined) {
|
|
||||||
userSettings['modelProviders'] = userModelProviders;
|
|
||||||
}
|
|
||||||
if (workspaceModelProviders !== undefined) {
|
|
||||||
workspaceSettings['modelProviders'] = workspaceModelProviders;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isTrusted,
|
|
||||||
user: { settings: userSettings },
|
|
||||||
workspace: { settings: workspaceSettings },
|
|
||||||
} as unknown as import('./settings.js').LoadedSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('getPersistScopeForModelSelection', () => {
|
|
||||||
it('prefers workspace when trusted and workspace defines modelProviders', () => {
|
|
||||||
const settings = makeSettings({
|
|
||||||
isTrusted: true,
|
|
||||||
workspaceModelProviders: {},
|
|
||||||
userModelProviders: { anything: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getPersistScopeForModelSelection(settings)).toBe(
|
|
||||||
SettingScope.Workspace,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to user when workspace does not define modelProviders', () => {
|
|
||||||
const settings = makeSettings({
|
|
||||||
isTrusted: true,
|
|
||||||
workspaceModelProviders: undefined,
|
|
||||||
userModelProviders: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores workspace modelProviders when workspace is untrusted', () => {
|
|
||||||
const settings = makeSettings({
|
|
||||||
isTrusted: false,
|
|
||||||
workspaceModelProviders: {},
|
|
||||||
userModelProviders: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to legacy trust heuristic when neither scope defines modelProviders', () => {
|
|
||||||
const trusted = makeSettings({
|
|
||||||
isTrusted: true,
|
|
||||||
userModelProviders: undefined,
|
|
||||||
workspaceModelProviders: undefined,
|
|
||||||
});
|
|
||||||
expect(getPersistScopeForModelSelection(trusted)).toBe(SettingScope.User);
|
|
||||||
|
|
||||||
const untrusted = makeSettings({
|
|
||||||
isTrusted: false,
|
|
||||||
userModelProviders: undefined,
|
|
||||||
workspaceModelProviders: undefined,
|
|
||||||
});
|
|
||||||
expect(getPersistScopeForModelSelection(untrusted)).toBe(SettingScope.User);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Qwen Team
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { SettingScope, type LoadedSettings } from './settings.js';
|
|
||||||
|
|
||||||
function hasOwnModelProviders(settingsObj: unknown): boolean {
|
|
||||||
if (!settingsObj || typeof settingsObj !== 'object') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const obj = settingsObj as Record<string, unknown>;
|
|
||||||
// Treat an explicitly configured empty object (modelProviders: {}) as "owned"
|
|
||||||
// by this scope, which is important when mergeStrategy is REPLACE.
|
|
||||||
return Object.prototype.hasOwnProperty.call(obj, 'modelProviders');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns which writable scope (Workspace/User) owns the effective modelProviders
|
|
||||||
* configuration.
|
|
||||||
*
|
|
||||||
* Note: Workspace scope is only considered when the workspace is trusted.
|
|
||||||
*/
|
|
||||||
export function getModelProvidersOwnerScope(
|
|
||||||
settings: LoadedSettings,
|
|
||||||
): SettingScope | undefined {
|
|
||||||
if (settings.isTrusted && hasOwnModelProviders(settings.workspace.settings)) {
|
|
||||||
return SettingScope.Workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasOwnModelProviders(settings.user.settings)) {
|
|
||||||
return SettingScope.User;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Choose the settings scope to persist a model selection.
|
|
||||||
* Prefer persisting back to the scope that contains the effective modelProviders
|
|
||||||
* config, otherwise fall back to the legacy trust-based heuristic.
|
|
||||||
*/
|
|
||||||
export function getPersistScopeForModelSelection(
|
|
||||||
settings: LoadedSettings,
|
|
||||||
): SettingScope {
|
|
||||||
return getModelProvidersOwnerScope(settings) ?? SettingScope.User;
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import type {
|
|||||||
TelemetrySettings,
|
TelemetrySettings,
|
||||||
AuthType,
|
AuthType,
|
||||||
ChatCompressionSettings,
|
ChatCompressionSettings,
|
||||||
ModelProvidersConfig,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
@@ -103,19 +102,6 @@ const SETTINGS_SCHEMA = {
|
|||||||
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Model providers configuration grouped by authType
|
|
||||||
modelProviders: {
|
|
||||||
type: 'object',
|
|
||||||
label: 'Model Providers',
|
|
||||||
category: 'Model',
|
|
||||||
requiresRestart: false,
|
|
||||||
default: {} as ModelProvidersConfig,
|
|
||||||
description:
|
|
||||||
'Model providers configuration grouped by authType. Each authType contains an array of model configurations.',
|
|
||||||
showInDialog: false,
|
|
||||||
mergeStrategy: MergeStrategy.REPLACE,
|
|
||||||
},
|
|
||||||
|
|
||||||
general: {
|
general: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
label: 'General',
|
label: 'General',
|
||||||
@@ -216,7 +202,6 @@ const SETTINGS_SCHEMA = {
|
|||||||
{ value: 'en', label: 'English' },
|
{ value: 'en', label: 'English' },
|
||||||
{ value: 'zh', label: '中文 (Chinese)' },
|
{ value: 'zh', label: '中文 (Chinese)' },
|
||||||
{ value: 'ru', label: 'Русский (Russian)' },
|
{ value: 'ru', label: 'Русский (Russian)' },
|
||||||
{ value: 'de', label: 'Deutsch (German)' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
terminalBell: {
|
terminalBell: {
|
||||||
@@ -690,6 +675,45 @@ const SETTINGS_SCHEMA = {
|
|||||||
{ value: 'openapi_30', label: 'OpenAPI 3.0 Strict' },
|
{ value: 'openapi_30', label: 'OpenAPI 3.0 Strict' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
samplingParams: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Sampling Parameters',
|
||||||
|
category: 'Generation Configuration',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as
|
||||||
|
| {
|
||||||
|
top_p?: number;
|
||||||
|
top_k?: number;
|
||||||
|
repetition_penalty?: number;
|
||||||
|
presence_penalty?: number;
|
||||||
|
frequency_penalty?: number;
|
||||||
|
temperature?: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
description: 'Sampling parameters for content generation.',
|
||||||
|
parentKey: 'generationConfig',
|
||||||
|
childKey: 'samplingParams',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
reasoning: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Reasoning Configuration',
|
||||||
|
category: 'Generation Configuration',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as
|
||||||
|
| false
|
||||||
|
| {
|
||||||
|
effort?: 'low' | 'medium' | 'high';
|
||||||
|
budget_tokens?: number;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
description:
|
||||||
|
'Reasoning configuration for models that support reasoning. Set to false to disable reasoning, or provide an object with effort level and optional token budget.',
|
||||||
|
parentKey: 'generationConfig',
|
||||||
|
childKey: 'reasoning',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { type LoadedSettings, SettingScope } from '../config/settings.js';
|
|||||||
import { performInitialAuth } from './auth.js';
|
import { performInitialAuth } from './auth.js';
|
||||||
import { validateTheme } from './theme.js';
|
import { validateTheme } from './theme.js';
|
||||||
import { initializeI18n } from '../i18n/index.js';
|
import { initializeI18n } from '../i18n/index.js';
|
||||||
import { initializeLlmOutputLanguage } from '../ui/commands/languageCommand.js';
|
|
||||||
|
|
||||||
export interface InitializationResult {
|
export interface InitializationResult {
|
||||||
authError: string | null;
|
authError: string | null;
|
||||||
@@ -42,12 +41,7 @@ export async function initializeApp(
|
|||||||
'auto';
|
'auto';
|
||||||
await initializeI18n(languageSetting);
|
await initializeI18n(languageSetting);
|
||||||
|
|
||||||
// Auto-detect and set LLM output language on first use
|
const authType = settings.merged.security?.auth?.selectedType;
|
||||||
initializeLlmOutputLanguage();
|
|
||||||
|
|
||||||
// Use authType from modelsConfig which respects CLI --auth-type argument
|
|
||||||
// over settings.security.auth.selectedType
|
|
||||||
const authType = config.modelsConfig.getCurrentAuthType();
|
|
||||||
const authError = await performInitialAuth(config, authType);
|
const authError = await performInitialAuth(config, authType);
|
||||||
|
|
||||||
// Fallback to user select when initial authentication fails
|
// Fallback to user select when initial authentication fails
|
||||||
@@ -61,7 +55,7 @@ export async function initializeApp(
|
|||||||
const themeError = validateTheme(settings);
|
const themeError = validateTheme(settings);
|
||||||
|
|
||||||
const shouldOpenAuthDialog =
|
const shouldOpenAuthDialog =
|
||||||
!config.modelsConfig.wasAuthTypeExplicitlyProvided() || !!authError;
|
settings.merged.security?.auth?.selectedType === undefined || !!authError;
|
||||||
|
|
||||||
if (config.getIdeMode()) {
|
if (config.getIdeMode()) {
|
||||||
const ideClient = await IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
|
|||||||
@@ -87,15 +87,6 @@ vi.mock('./config/sandboxConfig.js', () => ({
|
|||||||
loadSandboxConfig: vi.fn(),
|
loadSandboxConfig: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./core/initializer.js', () => ({
|
|
||||||
initializeApp: vi.fn().mockResolvedValue({
|
|
||||||
authError: null,
|
|
||||||
themeError: null,
|
|
||||||
shouldOpenAuthDialog: false,
|
|
||||||
geminiMdFileCount: 0,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('gemini.tsx main function', () => {
|
describe('gemini.tsx main function', () => {
|
||||||
let originalEnvGeminiSandbox: string | undefined;
|
let originalEnvGeminiSandbox: string | undefined;
|
||||||
let originalEnvSandbox: string | undefined;
|
let originalEnvSandbox: string | undefined;
|
||||||
@@ -371,6 +362,7 @@ describe('gemini.tsx main function', () => {
|
|||||||
expect(inputArg).toBe('hello stream');
|
expect(inputArg).toBe('hello stream');
|
||||||
|
|
||||||
expect(validateAuthSpy).toHaveBeenCalledWith(
|
expect(validateAuthSpy).toHaveBeenCalledWith(
|
||||||
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
configStub,
|
configStub,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
@@ -468,7 +460,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
telemetryOutfile: undefined,
|
telemetryOutfile: undefined,
|
||||||
allowedMcpServerNames: undefined,
|
allowedMcpServerNames: undefined,
|
||||||
allowedTools: undefined,
|
allowedTools: undefined,
|
||||||
acp: undefined,
|
|
||||||
experimentalAcp: undefined,
|
experimentalAcp: undefined,
|
||||||
experimentalSkills: undefined,
|
experimentalSkills: undefined,
|
||||||
extensions: undefined,
|
extensions: undefined,
|
||||||
@@ -648,37 +639,4 @@ describe('startInteractiveUI', () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
expect(checkForUpdates).toHaveBeenCalledTimes(1);
|
expect(checkForUpdates).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not check for updates when update nag is disabled', async () => {
|
|
||||||
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
|
|
||||||
|
|
||||||
const mockInitializationResult = {
|
|
||||||
authError: null,
|
|
||||||
themeError: null,
|
|
||||||
shouldOpenAuthDialog: false,
|
|
||||||
geminiMdFileCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const settingsWithUpdateNagDisabled = {
|
|
||||||
merged: {
|
|
||||||
general: {
|
|
||||||
disableUpdateNag: true,
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
hideWindowTitle: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as LoadedSettings;
|
|
||||||
|
|
||||||
await startInteractiveUI(
|
|
||||||
mockConfig,
|
|
||||||
settingsWithUpdateNagDisabled,
|
|
||||||
mockStartupWarnings,
|
|
||||||
mockWorkspaceRoot,
|
|
||||||
mockInitializationResult,
|
|
||||||
);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
expect(checkForUpdates).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type { Config, AuthType } from '@qwen-code/qwen-code-core';
|
||||||
import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core';
|
import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core';
|
||||||
import { render } from 'ink';
|
import { render } from 'ink';
|
||||||
import dns from 'node:dns';
|
import dns from 'node:dns';
|
||||||
@@ -183,18 +183,16 @@ export async function startInteractiveUI(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!settings.merged.general?.disableUpdateNag) {
|
checkForUpdates()
|
||||||
checkForUpdates()
|
.then((info) => {
|
||||||
.then((info) => {
|
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
})
|
||||||
})
|
.catch((err) => {
|
||||||
.catch((err) => {
|
// Silently ignore update check errors.
|
||||||
// Silently ignore update check errors.
|
if (config.getDebugMode()) {
|
||||||
if (config.getDebugMode()) {
|
console.error('Update check failed:', err);
|
||||||
console.error('Update check failed:', err);
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
registerCleanup(() => instance.unmount());
|
registerCleanup(() => instance.unmount());
|
||||||
}
|
}
|
||||||
@@ -252,20 +250,22 @@ export async function main() {
|
|||||||
argv,
|
argv,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!settings.merged.security?.auth?.useExternal) {
|
if (
|
||||||
|
settings.merged.security?.auth?.selectedType &&
|
||||||
|
!settings.merged.security?.auth?.useExternal
|
||||||
|
) {
|
||||||
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
|
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
|
||||||
try {
|
try {
|
||||||
const authType = partialConfig.modelsConfig.getCurrentAuthType();
|
const err = validateAuthMethod(
|
||||||
// Fresh users may not have selected/persisted an authType yet.
|
settings.merged.security.auth.selectedType,
|
||||||
// In that case, defer auth prompting/selection to the main interactive flow.
|
);
|
||||||
if (authType) {
|
if (err) {
|
||||||
const err = validateAuthMethod(authType, partialConfig);
|
throw new Error(err);
|
||||||
if (err) {
|
|
||||||
throw new Error(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
await partialConfig.refreshAuth(authType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await partialConfig.refreshAuth(
|
||||||
|
settings.merged.security.auth.selectedType,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error authenticating:', err);
|
console.error('Error authenticating:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -438,6 +438,8 @@ export async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
||||||
|
(argv.authType as AuthType) ||
|
||||||
|
settings.merged.security?.auth?.selectedType,
|
||||||
settings.merged.security?.auth?.useExternal,
|
settings.merged.security?.auth?.useExternal,
|
||||||
config,
|
config,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
* Copyright 2025 Qwen team
|
* Copyright 2025 Qwen
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -8,21 +8,15 @@ import * as fs from 'node:fs';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import {
|
|
||||||
type SupportedLanguage,
|
|
||||||
getLanguageNameFromLocale,
|
|
||||||
} from './languages.js';
|
|
||||||
|
|
||||||
export type { SupportedLanguage };
|
export type SupportedLanguage = 'en' | 'zh' | 'ru' | string; // Allow custom language codes
|
||||||
export { getLanguageNameFromLocale };
|
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let currentLanguage: SupportedLanguage = 'en';
|
let currentLanguage: SupportedLanguage = 'en';
|
||||||
let translations: Record<string, string | string[]> = {};
|
let translations: Record<string, string> = {};
|
||||||
|
|
||||||
// Cache
|
// Cache
|
||||||
type TranslationValue = string | string[];
|
type TranslationDict = Record<string, string>;
|
||||||
type TranslationDict = Record<string, TranslationValue>;
|
|
||||||
const translationCache: Record<string, TranslationDict> = {};
|
const translationCache: Record<string, TranslationDict> = {};
|
||||||
const loadingPromises: Record<string, Promise<TranslationDict>> = {};
|
const loadingPromises: Record<string, Promise<TranslationDict>> = {};
|
||||||
|
|
||||||
@@ -58,13 +52,11 @@ export function detectSystemLanguage(): SupportedLanguage {
|
|||||||
if (envLang?.startsWith('zh')) return 'zh';
|
if (envLang?.startsWith('zh')) return 'zh';
|
||||||
if (envLang?.startsWith('en')) return 'en';
|
if (envLang?.startsWith('en')) return 'en';
|
||||||
if (envLang?.startsWith('ru')) return 'ru';
|
if (envLang?.startsWith('ru')) return 'ru';
|
||||||
if (envLang?.startsWith('de')) return 'de';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
|
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
|
||||||
if (locale.startsWith('zh')) return 'zh';
|
if (locale.startsWith('zh')) return 'zh';
|
||||||
if (locale.startsWith('ru')) return 'ru';
|
if (locale.startsWith('ru')) return 'ru';
|
||||||
if (locale.startsWith('de')) return 'de';
|
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback to default
|
// Fallback to default
|
||||||
}
|
}
|
||||||
@@ -232,25 +224,9 @@ export function getCurrentLanguage(): SupportedLanguage {
|
|||||||
|
|
||||||
export function t(key: string, params?: Record<string, string>): string {
|
export function t(key: string, params?: Record<string, string>): string {
|
||||||
const translation = translations[key] ?? key;
|
const translation = translations[key] ?? key;
|
||||||
if (Array.isArray(translation)) {
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
return interpolate(translation, params);
|
return interpolate(translation, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a translation that is an array of strings.
|
|
||||||
* @param key The translation key
|
|
||||||
* @returns The array of strings, or an empty array if not found or not an array
|
|
||||||
*/
|
|
||||||
export function ta(key: string): string[] {
|
|
||||||
const translation = translations[key];
|
|
||||||
if (Array.isArray(translation)) {
|
|
||||||
return translation;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initializeI18n(
|
export async function initializeI18n(
|
||||||
lang?: SupportedLanguage | 'auto',
|
lang?: SupportedLanguage | 'auto',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Qwen team
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type SupportedLanguage = 'en' | 'zh' | 'ru' | 'de' | string;
|
|
||||||
|
|
||||||
export interface LanguageDefinition {
|
|
||||||
/** The internal locale code used by the i18n system (e.g., 'en', 'zh'). */
|
|
||||||
code: SupportedLanguage;
|
|
||||||
/** The standard name used in UI settings (e.g., 'en-US', 'zh-CN'). */
|
|
||||||
id: string;
|
|
||||||
/** The full English name of the language (e.g., 'English', 'Chinese'). */
|
|
||||||
fullName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SUPPORTED_LANGUAGES: readonly LanguageDefinition[] = [
|
|
||||||
{
|
|
||||||
code: 'en',
|
|
||||||
id: 'en-US',
|
|
||||||
fullName: 'English',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'zh',
|
|
||||||
id: 'zh-CN',
|
|
||||||
fullName: 'Chinese',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'ru',
|
|
||||||
id: 'ru-RU',
|
|
||||||
fullName: 'Russian',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'de',
|
|
||||||
id: 'de-DE',
|
|
||||||
fullName: 'German',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps a locale code to its English language name.
|
|
||||||
* Used for LLM output language instructions.
|
|
||||||
*/
|
|
||||||
export function getLanguageNameFromLocale(locale: SupportedLanguage): string {
|
|
||||||
const lang = SUPPORTED_LANGUAGES.find((l) => l.code === locale);
|
|
||||||
return lang?.fullName || 'English';
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -89,9 +89,6 @@ export default {
|
|||||||
'No tools available': 'No tools available',
|
'No tools available': 'No tools available',
|
||||||
'View or change the approval mode for tool usage':
|
'View or change the approval mode for tool usage':
|
||||||
'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',
|
'View or change the language setting': 'View or change the language setting',
|
||||||
'change the theme': 'change the theme',
|
'change the theme': 'change the theme',
|
||||||
'Select Theme': 'Select Theme',
|
'Select Theme': 'Select Theme',
|
||||||
@@ -105,8 +102,8 @@ export default {
|
|||||||
'Theme "{{themeName}}" not found.': 'Theme "{{themeName}}" not found.',
|
'Theme "{{themeName}}" not found.': 'Theme "{{themeName}}" not found.',
|
||||||
'Theme "{{themeName}}" not found in selected scope.':
|
'Theme "{{themeName}}" not found in selected scope.':
|
||||||
'Theme "{{themeName}}" not found in selected scope.',
|
'Theme "{{themeName}}" not found in selected scope.',
|
||||||
'Clear conversation history and free up context':
|
'clear the screen and conversation history':
|
||||||
'Clear conversation history and free up context',
|
'clear the screen and conversation history',
|
||||||
'Compresses the context by replacing it with a summary.':
|
'Compresses the context by replacing it with a summary.':
|
||||||
'Compresses the context by replacing it with a summary.',
|
'Compresses the context by replacing it with a summary.',
|
||||||
'open full Qwen Code documentation in your browser':
|
'open full Qwen Code documentation in your browser':
|
||||||
@@ -261,8 +258,6 @@ export default {
|
|||||||
', Tab to change focus': ', Tab to change focus',
|
', Tab to change focus': ', Tab to change focus',
|
||||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
|
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
|
||||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
|
||||||
'The command "/{{command}}" is not supported in non-interactive mode.',
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Settings Labels
|
// Settings Labels
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -595,12 +590,6 @@ export default {
|
|||||||
'No conversation found to summarize.': 'No conversation found to summarize.',
|
'No conversation found to summarize.': 'No conversation found to summarize.',
|
||||||
'Failed to generate project context summary: {{error}}':
|
'Failed to generate project context summary: {{error}}':
|
||||||
'Failed to generate project context summary: {{error}}',
|
'Failed to generate project context summary: {{error}}',
|
||||||
'Saved project summary to {{filePathForDisplay}}.':
|
|
||||||
'Saved project summary to {{filePathForDisplay}}.',
|
|
||||||
'Saving project summary...': 'Saving project summary...',
|
|
||||||
'Generating project summary...': 'Generating project summary...',
|
|
||||||
'Failed to generate summary - no text content received from LLM response':
|
|
||||||
'Failed to generate summary - no text content received from LLM response',
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Commands - Model
|
// Commands - Model
|
||||||
@@ -615,10 +604,9 @@ export default {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Commands - Clear
|
// Commands - Clear
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
'Starting a new session, resetting chat, and clearing terminal.':
|
'Clearing terminal and resetting chat.':
|
||||||
'Starting a new session, resetting chat, and clearing terminal.',
|
'Clearing terminal and resetting chat.',
|
||||||
'Starting a new session and clearing.':
|
'Clearing terminal.': 'Clearing terminal.',
|
||||||
'Starting a new session and clearing.',
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Commands - Compress
|
// Commands - Compress
|
||||||
@@ -770,21 +758,6 @@ export default {
|
|||||||
'Authentication timed out. Please try again.',
|
'Authentication timed out. Please try again.',
|
||||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)',
|
'Waiting for auth... (Press ESC or CTRL+C to cancel)',
|
||||||
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.':
|
|
||||||
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.',
|
|
||||||
'{{envKeyHint}} environment variable not found.':
|
|
||||||
'{{envKeyHint}} environment variable not found.',
|
|
||||||
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.':
|
|
||||||
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.',
|
|
||||||
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.':
|
|
||||||
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.',
|
|
||||||
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.':
|
|
||||||
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.',
|
|
||||||
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.':
|
|
||||||
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.',
|
|
||||||
'ANTHROPIC_BASE_URL environment variable not found.':
|
|
||||||
'ANTHROPIC_BASE_URL environment variable not found.',
|
|
||||||
'Invalid auth method selected.': 'Invalid auth method selected.',
|
|
||||||
'Failed to authenticate. Message: {{message}}':
|
'Failed to authenticate. Message: {{message}}':
|
||||||
'Failed to authenticate. Message: {{message}}',
|
'Failed to authenticate. Message: {{message}}',
|
||||||
'Authenticated successfully with {{authType}} credentials.':
|
'Authenticated successfully with {{authType}} credentials.':
|
||||||
@@ -806,15 +779,6 @@ export default {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
'Select Model': 'Select Model',
|
'Select Model': 'Select Model',
|
||||||
'(Press Esc to close)': '(Press Esc to close)',
|
'(Press Esc to close)': '(Press Esc to close)',
|
||||||
'Current (effective) configuration': 'Current (effective) configuration',
|
|
||||||
AuthType: 'AuthType',
|
|
||||||
'API Key': 'API Key',
|
|
||||||
unset: 'unset',
|
|
||||||
'(default)': '(default)',
|
|
||||||
'(set)': '(set)',
|
|
||||||
'(not set)': '(not set)',
|
|
||||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
|
||||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}",
|
|
||||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
|
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
|
||||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||||
@@ -963,137 +927,192 @@ export default {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
'Waiting for user confirmation...': 'Waiting for user confirmation...',
|
'Waiting for user confirmation...': 'Waiting for user confirmation...',
|
||||||
'(esc to cancel, {{time}})': '(esc to cancel, {{time}})',
|
'(esc to cancel, {{time}})': '(esc to cancel, {{time}})',
|
||||||
|
"I'm Feeling Lucky": "I'm Feeling Lucky",
|
||||||
// ============================================================================
|
'Shipping awesomeness... ': 'Shipping awesomeness... ',
|
||||||
// Loading Phrases
|
'Painting the serifs back on...': 'Painting the serifs back on...',
|
||||||
// ============================================================================
|
'Navigating the slime mold...': 'Navigating the slime mold...',
|
||||||
WITTY_LOADING_PHRASES: [
|
'Consulting the digital spirits...': 'Consulting the digital spirits...',
|
||||||
"I'm Feeling Lucky",
|
'Reticulating splines...': 'Reticulating splines...',
|
||||||
'Shipping awesomeness... ',
|
'Warming up the AI hamsters...': 'Warming up the AI hamsters...',
|
||||||
'Painting the serifs back on...',
|
'Asking the magic conch shell...': 'Asking the magic conch shell...',
|
||||||
'Navigating the slime mold...',
|
'Generating witty retort...': 'Generating witty retort...',
|
||||||
'Consulting the digital spirits...',
|
'Polishing the algorithms...': 'Polishing the algorithms...',
|
||||||
'Reticulating splines...',
|
"Don't rush perfection (or my code)...":
|
||||||
'Warming up the AI hamsters...',
|
|
||||||
'Asking the magic conch shell...',
|
|
||||||
'Generating witty retort...',
|
|
||||||
'Polishing the algorithms...',
|
|
||||||
"Don't rush perfection (or my code)...",
|
"Don't rush perfection (or my code)...",
|
||||||
'Brewing fresh bytes...',
|
'Brewing fresh bytes...': 'Brewing fresh bytes...',
|
||||||
'Counting electrons...',
|
'Counting electrons...': 'Counting electrons...',
|
||||||
'Engaging cognitive processors...',
|
'Engaging cognitive processors...': 'Engaging cognitive processors...',
|
||||||
|
'Checking for syntax errors in the universe...':
|
||||||
'Checking for syntax errors in the universe...',
|
'Checking for syntax errors in the universe...',
|
||||||
'One moment, optimizing humor...',
|
'One moment, optimizing humor...': 'One moment, optimizing humor...',
|
||||||
'Shuffling punchlines...',
|
'Shuffling punchlines...': 'Shuffling punchlines...',
|
||||||
'Untangling neural nets...',
|
'Untangling neural nets...': 'Untangling neural nets...',
|
||||||
'Compiling brilliance...',
|
'Compiling brilliance...': 'Compiling brilliance...',
|
||||||
'Loading wit.exe...',
|
'Loading wit.exe...': 'Loading wit.exe...',
|
||||||
'Summoning the cloud of wisdom...',
|
'Summoning the cloud of wisdom...': 'Summoning the cloud of wisdom...',
|
||||||
'Preparing a witty response...',
|
'Preparing a witty response...': 'Preparing a witty response...',
|
||||||
|
"Just a sec, I'm debugging reality...":
|
||||||
"Just a sec, I'm debugging reality...",
|
"Just a sec, I'm debugging reality...",
|
||||||
'Confuzzling the options...',
|
'Confuzzling the options...': 'Confuzzling the options...',
|
||||||
'Tuning the cosmic frequencies...',
|
'Tuning the cosmic frequencies...': 'Tuning the cosmic frequencies...',
|
||||||
|
'Crafting a response worthy of your patience...':
|
||||||
'Crafting a response worthy of your patience...',
|
'Crafting a response worthy of your patience...',
|
||||||
'Compiling the 1s and 0s...',
|
'Compiling the 1s and 0s...': 'Compiling the 1s and 0s...',
|
||||||
|
'Resolving dependencies... and existential crises...':
|
||||||
'Resolving dependencies... and existential crises...',
|
'Resolving dependencies... and existential crises...',
|
||||||
|
'Defragmenting memories... both RAM and personal...':
|
||||||
'Defragmenting memories... both RAM and personal...',
|
'Defragmenting memories... both RAM and personal...',
|
||||||
'Rebooting the humor module...',
|
'Rebooting the humor module...': 'Rebooting the humor module...',
|
||||||
|
'Caching the essentials (mostly cat memes)...':
|
||||||
'Caching the essentials (mostly cat memes)...',
|
'Caching the essentials (mostly cat memes)...',
|
||||||
'Optimizing for ludicrous speed',
|
'Optimizing for ludicrous speed': 'Optimizing for ludicrous speed',
|
||||||
|
"Swapping bits... don't tell the bytes...":
|
||||||
"Swapping bits... don't tell the bytes...",
|
"Swapping bits... don't tell the bytes...",
|
||||||
|
'Garbage collecting... be right back...':
|
||||||
'Garbage collecting... be right back...',
|
'Garbage collecting... be right back...',
|
||||||
'Assembling the interwebs...',
|
'Assembling the interwebs...': 'Assembling the interwebs...',
|
||||||
'Converting coffee into code...',
|
'Converting coffee into code...': 'Converting coffee into code...',
|
||||||
'Updating the syntax for reality...',
|
'Updating the syntax for reality...': 'Updating the syntax for reality...',
|
||||||
'Rewiring the synapses...',
|
'Rewiring the synapses...': 'Rewiring the synapses...',
|
||||||
|
'Looking for a misplaced semicolon...':
|
||||||
'Looking for a misplaced semicolon...',
|
'Looking for a misplaced semicolon...',
|
||||||
"Greasin' the cogs of the machine...",
|
"Greasin' the cogs of the machine...": "Greasin' the cogs of the machine...",
|
||||||
'Pre-heating the servers...',
|
'Pre-heating the servers...': 'Pre-heating the servers...',
|
||||||
'Calibrating the flux capacitor...',
|
'Calibrating the flux capacitor...': 'Calibrating the flux capacitor...',
|
||||||
'Engaging the improbability drive...',
|
'Engaging the improbability drive...': 'Engaging the improbability drive...',
|
||||||
'Channeling the Force...',
|
'Channeling the Force...': 'Channeling the Force...',
|
||||||
|
'Aligning the stars for optimal response...':
|
||||||
'Aligning the stars for optimal response...',
|
'Aligning the stars for optimal response...',
|
||||||
'So say we all...',
|
'So say we all...': 'So say we all...',
|
||||||
'Loading the next great idea...',
|
'Loading the next great idea...': 'Loading the next great idea...',
|
||||||
"Just a moment, I'm in the zone...",
|
"Just a moment, I'm in the zone...": "Just a moment, I'm in the zone...",
|
||||||
|
'Preparing to dazzle you with brilliance...':
|
||||||
'Preparing to dazzle you with brilliance...',
|
'Preparing to dazzle you with brilliance...',
|
||||||
|
"Just a tick, I'm polishing my wit...":
|
||||||
"Just a tick, I'm polishing my wit...",
|
"Just a tick, I'm polishing my wit...",
|
||||||
|
"Hold tight, I'm crafting a masterpiece...":
|
||||||
"Hold tight, I'm crafting a masterpiece...",
|
"Hold tight, I'm crafting a masterpiece...",
|
||||||
|
"Just a jiffy, I'm debugging the universe...":
|
||||||
"Just a jiffy, I'm debugging the universe...",
|
"Just a jiffy, I'm debugging the universe...",
|
||||||
|
"Just a moment, I'm aligning the pixels...":
|
||||||
"Just a moment, I'm aligning the pixels...",
|
"Just a moment, I'm aligning the pixels...",
|
||||||
|
"Just a sec, I'm optimizing the humor...":
|
||||||
"Just a sec, I'm optimizing the humor...",
|
"Just a sec, I'm optimizing the humor...",
|
||||||
|
"Just a moment, I'm tuning the algorithms...":
|
||||||
"Just a moment, I'm tuning the algorithms...",
|
"Just a moment, I'm tuning the algorithms...",
|
||||||
'Warp speed engaged...',
|
'Warp speed engaged...': 'Warp speed engaged...',
|
||||||
|
'Mining for more Dilithium crystals...':
|
||||||
'Mining for more Dilithium crystals...',
|
'Mining for more Dilithium crystals...',
|
||||||
"Don't panic...",
|
"Don't panic...": "Don't panic...",
|
||||||
'Following the white rabbit...',
|
'Following the white rabbit...': 'Following the white rabbit...',
|
||||||
|
'The truth is in here... somewhere...':
|
||||||
'The truth is in here... somewhere...',
|
'The truth is in here... somewhere...',
|
||||||
'Blowing on the cartridge...',
|
'Blowing on the cartridge...': 'Blowing on the cartridge...',
|
||||||
'Loading... Do a barrel roll!',
|
'Loading... Do a barrel roll!': 'Loading... Do a barrel roll!',
|
||||||
'Waiting for the respawn...',
|
'Waiting for the respawn...': 'Waiting for the respawn...',
|
||||||
|
'Finishing the Kessel Run in less than 12 parsecs...':
|
||||||
'Finishing the Kessel Run in less than 12 parsecs...',
|
'Finishing the Kessel Run in less than 12 parsecs...',
|
||||||
|
"The cake is not a lie, it's just still loading...":
|
||||||
"The cake is not a lie, it's just still loading...",
|
"The cake is not a lie, it's just still loading...",
|
||||||
|
'Fiddling with the character creation screen...':
|
||||||
'Fiddling with the character creation screen...',
|
'Fiddling with the character creation screen...',
|
||||||
|
"Just a moment, I'm finding the right meme...":
|
||||||
"Just a moment, I'm finding the right meme...",
|
"Just a moment, I'm finding the right meme...",
|
||||||
"Pressing 'A' to continue...",
|
"Pressing 'A' to continue...": "Pressing 'A' to continue...",
|
||||||
'Herding digital cats...',
|
'Herding digital cats...': 'Herding digital cats...',
|
||||||
'Polishing the pixels...',
|
'Polishing the pixels...': 'Polishing the pixels...',
|
||||||
|
'Finding a suitable loading screen pun...':
|
||||||
'Finding a suitable loading screen pun...',
|
'Finding a suitable loading screen pun...',
|
||||||
|
'Distracting you with this witty phrase...':
|
||||||
'Distracting you with this witty phrase...',
|
'Distracting you with this witty phrase...',
|
||||||
'Almost there... probably...',
|
'Almost there... probably...': 'Almost there... probably...',
|
||||||
|
'Our hamsters are working as fast as they can...':
|
||||||
'Our hamsters are working as fast as they can...',
|
'Our hamsters are working as fast as they can...',
|
||||||
'Giving Cloudy a pat on the head...',
|
'Giving Cloudy a pat on the head...': 'Giving Cloudy a pat on the head...',
|
||||||
'Petting the cat...',
|
'Petting the cat...': 'Petting the cat...',
|
||||||
'Rickrolling my boss...',
|
'Rickrolling my boss...': 'Rickrolling my boss...',
|
||||||
|
'Never gonna give you up, never gonna let you down...':
|
||||||
'Never gonna give you up, never gonna let you down...',
|
'Never gonna give you up, never gonna let you down...',
|
||||||
'Slapping the bass...',
|
'Slapping the bass...': 'Slapping the bass...',
|
||||||
'Tasting the snozberries...',
|
'Tasting the snozberries...': 'Tasting the snozberries...',
|
||||||
|
"I'm going the distance, I'm going for speed...":
|
||||||
"I'm going the distance, I'm going for speed...",
|
"I'm going the distance, I'm going for speed...",
|
||||||
|
'Is this the real life? Is this just fantasy?...':
|
||||||
'Is this the real life? Is this just fantasy?...',
|
'Is this the real life? Is this just fantasy?...',
|
||||||
|
"I've got a good feeling about this...":
|
||||||
"I've got a good feeling about this...",
|
"I've got a good feeling about this...",
|
||||||
'Poking the bear...',
|
'Poking the bear...': 'Poking the bear...',
|
||||||
|
'Doing research on the latest memes...':
|
||||||
'Doing research on the latest memes...',
|
'Doing research on the latest memes...',
|
||||||
|
'Figuring out how to make this more witty...':
|
||||||
'Figuring out how to make this more witty...',
|
'Figuring out how to make this more witty...',
|
||||||
'Hmmm... let me think...',
|
'Hmmm... let me think...': 'Hmmm... let me think...',
|
||||||
|
'What do you call a fish with no eyes? A fsh...':
|
||||||
'What do you call a fish with no eyes? A fsh...',
|
'What do you call a fish with no eyes? A fsh...',
|
||||||
|
'Why did the computer go to therapy? It had too many bytes...':
|
||||||
'Why did the computer go to therapy? It had too many bytes...',
|
'Why did the computer go to therapy? It had too many bytes...',
|
||||||
|
"Why don't programmers like nature? It has too many bugs...":
|
||||||
"Why don't programmers like nature? It has too many bugs...",
|
"Why don't programmers like nature? It has too many bugs...",
|
||||||
|
'Why do programmers prefer dark mode? Because light attracts bugs...':
|
||||||
'Why do programmers prefer dark mode? Because light attracts bugs...',
|
'Why do programmers prefer dark mode? Because light attracts bugs...',
|
||||||
|
'Why did the developer go broke? Because they used up all their cache...':
|
||||||
'Why did the developer go broke? Because they used up all their cache...',
|
'Why did the developer go broke? Because they used up all their cache...',
|
||||||
|
"What can you do with a broken pencil? Nothing, it's pointless...":
|
||||||
"What can you do with a broken pencil? Nothing, it's pointless...",
|
"What can you do with a broken pencil? Nothing, it's pointless...",
|
||||||
'Applying percussive maintenance...',
|
'Applying percussive maintenance...': 'Applying percussive maintenance...',
|
||||||
|
'Searching for the correct USB orientation...':
|
||||||
'Searching for the correct USB orientation...',
|
'Searching for the correct USB orientation...',
|
||||||
|
'Ensuring the magic smoke stays inside the wires...':
|
||||||
'Ensuring the magic smoke stays inside the wires...',
|
'Ensuring the magic smoke stays inside the wires...',
|
||||||
'Trying to exit Vim...',
|
'Rewriting in Rust for no particular reason...':
|
||||||
'Spinning up the hamster wheel...',
|
'Rewriting in Rust for no particular reason...',
|
||||||
|
'Trying to exit Vim...': 'Trying to exit Vim...',
|
||||||
|
'Spinning up the hamster wheel...': 'Spinning up the hamster wheel...',
|
||||||
|
"That's not a bug, it's an undocumented feature...":
|
||||||
"That's not a bug, it's an undocumented feature...",
|
"That's not a bug, it's an undocumented feature...",
|
||||||
'Engage.',
|
'Engage.': 'Engage.',
|
||||||
"I'll be back... with an answer.",
|
"I'll be back... with an answer.": "I'll be back... with an answer.",
|
||||||
'My other process is a TARDIS...',
|
'My other process is a TARDIS...': 'My other process is a TARDIS...',
|
||||||
|
'Communing with the machine spirit...':
|
||||||
'Communing with the machine spirit...',
|
'Communing with the machine spirit...',
|
||||||
'Letting the thoughts marinate...',
|
'Letting the thoughts marinate...': 'Letting the thoughts marinate...',
|
||||||
|
'Just remembered where I put my keys...':
|
||||||
'Just remembered where I put my keys...',
|
'Just remembered where I put my keys...',
|
||||||
'Pondering the orb...',
|
'Pondering the orb...': 'Pondering the orb...',
|
||||||
|
"I've seen things you people wouldn't believe... like a user who reads loading messages.":
|
||||||
"I've seen things you people wouldn't believe... like a user who reads loading messages.",
|
"I've seen things you people wouldn't believe... like a user who reads loading messages.",
|
||||||
'Initiating thoughtful gaze...',
|
'Initiating thoughtful gaze...': 'Initiating thoughtful gaze...',
|
||||||
|
"What's a computer's favorite snack? Microchips.":
|
||||||
"What's a computer's favorite snack? Microchips.",
|
"What's a computer's favorite snack? Microchips.",
|
||||||
|
"Why do Java developers wear glasses? Because they don't C#.":
|
||||||
"Why do Java developers wear glasses? Because they don't C#.",
|
"Why do Java developers wear glasses? Because they don't C#.",
|
||||||
'Charging the laser... pew pew!',
|
'Charging the laser... pew pew!': 'Charging the laser... pew pew!',
|
||||||
'Dividing by zero... just kidding!',
|
'Dividing by zero... just kidding!': 'Dividing by zero... just kidding!',
|
||||||
|
'Looking for an adult superviso... I mean, processing.':
|
||||||
'Looking for an adult superviso... I mean, processing.',
|
'Looking for an adult superviso... I mean, processing.',
|
||||||
'Making it go beep boop.',
|
'Making it go beep boop.': 'Making it go beep boop.',
|
||||||
|
'Buffering... because even AIs need a moment.':
|
||||||
'Buffering... because even AIs need a moment.',
|
'Buffering... because even AIs need a moment.',
|
||||||
|
'Entangling quantum particles for a faster response...':
|
||||||
'Entangling quantum particles for a faster response...',
|
'Entangling quantum particles for a faster response...',
|
||||||
|
'Polishing the chrome... on the algorithms.':
|
||||||
'Polishing the chrome... on the algorithms.',
|
'Polishing the chrome... on the algorithms.',
|
||||||
|
'Are you not entertained? (Working on it!)':
|
||||||
'Are you not entertained? (Working on it!)',
|
'Are you not entertained? (Working on it!)',
|
||||||
|
'Summoning the code gremlins... to help, of course.':
|
||||||
'Summoning the code gremlins... to help, of course.',
|
'Summoning the code gremlins... to help, of course.',
|
||||||
|
'Just waiting for the dial-up tone to finish...':
|
||||||
'Just waiting for the dial-up tone to finish...',
|
'Just waiting for the dial-up tone to finish...',
|
||||||
'Recalibrating the humor-o-meter.',
|
'Recalibrating the humor-o-meter.': 'Recalibrating the humor-o-meter.',
|
||||||
|
'My other loading screen is even funnier.':
|
||||||
'My other loading screen is even funnier.',
|
'My other loading screen is even funnier.',
|
||||||
|
"Pretty sure there's a cat walking on the keyboard somewhere...":
|
||||||
"Pretty sure there's a cat walking on the keyboard somewhere...",
|
"Pretty sure there's a cat walking on the keyboard somewhere...",
|
||||||
|
'Enhancing... Enhancing... Still loading.':
|
||||||
'Enhancing... Enhancing... Still loading.',
|
'Enhancing... Enhancing... Still loading.',
|
||||||
|
"It's not a bug, it's a feature... of this loading screen.":
|
||||||
"It's not a bug, it's a feature... of this loading screen.",
|
"It's not a bug, it's a feature... of this loading screen.",
|
||||||
|
'Have you tried turning it off and on again? (The loading screen, not me.)':
|
||||||
'Have you tried turning it off and on again? (The loading screen, not me.)',
|
'Have you tried turning it off and on again? (The loading screen, not me.)',
|
||||||
'Constructing additional pylons...',
|
'Constructing additional pylons...': 'Constructing additional pylons...',
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -89,10 +89,6 @@ 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}}':
|
|
||||||
'Недопустимый режим подтверждения "{{arg}}". Допустимые режимы: {{modes}}',
|
|
||||||
'Approval mode set to "{{mode}}"':
|
|
||||||
'Режим подтверждения установлен на "{{mode}}"',
|
|
||||||
'View or change the language setting':
|
'View or change the language setting':
|
||||||
'Просмотр или изменение настроек языка',
|
'Просмотр или изменение настроек языка',
|
||||||
'change the theme': 'Изменение темы',
|
'change the theme': 'Изменение темы',
|
||||||
@@ -107,8 +103,8 @@ export default {
|
|||||||
'Theme "{{themeName}}" not found.': 'Тема "{{themeName}}" не найдена.',
|
'Theme "{{themeName}}" not found.': 'Тема "{{themeName}}" не найдена.',
|
||||||
'Theme "{{themeName}}" not found in selected scope.':
|
'Theme "{{themeName}}" not found in selected scope.':
|
||||||
'Тема "{{themeName}}" не найдена в выбранной области.',
|
'Тема "{{themeName}}" не найдена в выбранной области.',
|
||||||
'Clear conversation history and free up context':
|
'clear the screen and conversation history':
|
||||||
'Очистить историю диалога и освободить контекст',
|
'Очистка экрана и истории диалога',
|
||||||
'Compresses the context by replacing it with a summary.':
|
'Compresses the context by replacing it with a summary.':
|
||||||
'Сжатие контекста заменой на краткую сводку',
|
'Сжатие контекста заменой на краткую сводку',
|
||||||
'open full Qwen Code documentation in your browser':
|
'open full Qwen Code documentation in your browser':
|
||||||
@@ -264,8 +260,7 @@ export default {
|
|||||||
', Tab to change focus': ', Tab для смены фокуса',
|
', Tab to change focus': ', Tab для смены фокуса',
|
||||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||||
'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.',
|
'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.',
|
||||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
|
||||||
'Команда "/{{command}}" не поддерживается в неинтерактивном режиме.',
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Метки настроек
|
// Метки настроек
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -318,7 +313,6 @@ export default {
|
|||||||
'Tool Output Truncation Lines': 'Лимит строк вывода инструментов',
|
'Tool Output Truncation Lines': 'Лимит строк вывода инструментов',
|
||||||
'Folder Trust': 'Доверие к папке',
|
'Folder Trust': 'Доверие к папке',
|
||||||
'Vision Model Preview': 'Визуальная модель (предпросмотр)',
|
'Vision Model Preview': 'Визуальная модель (предпросмотр)',
|
||||||
'Tool Schema Compliance': 'Соответствие схеме инструмента',
|
|
||||||
// Варианты перечислений настроек
|
// Варианты перечислений настроек
|
||||||
'Auto (detect from system)': 'Авто (определить из системы)',
|
'Auto (detect from system)': 'Авто (определить из системы)',
|
||||||
Text: 'Текст',
|
Text: 'Текст',
|
||||||
@@ -347,8 +341,8 @@ export default {
|
|||||||
'Установка предпочитаемого внешнего редактора',
|
'Установка предпочитаемого внешнего редактора',
|
||||||
'Manage extensions': 'Управление расширениями',
|
'Manage extensions': 'Управление расширениями',
|
||||||
'List active extensions': 'Показать активные расширения',
|
'List active extensions': 'Показать активные расширения',
|
||||||
'Update extensions. Usage: update <extension-names>|--all':
|
'Update extensions. Usage: update |--all':
|
||||||
'Обновить расширения. Использование: update <extension-names>|--all',
|
'Обновить расширения. Использование: update |--all',
|
||||||
'manage IDE integration': 'Управление интеграцией с IDE',
|
'manage IDE integration': 'Управление интеграцией с IDE',
|
||||||
'check status of IDE integration': 'Проверить статус интеграции с IDE',
|
'check status of IDE integration': 'Проверить статус интеграции с IDE',
|
||||||
'install required IDE companion for {{ideName}}':
|
'install required IDE companion for {{ideName}}':
|
||||||
@@ -406,8 +400,7 @@ export default {
|
|||||||
'Set LLM output language': 'Установка языка вывода LLM',
|
'Set LLM output language': 'Установка языка вывода LLM',
|
||||||
'Usage: /language ui [zh-CN|en-US]':
|
'Usage: /language ui [zh-CN|en-US]':
|
||||||
'Использование: /language ui [zh-CN|en-US|ru-RU]',
|
'Использование: /language ui [zh-CN|en-US|ru-RU]',
|
||||||
'Usage: /language output <language>':
|
'Usage: /language output ': 'Использование: /language output ',
|
||||||
'Использование: /language output <language>',
|
|
||||||
'Example: /language output 中文': 'Пример: /language output 中文',
|
'Example: /language output 中文': 'Пример: /language output 中文',
|
||||||
'Example: /language output English': 'Пример: /language output English',
|
'Example: /language output English': 'Пример: /language output English',
|
||||||
'Example: /language output 日本語': 'Пример: /language output 日本語',
|
'Example: /language output 日本語': 'Пример: /language output 日本語',
|
||||||
@@ -424,8 +417,9 @@ export default {
|
|||||||
'To request additional UI language packs, please open an issue on GitHub.':
|
'To request additional UI language packs, please open an issue on GitHub.':
|
||||||
'Для запроса дополнительных языковых пакетов интерфейса, пожалуйста, создайте обращение на GitHub.',
|
'Для запроса дополнительных языковых пакетов интерфейса, пожалуйста, создайте обращение на GitHub.',
|
||||||
'Available options:': 'Доступные варианты:',
|
'Available options:': 'Доступные варианты:',
|
||||||
' - zh-CN: Simplified Chinese': ' - zh-CN: Упрощенный китайский',
|
' - zh-CN: Simplified Chinese': ' - zh-CN: Упрощенный китайский',
|
||||||
' - en-US: English': ' - en-US: Английский',
|
' - en-US: English': ' - en-US: Английский',
|
||||||
|
' - ru-RU: Russian': ' - ru-RU: Русский',
|
||||||
'Set UI language to Simplified Chinese (zh-CN)':
|
'Set UI language to Simplified Chinese (zh-CN)':
|
||||||
'Установить язык интерфейса на упрощенный китайский (zh-CN)',
|
'Установить язык интерфейса на упрощенный китайский (zh-CN)',
|
||||||
'Set UI language to English (en-US)':
|
'Set UI language to English (en-US)':
|
||||||
@@ -441,8 +435,8 @@ export default {
|
|||||||
'Режим подтверждения изменен на: {{mode}}',
|
'Режим подтверждения изменен на: {{mode}}',
|
||||||
'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})':
|
'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})':
|
||||||
'Режим подтверждения изменен на: {{mode}} (сохранено в настройках {{scope}}{{location}})',
|
'Режим подтверждения изменен на: {{mode}} (сохранено в настройках {{scope}}{{location}})',
|
||||||
'Usage: /approval-mode <mode> [--session|--user|--project]':
|
'Usage: /approval-mode [--session|--user|--project]':
|
||||||
'Использование: /approval-mode <mode> [--session|--user|--project]',
|
'Использование: /approval-mode [--session|--user|--project]',
|
||||||
'Scope subcommands do not accept additional arguments.':
|
'Scope subcommands do not accept additional arguments.':
|
||||||
'Подкоманды области не принимают дополнительных аргументов.',
|
'Подкоманды области не принимают дополнительных аргументов.',
|
||||||
'Plan mode - Analyze only, do not modify files or execute commands':
|
'Plan mode - Analyze only, do not modify files or execute commands':
|
||||||
@@ -594,8 +588,8 @@ export default {
|
|||||||
'Ошибка при экспорте диалога: {{error}}',
|
'Ошибка при экспорте диалога: {{error}}',
|
||||||
'Conversation shared to {{filePath}}': 'Диалог экспортирован в {{filePath}}',
|
'Conversation shared to {{filePath}}': 'Диалог экспортирован в {{filePath}}',
|
||||||
'No conversation found to share.': 'Нет диалога для экспорта.',
|
'No conversation found to share.': 'Нет диалога для экспорта.',
|
||||||
'Share the current conversation to a markdown or json file. Usage: /chat share <file>':
|
'Share the current conversation to a markdown or json file. Usage: /chat share <путь-к-файлу>':
|
||||||
'Экспортировать текущий диалог в markdown или json файл. Использование: /chat share <файл>',
|
'Экспортировать текущий диалог в markdown или json файл. Использование: /chat share <путь-к-файлу>',
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Команды - Резюме
|
// Команды - Резюме
|
||||||
@@ -610,12 +604,6 @@ export default {
|
|||||||
'Не найдено диалогов для создания сводки.',
|
'Не найдено диалогов для создания сводки.',
|
||||||
'Failed to generate project context summary: {{error}}':
|
'Failed to generate project context summary: {{error}}':
|
||||||
'Не удалось сгенерировать сводку контекста проекта: {{error}}',
|
'Не удалось сгенерировать сводку контекста проекта: {{error}}',
|
||||||
'Saved project summary to {{filePathForDisplay}}.':
|
|
||||||
'Сводка проекта сохранена в {{filePathForDisplay}}',
|
|
||||||
'Saving project summary...': 'Сохранение сводки проекта...',
|
|
||||||
'Generating project summary...': 'Генерация сводки проекта...',
|
|
||||||
'Failed to generate summary - no text content received from LLM response':
|
|
||||||
'Не удалось сгенерировать сводку - не получен текстовый контент из ответа LLM',
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Команды - Модель
|
// Команды - Модель
|
||||||
@@ -630,9 +618,8 @@ export default {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Команды - Очистка
|
// Команды - Очистка
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
'Starting a new session, resetting chat, and clearing terminal.':
|
'Clearing terminal and resetting chat.': 'Очистка терминала и сброс чата.',
|
||||||
'Начало новой сессии, сброс чата и очистка терминала.',
|
'Clearing terminal.': 'Очистка терминала.',
|
||||||
'Starting a new session and clearing.': 'Начало новой сессии и очистка.',
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Команды - Сжатие
|
// Команды - Сжатие
|
||||||
@@ -663,8 +650,8 @@ export default {
|
|||||||
'Команда /directory add не поддерживается в ограничительных профилях песочницы. Пожалуйста, используйте --include-directories при запуске сессии.',
|
'Команда /directory add не поддерживается в ограничительных профилях песочницы. Пожалуйста, используйте --include-directories при запуске сессии.',
|
||||||
"Error adding '{{path}}': {{error}}":
|
"Error adding '{{path}}': {{error}}":
|
||||||
"Ошибка при добавлении '{{path}}': {{error}}",
|
"Ошибка при добавлении '{{path}}': {{error}}",
|
||||||
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}':
|
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}':
|
||||||
'Успешно добавлены файлы QWEN.md из следующих директорий (если они есть):\n- {{directories}}',
|
'Успешно добавлены файлы GEMINI.md из следующих директорий (если они есть):\n- {{directories}}',
|
||||||
'Error refreshing memory: {{error}}':
|
'Error refreshing memory: {{error}}':
|
||||||
'Ошибка при обновлении памяти: {{error}}',
|
'Ошибка при обновлении памяти: {{error}}',
|
||||||
'Successfully added directories:\n- {{directories}}':
|
'Successfully added directories:\n- {{directories}}':
|
||||||
@@ -786,21 +773,6 @@ export default {
|
|||||||
'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.',
|
'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.',
|
||||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||||
'Ожидание авторизации... (Нажмите ESC или CTRL+C для отмены)',
|
'Ожидание авторизации... (Нажмите ESC или CTRL+C для отмены)',
|
||||||
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.':
|
|
||||||
'Отсутствует API-ключ для аутентификации, совместимой с OpenAI. Укажите settings.security.auth.apiKey или переменную окружения {{envKeyHint}}.',
|
|
||||||
'{{envKeyHint}} environment variable not found.':
|
|
||||||
'Переменная окружения {{envKeyHint}} не найдена.',
|
|
||||||
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.':
|
|
||||||
'Переменная окружения {{envKeyHint}} не найдена. Укажите её в файле .env или среди системных переменных.',
|
|
||||||
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.':
|
|
||||||
'Переменная окружения {{envKeyHint}} не найдена (или установите settings.security.auth.apiKey). Укажите её в файле .env или среди системных переменных.',
|
|
||||||
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.':
|
|
||||||
'Отсутствует API-ключ для аутентификации, совместимой с OpenAI. Установите переменную окружения {{envKeyHint}}.',
|
|
||||||
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.':
|
|
||||||
'У провайдера Anthropic отсутствует обязательный baseUrl в modelProviders[].baseUrl.',
|
|
||||||
'ANTHROPIC_BASE_URL environment variable not found.':
|
|
||||||
'Переменная окружения ANTHROPIC_BASE_URL не найдена.',
|
|
||||||
'Invalid auth method selected.': 'Выбран недопустимый метод авторизации.',
|
|
||||||
'Failed to authenticate. Message: {{message}}':
|
'Failed to authenticate. Message: {{message}}':
|
||||||
'Не удалось авторизоваться. Сообщение: {{message}}',
|
'Не удалось авторизоваться. Сообщение: {{message}}',
|
||||||
'Authenticated successfully with {{authType}} credentials.':
|
'Authenticated successfully with {{authType}} credentials.':
|
||||||
@@ -822,15 +794,6 @@ export default {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
'Select Model': 'Выбрать модель',
|
'Select Model': 'Выбрать модель',
|
||||||
'(Press Esc to close)': '(Нажмите Esc для закрытия)',
|
'(Press Esc to close)': '(Нажмите Esc для закрытия)',
|
||||||
'Current (effective) configuration': 'Текущая (фактическая) конфигурация',
|
|
||||||
AuthType: 'Тип авторизации',
|
|
||||||
'API Key': 'API-ключ',
|
|
||||||
unset: 'не задано',
|
|
||||||
'(default)': '(по умолчанию)',
|
|
||||||
'(set)': '(установлено)',
|
|
||||||
'(not set)': '(не задано)',
|
|
||||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
|
||||||
"Не удалось переключиться на модель '{{modelId}}'.\n\n{{error}}",
|
|
||||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||||
'Последняя модель Qwen Coder от Alibaba Cloud ModelStudio (версия: qwen3-coder-plus-2025-09-23)',
|
'Последняя модель Qwen Coder от Alibaba Cloud ModelStudio (версия: qwen3-coder-plus-2025-09-23)',
|
||||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||||
@@ -921,7 +884,6 @@ export default {
|
|||||||
// Экран выхода / Статистика
|
// Экран выхода / Статистика
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
'Agent powering down. Goodbye!': 'Агент завершает работу. До свидания!',
|
'Agent powering down. Goodbye!': 'Агент завершает работу. До свидания!',
|
||||||
'To continue this session, run': 'Для продолжения этой сессии, выполните',
|
|
||||||
'Interaction Summary': 'Сводка взаимодействия',
|
'Interaction Summary': 'Сводка взаимодействия',
|
||||||
'Session ID:': 'ID сессии:',
|
'Session ID:': 'ID сессии:',
|
||||||
'Tool Calls:': 'Вызовы инструментов:',
|
'Tool Calls:': 'Вызовы инструментов:',
|
||||||
@@ -981,139 +943,179 @@ export default {
|
|||||||
'Waiting for user confirmation...':
|
'Waiting for user confirmation...':
|
||||||
'Ожидание подтверждения от пользователя...',
|
'Ожидание подтверждения от пользователя...',
|
||||||
'(esc to cancel, {{time}})': '(esc для отмены, {{time}})',
|
'(esc to cancel, {{time}})': '(esc для отмены, {{time}})',
|
||||||
|
"I'm Feeling Lucky": 'Мне повезёт!',
|
||||||
// ============================================================================
|
'Shipping awesomeness... ': 'Доставляем крутизну... ',
|
||||||
|
'Painting the serifs back on...': 'Рисуем засечки на буквах...',
|
||||||
// ============================================================================
|
'Navigating the slime mold...': 'Пробираемся через слизевиков..',
|
||||||
// Loading Phrases
|
'Consulting the digital spirits...': 'Советуемся с цифровыми духами...',
|
||||||
// ============================================================================
|
'Reticulating splines...': 'Сглаживание сплайнов...',
|
||||||
WITTY_LOADING_PHRASES: [
|
'Warming up the AI hamsters...': 'Разогреваем ИИ-хомячков...',
|
||||||
'Мне повезёт!',
|
'Asking the magic conch shell...': 'Спрашиваем волшебную ракушку...',
|
||||||
'Доставляем крутизну... ',
|
'Generating witty retort...': 'Генерируем остроумный ответ...',
|
||||||
'Рисуем засечки на буквах...',
|
'Polishing the algorithms...': 'Полируем алгоритмы...',
|
||||||
'Пробираемся через слизевиков..',
|
"Don't rush perfection (or my code)...":
|
||||||
'Советуемся с цифровыми духами...',
|
|
||||||
'Сглаживание сплайнов...',
|
|
||||||
'Разогреваем ИИ-хомячков...',
|
|
||||||
'Спрашиваем волшебную ракушку...',
|
|
||||||
'Генерируем остроумный ответ...',
|
|
||||||
'Полируем алгоритмы...',
|
|
||||||
'Не торопите совершенство (или мой код)...',
|
'Не торопите совершенство (или мой код)...',
|
||||||
'Завариваем свежие байты...',
|
'Brewing fresh bytes...': 'Завариваем свежие байты...',
|
||||||
'Пересчитываем электроны...',
|
'Counting electrons...': 'Пересчитываем электроны...',
|
||||||
'Задействуем когнитивные процессоры...',
|
'Engaging cognitive processors...': 'Задействуем когнитивные процессоры...',
|
||||||
|
'Checking for syntax errors in the universe...':
|
||||||
'Ищем синтаксические ошибки во вселенной...',
|
'Ищем синтаксические ошибки во вселенной...',
|
||||||
'Секундочку, оптимизируем юмор...',
|
'One moment, optimizing humor...': 'Секундочку, оптимизируем юмор...',
|
||||||
'Перетасовываем панчлайны...',
|
'Shuffling punchlines...': 'Перетасовываем панчлайны...',
|
||||||
'Распутаваем нейросети...',
|
'Untangling neural nets...': 'Распутаваем нейросети...',
|
||||||
'Компилируем гениальность...',
|
'Compiling brilliance...': 'Компилируем гениальность...',
|
||||||
'Загружаем yumor.exe...',
|
'Loading wit.exe...': 'Загружаем yumor.exe...',
|
||||||
'Призываем облако мудрости...',
|
'Summoning the cloud of wisdom...': 'Призываем облако мудрости...',
|
||||||
'Готовим остроумный ответ...',
|
'Preparing a witty response...': 'Готовим остроумный ответ...',
|
||||||
'Секунду, идёт отладка реальности...',
|
"Just a sec, I'm debugging reality...": 'Секунду, идёт отладка реальности...',
|
||||||
'Запутываем варианты...',
|
'Confuzzling the options...': 'Запутываем варианты...',
|
||||||
'Настраиваем космические частоты...',
|
'Tuning the cosmic frequencies...': 'Настраиваем космические частоты...',
|
||||||
|
'Crafting a response worthy of your patience...':
|
||||||
'Создаем ответ, достойный вашего терпения...',
|
'Создаем ответ, достойный вашего терпения...',
|
||||||
'Компилируем единички и нолики...',
|
'Compiling the 1s and 0s...': 'Компилируем единички и нолики...',
|
||||||
|
'Resolving dependencies... and existential crises...':
|
||||||
'Разрешаем зависимости... и экзистенциальные кризисы...',
|
'Разрешаем зависимости... и экзистенциальные кризисы...',
|
||||||
|
'Defragmenting memories... both RAM and personal...':
|
||||||
'Дефрагментация памяти... и оперативной, и личной...',
|
'Дефрагментация памяти... и оперативной, и личной...',
|
||||||
'Перезагрузка модуля юмора...',
|
'Rebooting the humor module...': 'Перезагрузка модуля юмора...',
|
||||||
|
'Caching the essentials (mostly cat memes)...':
|
||||||
'Кэшируем самое важное (в основном мемы с котиками)...',
|
'Кэшируем самое важное (в основном мемы с котиками)...',
|
||||||
'Оптимизация для безумной скорости',
|
'Optimizing for ludicrous speed': 'Оптимизация для безумной скорости',
|
||||||
|
"Swapping bits... don't tell the bytes...":
|
||||||
'Меняем биты... только байтам не говорите...',
|
'Меняем биты... только байтам не говорите...',
|
||||||
'Сборка мусора... скоро вернусь...',
|
'Garbage collecting... be right back...': 'Сборка мусора... скоро вернусь...',
|
||||||
'Сборка интернетов...',
|
'Assembling the interwebs...': 'Сборка интернетов...',
|
||||||
'Превращаем кофе в код...',
|
'Converting coffee into code...': 'Превращаем кофе в код...',
|
||||||
'Обновляем синтаксис реальности...',
|
'Updating the syntax for reality...': 'Обновляем синтаксис реальности...',
|
||||||
'Переподключаем синапсы...',
|
'Rewiring the synapses...': 'Переподключаем синапсы...',
|
||||||
'Ищем лишнюю точку с запятой...',
|
'Looking for a misplaced semicolon...': 'Ищем лишнюю точку с запятой...',
|
||||||
'Смазываем шестерёнки машины...',
|
"Greasin' the cogs of the machine...": 'Смазываем шестерёнки машины...',
|
||||||
'Разогреваем серверы...',
|
'Pre-heating the servers...': 'Разогреваем серверы...',
|
||||||
'Калибруем потоковый накопитель...',
|
'Calibrating the flux capacitor...': 'Калибруем потоковый накопитель...',
|
||||||
'Включаем двигатель невероятности...',
|
'Engaging the improbability drive...': 'Включаем двигатель невероятности...',
|
||||||
'Направляем Силу...',
|
'Channeling the Force...': 'Направляем Силу...',
|
||||||
|
'Aligning the stars for optimal response...':
|
||||||
'Выравниваем звёзды для оптимального ответа...',
|
'Выравниваем звёзды для оптимального ответа...',
|
||||||
'Так скажем мы все...',
|
'So say we all...': 'Так скажем мы все...',
|
||||||
'Загрузка следующей великой идеи...',
|
'Loading the next great idea...': 'Загрузка следующей великой идеи...',
|
||||||
'Минутку, я в потоке...',
|
"Just a moment, I'm in the zone...": 'Минутку, я в потоке...',
|
||||||
|
'Preparing to dazzle you with brilliance...':
|
||||||
'Готовлюсь ослепить вас гениальностью...',
|
'Готовлюсь ослепить вас гениальностью...',
|
||||||
'Секунду, полирую остроумие...',
|
"Just a tick, I'm polishing my wit...": 'Секунду, полирую остроумие...',
|
||||||
'Держитесь, создаю шедевр...',
|
"Hold tight, I'm crafting a masterpiece...": 'Держитесь, создаю шедевр...',
|
||||||
|
"Just a jiffy, I'm debugging the universe...":
|
||||||
'Мигом, отлаживаю вселенную...',
|
'Мигом, отлаживаю вселенную...',
|
||||||
'Момент, выравниваю пиксели...',
|
"Just a moment, I'm aligning the pixels...": 'Момент, выравниваю пиксели...',
|
||||||
'Секунду, оптимизирую юмор...',
|
"Just a sec, I'm optimizing the humor...": 'Секунду, оптимизирую юмор...',
|
||||||
|
"Just a moment, I'm tuning the algorithms...":
|
||||||
'Момент, настраиваю алгоритмы...',
|
'Момент, настраиваю алгоритмы...',
|
||||||
'Варп-прыжок активирован...',
|
'Warp speed engaged...': 'Варп-скорость включена...',
|
||||||
'Добываем кристаллы дилития...',
|
'Mining for more Dilithium crystals...': 'Добываем кристаллы дилития...',
|
||||||
'Без паники...',
|
"Don't panic...": 'Без паники...',
|
||||||
'Следуем за белым кроликом...',
|
'Following the white rabbit...': 'Следуем за белым кроликом...',
|
||||||
'Истина где-то здесь... внутри...',
|
'The truth is in here... somewhere...': 'Истина где-то здесь... внутри...',
|
||||||
'Продуваем картридж...',
|
'Blowing on the cartridge...': 'Продуваем картридж...',
|
||||||
'Загрузка... Сделай бочку!',
|
'Loading... Do a barrel roll!': 'Загрузка... Сделай бочку!',
|
||||||
'Ждем респауна...',
|
'Waiting for the respawn...': 'Ждем респауна...',
|
||||||
|
'Finishing the Kessel Run in less than 12 parsecs...':
|
||||||
'Делаем Дугу Кесселя менее чем за 12 парсеков...',
|
'Делаем Дугу Кесселя менее чем за 12 парсеков...',
|
||||||
|
"The cake is not a lie, it's just still loading...":
|
||||||
'Тортик — не ложь, он просто ещё грузится...',
|
'Тортик — не ложь, он просто ещё грузится...',
|
||||||
|
'Fiddling with the character creation screen...':
|
||||||
'Возимся с экраном создания персонажа...',
|
'Возимся с экраном создания персонажа...',
|
||||||
|
"Just a moment, I'm finding the right meme...":
|
||||||
'Минутку, ищу подходящий мем...',
|
'Минутку, ищу подходящий мем...',
|
||||||
"Нажимаем 'A' для продолжения...",
|
"Pressing 'A' to continue...": "Нажимаем 'A' для продолжения...",
|
||||||
'Пасём цифровых котов...',
|
'Herding digital cats...': 'Пасём цифровых котов...',
|
||||||
'Полируем пиксели...',
|
'Polishing the pixels...': 'Полируем пиксели...',
|
||||||
|
'Finding a suitable loading screen pun...':
|
||||||
'Ищем подходящий каламбур для экрана загрузки...',
|
'Ищем подходящий каламбур для экрана загрузки...',
|
||||||
|
'Distracting you with this witty phrase...':
|
||||||
'Отвлекаем вас этой остроумной фразой...',
|
'Отвлекаем вас этой остроумной фразой...',
|
||||||
'Почти готово... вроде...',
|
'Almost there... probably...': 'Почти готово... вроде...',
|
||||||
|
'Our hamsters are working as fast as they can...':
|
||||||
'Наши хомячки работают изо всех сил...',
|
'Наши хомячки работают изо всех сил...',
|
||||||
'Гладим Облачко по голове...',
|
'Giving Cloudy a pat on the head...': 'Гладим Облачко по голове...',
|
||||||
'Гладим кота...',
|
'Petting the cat...': 'Гладим кота...',
|
||||||
'Рикроллим начальника...',
|
'Rickrolling my boss...': 'Рикроллим начальника...',
|
||||||
|
'Never gonna give you up, never gonna let you down...':
|
||||||
'Never gonna give you up, never gonna let you down...',
|
'Never gonna give you up, never gonna let you down...',
|
||||||
'Лабаем бас-гитару...',
|
'Slapping the bass...': 'Лабаем бас-гитару...',
|
||||||
'Пробуем снузберри на вкус...',
|
'Tasting the snozberries...': 'Пробуем снузберри на вкус...',
|
||||||
|
"I'm going the distance, I'm going for speed...":
|
||||||
'Иду до конца, иду на скорость...',
|
'Иду до конца, иду на скорость...',
|
||||||
|
'Is this the real life? Is this just fantasy?...':
|
||||||
'Is this the real life? Is this just fantasy?...',
|
'Is this the real life? Is this just fantasy?...',
|
||||||
'У меня хорошее предчувствие...',
|
"I've got a good feeling about this...": 'У меня хорошее предчувствие...',
|
||||||
'Дразним медведя... (Не лезь...)',
|
'Poking the bear...': 'Дразним медведя... (Не лезь...)',
|
||||||
'Изучаем свежие мемы...',
|
'Doing research on the latest memes...': 'Изучаем свежие мемы...',
|
||||||
|
'Figuring out how to make this more witty...':
|
||||||
'Думаем, как сделать это остроумнее...',
|
'Думаем, как сделать это остроумнее...',
|
||||||
'Хмм... дайте подумать...',
|
'Hmmm... let me think...': 'Хмм... дайте подумать...',
|
||||||
|
'What do you call a fish with no eyes? A fsh...':
|
||||||
'Как называется бумеранг, который не возвращается? Палка...',
|
'Как называется бумеранг, который не возвращается? Палка...',
|
||||||
|
'Why did the computer go to therapy? It had too many bytes...':
|
||||||
'Почему компьютер простудился? Потому что оставил окна открытыми...',
|
'Почему компьютер простудился? Потому что оставил окна открытыми...',
|
||||||
|
"Why don't programmers like nature? It has too many bugs...":
|
||||||
'Почему программисты не любят гулять на улице? Там среда не настроена...',
|
'Почему программисты не любят гулять на улице? Там среда не настроена...',
|
||||||
|
'Why do programmers prefer dark mode? Because light attracts bugs...':
|
||||||
'Почему программисты предпочитают тёмную тему? Потому что в темноте не видно багов...',
|
'Почему программисты предпочитают тёмную тему? Потому что в темноте не видно багов...',
|
||||||
|
'Why did the developer go broke? Because they used up all their cache...':
|
||||||
'Почему разработчик разорился? Потому что потратил весь свой кэш...',
|
'Почему разработчик разорился? Потому что потратил весь свой кэш...',
|
||||||
|
"What can you do with a broken pencil? Nothing, it's pointless...":
|
||||||
'Что можно делать со сломанным карандашом? Ничего — он тупой...',
|
'Что можно делать со сломанным карандашом? Ничего — он тупой...',
|
||||||
'Провожу настройку методом тыка...',
|
'Applying percussive maintenance...': 'Провожу настройку методом тыка...',
|
||||||
|
'Searching for the correct USB orientation...':
|
||||||
'Ищем, какой стороной вставлять флешку...',
|
'Ищем, какой стороной вставлять флешку...',
|
||||||
|
'Ensuring the magic smoke stays inside the wires...':
|
||||||
'Следим, чтобы волшебный дым не вышел из проводов...',
|
'Следим, чтобы волшебный дым не вышел из проводов...',
|
||||||
'Пытаемся выйти из Vim...',
|
'Rewriting in Rust for no particular reason...':
|
||||||
'Раскручиваем колесо для хомяка...',
|
'Переписываем всё на Rust без особой причины...',
|
||||||
'Это не баг, а фича...',
|
'Trying to exit Vim...': 'Пытаемся выйти из Vim...',
|
||||||
'Поехали!',
|
'Spinning up the hamster wheel...': 'Раскручиваем колесо для хомяка...',
|
||||||
'Я вернусь... с ответом.',
|
"That's not a bug, it's an undocumented feature...": 'Это не баг, а фича...',
|
||||||
'Мой другой процесс — это ТАРДИС...',
|
'Engage.': 'Поехали!',
|
||||||
'Общаемся с духом машины...',
|
"I'll be back... with an answer.": 'Я вернусь... с ответом.',
|
||||||
'Даем мыслям замариноваться...',
|
'My other process is a TARDIS...': 'Мой другой процесс — это ТАРДИС...',
|
||||||
|
'Communing with the machine spirit...': 'Общаемся с духом машины...',
|
||||||
|
'Letting the thoughts marinate...': 'Даем мыслям замариноваться...',
|
||||||
|
'Just remembered where I put my keys...':
|
||||||
'Только что вспомнил, куда положил ключи...',
|
'Только что вспомнил, куда положил ключи...',
|
||||||
'Размышляю над сферой...',
|
'Pondering the orb...': 'Размышляю над сферой...',
|
||||||
'Я видел такое, что вам, людям, и не снилось... пользователя, читающего эти сообщения.',
|
"I've seen things you people wouldn't believe... like a user who reads loading messages.":
|
||||||
'Инициируем задумчивый взгляд...',
|
'Я видел такое, во что вы, люди, просто не поверите... например, пользователя, читающего сообщения загрузки.',
|
||||||
|
'Initiating thoughtful gaze...': 'Инициируем задумчивый взгляд...',
|
||||||
|
"What's a computer's favorite snack? Microchips.":
|
||||||
'Что сервер заказывает в баре? Пинг-коладу.',
|
'Что сервер заказывает в баре? Пинг-коладу.',
|
||||||
|
"Why do Java developers wear glasses? Because they don't C#.":
|
||||||
'Почему Java-разработчики не убираются дома? Они ждут сборщик мусора...',
|
'Почему Java-разработчики не убираются дома? Они ждут сборщик мусора...',
|
||||||
'Заряжаем лазер... пиу-пиу!',
|
'Charging the laser... pew pew!': 'Заряжаем лазер... пиу-пиу!',
|
||||||
'Делим на ноль... шучу!',
|
'Dividing by zero... just kidding!': 'Делим на ноль... шучу!',
|
||||||
|
'Looking for an adult superviso... I mean, processing.':
|
||||||
'Ищу взрослых для присмот... в смысле, обрабатываю.',
|
'Ищу взрослых для присмот... в смысле, обрабатываю.',
|
||||||
'Делаем бип-буп.',
|
'Making it go beep boop.': 'Делаем бип-буп.',
|
||||||
'Буферизация... даже ИИ нужно время подумать.',
|
'Buffering... because even AIs need a moment.':
|
||||||
|
'Буферизация... даже ИИ нужно мгновение.',
|
||||||
|
'Entangling quantum particles for a faster response...':
|
||||||
'Запутываем квантовые частицы для быстрого ответа...',
|
'Запутываем квантовые частицы для быстрого ответа...',
|
||||||
|
'Polishing the chrome... on the algorithms.':
|
||||||
'Полируем хром... на алгоритмах.',
|
'Полируем хром... на алгоритмах.',
|
||||||
|
'Are you not entertained? (Working on it!)':
|
||||||
'Вы ещё не развлеклись?! Разве вы не за этим сюда пришли?!',
|
'Вы ещё не развлеклись?! Разве вы не за этим сюда пришли?!',
|
||||||
|
'Summoning the code gremlins... to help, of course.':
|
||||||
'Призываем гремлинов кода... для помощи, конечно же.',
|
'Призываем гремлинов кода... для помощи, конечно же.',
|
||||||
|
'Just waiting for the dial-up tone to finish...':
|
||||||
'Ждем, пока закончится звук dial-up модема...',
|
'Ждем, пока закончится звук dial-up модема...',
|
||||||
'Перекалибровка юморометра.',
|
'Recalibrating the humor-o-meter.': 'Перекалибровка юморометра.',
|
||||||
|
'My other loading screen is even funnier.':
|
||||||
'Мой другой экран загрузки ещё смешнее.',
|
'Мой другой экран загрузки ещё смешнее.',
|
||||||
|
"Pretty sure there's a cat walking on the keyboard somewhere...":
|
||||||
'Кажется, где-то по клавиатуре гуляет кот...',
|
'Кажется, где-то по клавиатуре гуляет кот...',
|
||||||
|
'Enhancing... Enhancing... Still loading.':
|
||||||
'Улучшаем... Ещё улучшаем... Всё ещё грузится.',
|
'Улучшаем... Ещё улучшаем... Всё ещё грузится.',
|
||||||
|
"It's not a bug, it's a feature... of this loading screen.":
|
||||||
'Это не баг, это фича... экрана загрузки.',
|
'Это не баг, это фича... экрана загрузки.',
|
||||||
|
'Have you tried turning it off and on again? (The loading screen, not me.)':
|
||||||
'Пробовали выключить и включить снова? (Экран загрузки, не меня!)',
|
'Пробовали выключить и включить снова? (Экран загрузки, не меня!)',
|
||||||
'Нужно построить больше пилонов...',
|
'Constructing additional pylons...': 'Нужно построить больше пилонов...',
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -88,9 +88,6 @@ 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}}':
|
|
||||||
'无效的审批模式 "{{arg}}"。有效模式:{{modes}}',
|
|
||||||
'Approval mode set to "{{mode}}"': '审批模式已设置为 "{{mode}}"',
|
|
||||||
'View or change the language setting': '查看或更改语言设置',
|
'View or change the language setting': '查看或更改语言设置',
|
||||||
'change the theme': '更改主题',
|
'change the theme': '更改主题',
|
||||||
'Select Theme': '选择主题',
|
'Select Theme': '选择主题',
|
||||||
@@ -104,7 +101,7 @@ export default {
|
|||||||
'Theme "{{themeName}}" not found.': '未找到主题 "{{themeName}}"。',
|
'Theme "{{themeName}}" not found.': '未找到主题 "{{themeName}}"。',
|
||||||
'Theme "{{themeName}}" not found in selected scope.':
|
'Theme "{{themeName}}" not found in selected scope.':
|
||||||
'在所选作用域中未找到主题 "{{themeName}}"。',
|
'在所选作用域中未找到主题 "{{themeName}}"。',
|
||||||
'Clear conversation history and free up context': '清除对话历史并释放上下文',
|
'clear the screen and conversation history': '清屏并清除对话历史',
|
||||||
'Compresses the context by replacing it with a summary.':
|
'Compresses the context by replacing it with a summary.':
|
||||||
'通过用摘要替换来压缩上下文',
|
'通过用摘要替换来压缩上下文',
|
||||||
'open full Qwen Code documentation in your browser':
|
'open full Qwen Code documentation in your browser':
|
||||||
@@ -252,8 +249,6 @@ export default {
|
|||||||
', Tab to change focus': ',Tab 切换焦点',
|
', Tab to change focus': ',Tab 切换焦点',
|
||||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||||
'要查看更改,必须重启 Qwen Code。按 r 退出并立即应用更改。',
|
'要查看更改,必须重启 Qwen Code。按 r 退出并立即应用更改。',
|
||||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
|
||||||
'不支持在非交互模式下使用命令 "/{{command}}"。',
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Settings Labels
|
// Settings Labels
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -565,12 +560,6 @@ export default {
|
|||||||
'No conversation found to summarize.': '未找到要总结的对话',
|
'No conversation found to summarize.': '未找到要总结的对话',
|
||||||
'Failed to generate project context summary: {{error}}':
|
'Failed to generate project context summary: {{error}}':
|
||||||
'生成项目上下文摘要失败:{{error}}',
|
'生成项目上下文摘要失败:{{error}}',
|
||||||
'Saved project summary to {{filePathForDisplay}}.':
|
|
||||||
'项目摘要已保存到 {{filePathForDisplay}}',
|
|
||||||
'Saving project summary...': '正在保存项目摘要...',
|
|
||||||
'Generating project summary...': '正在生成项目摘要...',
|
|
||||||
'Failed to generate summary - no text content received from LLM response':
|
|
||||||
'生成摘要失败 - 未从 LLM 响应中接收到文本内容',
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Commands - Model
|
// Commands - Model
|
||||||
@@ -584,9 +573,8 @@ export default {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Commands - Clear
|
// Commands - Clear
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
'Starting a new session, resetting chat, and clearing terminal.':
|
'Clearing terminal and resetting chat.': '正在清屏并重置聊天',
|
||||||
'正在开始新会话,重置聊天并清屏。',
|
'Clearing terminal.': '正在清屏',
|
||||||
'Starting a new session and clearing.': '正在开始新会话并清屏。',
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Commands - Compress
|
// Commands - Compress
|
||||||
@@ -728,21 +716,6 @@ export default {
|
|||||||
'Authentication timed out. Please try again.': '认证超时。请重试。',
|
'Authentication timed out. Please try again.': '认证超时。请重试。',
|
||||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||||
'正在等待认证...(按 ESC 或 CTRL+C 取消)',
|
'正在等待认证...(按 ESC 或 CTRL+C 取消)',
|
||||||
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.':
|
|
||||||
'缺少 OpenAI 兼容认证的 API 密钥。请设置 settings.security.auth.apiKey 或设置 {{envKeyHint}} 环境变量。',
|
|
||||||
'{{envKeyHint}} environment variable not found.':
|
|
||||||
'未找到 {{envKeyHint}} 环境变量。',
|
|
||||||
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.':
|
|
||||||
'未找到 {{envKeyHint}} 环境变量。请在 .env 文件或系统环境变量中进行设置。',
|
|
||||||
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.':
|
|
||||||
'未找到 {{envKeyHint}} 环境变量(或设置 settings.security.auth.apiKey)。请在 .env 文件或系统环境变量中进行设置。',
|
|
||||||
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.':
|
|
||||||
'缺少 OpenAI 兼容认证的 API 密钥。请设置 {{envKeyHint}} 环境变量。',
|
|
||||||
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.':
|
|
||||||
'Anthropic 提供商缺少必需的 baseUrl,请在 modelProviders[].baseUrl 中配置。',
|
|
||||||
'ANTHROPIC_BASE_URL environment variable not found.':
|
|
||||||
'未找到 ANTHROPIC_BASE_URL 环境变量。',
|
|
||||||
'Invalid auth method selected.': '选择了无效的认证方式。',
|
|
||||||
'Failed to authenticate. Message: {{message}}': '认证失败。消息:{{message}}',
|
'Failed to authenticate. Message: {{message}}': '认证失败。消息:{{message}}',
|
||||||
'Authenticated successfully with {{authType}} credentials.':
|
'Authenticated successfully with {{authType}} credentials.':
|
||||||
'使用 {{authType}} 凭据成功认证。',
|
'使用 {{authType}} 凭据成功认证。',
|
||||||
@@ -762,15 +735,6 @@ export default {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
'Select Model': '选择模型',
|
'Select Model': '选择模型',
|
||||||
'(Press Esc to close)': '(按 Esc 关闭)',
|
'(Press Esc to close)': '(按 Esc 关闭)',
|
||||||
'Current (effective) configuration': '当前(实际生效)配置',
|
|
||||||
AuthType: '认证方式',
|
|
||||||
'API Key': 'API 密钥',
|
|
||||||
unset: '未设置',
|
|
||||||
'(default)': '(默认)',
|
|
||||||
'(set)': '(已设置)',
|
|
||||||
'(not set)': '(未设置)',
|
|
||||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
|
||||||
"无法切换到模型 '{{modelId}}'.\n\n{{error}}",
|
|
||||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||||
'来自阿里云 ModelStudio 的最新 Qwen Coder 模型(版本:qwen3-coder-plus-2025-09-23)',
|
'来自阿里云 ModelStudio 的最新 Qwen Coder 模型(版本:qwen3-coder-plus-2025-09-23)',
|
||||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||||
@@ -916,39 +880,165 @@ export default {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
'Waiting for user confirmation...': '等待用户确认...',
|
'Waiting for user confirmation...': '等待用户确认...',
|
||||||
'(esc to cancel, {{time}})': '(按 esc 取消,{{time}})',
|
'(esc to cancel, {{time}})': '(按 esc 取消,{{time}})',
|
||||||
WITTY_LOADING_PHRASES: [
|
"I'm Feeling Lucky": '我感觉很幸运',
|
||||||
// --- 职场搬砖系列 ---
|
'Shipping awesomeness... ': '正在运送精彩内容... ',
|
||||||
'正在努力搬砖,请稍候...',
|
'Painting the serifs back on...': '正在重新绘制衬线...',
|
||||||
'老板在身后,快加载啊!',
|
'Navigating the slime mold...': '正在导航粘液霉菌...',
|
||||||
'头发掉光前,一定能加载完...',
|
'Consulting the digital spirits...': '正在咨询数字精灵...',
|
||||||
'服务器正在深呼吸,准备放大招...',
|
'Reticulating splines...': '正在网格化样条曲线...',
|
||||||
'正在向服务器投喂咖啡...',
|
'Warming up the AI hamsters...': '正在预热 AI 仓鼠...',
|
||||||
|
'Asking the magic conch shell...': '正在询问魔法海螺壳...',
|
||||||
// --- 大厂黑话系列 ---
|
'Generating witty retort...': '正在生成机智的反驳...',
|
||||||
'正在赋能全链路,寻找关键抓手...',
|
'Polishing the algorithms...': '正在打磨算法...',
|
||||||
'正在降本增效,优化加载路径...',
|
"Don't rush perfection (or my code)...": '不要急于追求完美(或我的代码)...',
|
||||||
'正在打破部门壁垒,沉淀方法论...',
|
'Brewing fresh bytes...': '正在酿造新鲜字节...',
|
||||||
'正在拥抱变化,迭代核心价值...',
|
'Counting electrons...': '正在计算电子...',
|
||||||
'正在对齐颗粒度,打磨底层逻辑...',
|
'Engaging cognitive processors...': '正在启动认知处理器...',
|
||||||
'大力出奇迹,正在强行加载...',
|
'Checking for syntax errors in the universe...':
|
||||||
|
'正在检查宇宙中的语法错误...',
|
||||||
// --- 程序员自嘲系列 ---
|
'One moment, optimizing humor...': '稍等片刻,正在优化幽默感...',
|
||||||
'只要我不写代码,代码就没有 Bug...',
|
'Shuffling punchlines...': '正在洗牌笑点...',
|
||||||
'正在把 Bug 转化为 Feature...',
|
'Untangling neural nets...': '正在解开神经网络...',
|
||||||
'只要我不尴尬,Bug 就追不上我...',
|
'Compiling brilliance...': '正在编译智慧...',
|
||||||
'正在试图理解去年的自己写了什么...',
|
'Loading wit.exe...': '正在加载 wit.exe...',
|
||||||
'正在猿力觉醒中,请耐心等待...',
|
'Summoning the cloud of wisdom...': '正在召唤智慧云...',
|
||||||
|
'Preparing a witty response...': '正在准备机智的回复...',
|
||||||
// --- 合作愉快系列 ---
|
"Just a sec, I'm debugging reality...": '稍等片刻,我正在调试现实...',
|
||||||
'正在询问产品经理:这需求是真的吗?',
|
'Confuzzling the options...': '正在混淆选项...',
|
||||||
'正在给产品经理画饼,请稍等...',
|
'Tuning the cosmic frequencies...': '正在调谐宇宙频率...',
|
||||||
|
'Crafting a response worthy of your patience...':
|
||||||
// --- 温暖治愈系列 ---
|
'正在制作值得您耐心等待的回复...',
|
||||||
'每一行代码,都在努力让世界变得更好一点点...',
|
'Compiling the 1s and 0s...': '正在编译 1 和 0...',
|
||||||
'每一个伟大的想法,都值得这份耐心的等待...',
|
'Resolving dependencies... and existential crises...':
|
||||||
'别急,美好的事物总是需要一点时间去酝酿...',
|
'正在解决依赖关系...和存在主义危机...',
|
||||||
'愿你的代码永无 Bug,愿你的梦想终将成真...',
|
'Defragmenting memories... both RAM and personal...':
|
||||||
'哪怕只有 0.1% 的进度,也是在向目标靠近...',
|
'正在整理记忆碎片...包括 RAM 和个人记忆...',
|
||||||
'加载的是字节,承载的是对技术的热爱...',
|
'Rebooting the humor module...': '正在重启幽默模块...',
|
||||||
],
|
'Caching the essentials (mostly cat memes)...':
|
||||||
|
'正在缓存必需品(主要是猫咪表情包)...',
|
||||||
|
'Optimizing for ludicrous speed': '正在优化到荒谬的速度',
|
||||||
|
"Swapping bits... don't tell the bytes...": '正在交换位...不要告诉字节...',
|
||||||
|
'Garbage collecting... be right back...': '正在垃圾回收...马上回来...',
|
||||||
|
'Assembling the interwebs...': '正在组装互联网...',
|
||||||
|
'Converting coffee into code...': '正在将咖啡转换为代码...',
|
||||||
|
'Updating the syntax for reality...': '正在更新现实的语法...',
|
||||||
|
'Rewiring the synapses...': '正在重新连接突触...',
|
||||||
|
'Looking for a misplaced semicolon...': '正在寻找放错位置的分号...',
|
||||||
|
"Greasin' the cogs of the machine...": '正在给机器的齿轮上油...',
|
||||||
|
'Pre-heating the servers...': '正在预热服务器...',
|
||||||
|
'Calibrating the flux capacitor...': '正在校准通量电容器...',
|
||||||
|
'Engaging the improbability drive...': '正在启动不可能性驱动器...',
|
||||||
|
'Channeling the Force...': '正在引导原力...',
|
||||||
|
'Aligning the stars for optimal response...': '正在对齐星星以获得最佳回复...',
|
||||||
|
'So say we all...': '我们都说...',
|
||||||
|
'Loading the next great idea...': '正在加载下一个伟大的想法...',
|
||||||
|
"Just a moment, I'm in the zone...": '稍等片刻,我正进入状态...',
|
||||||
|
'Preparing to dazzle you with brilliance...': '正在准备用智慧让您眼花缭乱...',
|
||||||
|
"Just a tick, I'm polishing my wit...": '稍等片刻,我正在打磨我的智慧...',
|
||||||
|
"Hold tight, I'm crafting a masterpiece...": '请稍等,我正在制作杰作...',
|
||||||
|
"Just a jiffy, I'm debugging the universe...": '稍等片刻,我正在调试宇宙...',
|
||||||
|
"Just a moment, I'm aligning the pixels...": '稍等片刻,我正在对齐像素...',
|
||||||
|
"Just a sec, I'm optimizing the humor...": '稍等片刻,我正在优化幽默感...',
|
||||||
|
"Just a moment, I'm tuning the algorithms...": '稍等片刻,我正在调整算法...',
|
||||||
|
'Warp speed engaged...': '曲速已启动...',
|
||||||
|
'Mining for more Dilithium crystals...': '正在挖掘更多二锂晶体...',
|
||||||
|
"Don't panic...": '不要惊慌...',
|
||||||
|
'Following the white rabbit...': '正在跟随白兔...',
|
||||||
|
'The truth is in here... somewhere...': '真相在这里...某个地方...',
|
||||||
|
'Blowing on the cartridge...': '正在吹卡带...',
|
||||||
|
'Loading... Do a barrel roll!': '正在加载...做个桶滚!',
|
||||||
|
'Waiting for the respawn...': '等待重生...',
|
||||||
|
'Finishing the Kessel Run in less than 12 parsecs...':
|
||||||
|
'正在以不到 12 秒差距完成凯塞尔航线...',
|
||||||
|
"The cake is not a lie, it's just still loading...":
|
||||||
|
'蛋糕不是谎言,只是还在加载...',
|
||||||
|
'Fiddling with the character creation screen...': '正在摆弄角色创建界面...',
|
||||||
|
"Just a moment, I'm finding the right meme...":
|
||||||
|
'稍等片刻,我正在寻找合适的表情包...',
|
||||||
|
"Pressing 'A' to continue...": "按 'A' 继续...",
|
||||||
|
'Herding digital cats...': '正在放牧数字猫...',
|
||||||
|
'Polishing the pixels...': '正在打磨像素...',
|
||||||
|
'Finding a suitable loading screen pun...': '正在寻找合适的加载屏幕双关语...',
|
||||||
|
'Distracting you with this witty phrase...':
|
||||||
|
'正在用这个机智的短语分散您的注意力...',
|
||||||
|
'Almost there... probably...': '快到了...可能...',
|
||||||
|
'Our hamsters are working as fast as they can...':
|
||||||
|
'我们的仓鼠正在尽可能快地工作...',
|
||||||
|
'Giving Cloudy a pat on the head...': '正在拍拍 Cloudy 的头...',
|
||||||
|
'Petting the cat...': '正在抚摸猫咪...',
|
||||||
|
'Rickrolling my boss...': '正在 Rickroll 我的老板...',
|
||||||
|
'Never gonna give you up, never gonna let you down...':
|
||||||
|
'永远不会放弃你,永远不会让你失望...',
|
||||||
|
'Slapping the bass...': '正在拍打低音...',
|
||||||
|
'Tasting the snozberries...': '正在品尝 snozberries...',
|
||||||
|
"I'm going the distance, I'm going for speed...":
|
||||||
|
'我要走得更远,我要追求速度...',
|
||||||
|
'Is this the real life? Is this just fantasy?...':
|
||||||
|
'这是真实的生活吗?还是只是幻想?...',
|
||||||
|
"I've got a good feeling about this...": '我对这个感觉很好...',
|
||||||
|
'Poking the bear...': '正在戳熊...',
|
||||||
|
'Doing research on the latest memes...': '正在研究最新的表情包...',
|
||||||
|
'Figuring out how to make this more witty...': '正在想办法让这更有趣...',
|
||||||
|
'Hmmm... let me think...': '嗯...让我想想...',
|
||||||
|
'What do you call a fish with no eyes? A fsh...':
|
||||||
|
'没有眼睛的鱼叫什么?一条鱼...',
|
||||||
|
'Why did the computer go to therapy? It had too many bytes...':
|
||||||
|
'为什么电脑去看心理医生?因为它有太多字节...',
|
||||||
|
"Why don't programmers like nature? It has too many bugs...":
|
||||||
|
'为什么程序员不喜欢大自然?因为虫子太多了...',
|
||||||
|
'Why do programmers prefer dark mode? Because light attracts bugs...':
|
||||||
|
'为什么程序员喜欢暗色模式?因为光会吸引虫子...',
|
||||||
|
'Why did the developer go broke? Because they used up all their cache...':
|
||||||
|
'为什么开发者破产了?因为他们用完了所有缓存...',
|
||||||
|
"What can you do with a broken pencil? Nothing, it's pointless...":
|
||||||
|
'你能用断了的铅笔做什么?什么都不能,因为它没有笔尖...',
|
||||||
|
'Applying percussive maintenance...': '正在应用敲击维护...',
|
||||||
|
'Searching for the correct USB orientation...': '正在寻找正确的 USB 方向...',
|
||||||
|
'Ensuring the magic smoke stays inside the wires...':
|
||||||
|
'确保魔法烟雾留在电线内...',
|
||||||
|
'Rewriting in Rust for no particular reason...':
|
||||||
|
'正在用 Rust 重写,没有特别的原因...',
|
||||||
|
'Trying to exit Vim...': '正在尝试退出 Vim...',
|
||||||
|
'Spinning up the hamster wheel...': '正在启动仓鼠轮...',
|
||||||
|
"That's not a bug, it's an undocumented feature...":
|
||||||
|
'这不是一个错误,这是一个未记录的功能...',
|
||||||
|
'Engage.': '启动。',
|
||||||
|
"I'll be back... with an answer.": '我会回来的...带着答案。',
|
||||||
|
'My other process is a TARDIS...': '我的另一个进程是 TARDIS...',
|
||||||
|
'Communing with the machine spirit...': '正在与机器精神交流...',
|
||||||
|
'Letting the thoughts marinate...': '让想法慢慢酝酿...',
|
||||||
|
'Just remembered where I put my keys...': '刚刚想起我把钥匙放在哪里了...',
|
||||||
|
'Pondering the orb...': '正在思考球体...',
|
||||||
|
"I've seen things you people wouldn't believe... like a user who reads loading messages.":
|
||||||
|
'我见过你们不会相信的事情...比如一个阅读加载消息的用户。',
|
||||||
|
'Initiating thoughtful gaze...': '正在启动深思凝视...',
|
||||||
|
"What's a computer's favorite snack? Microchips.":
|
||||||
|
'电脑最喜欢的零食是什么?微芯片。',
|
||||||
|
"Why do Java developers wear glasses? Because they don't C#.":
|
||||||
|
'为什么 Java 开发者戴眼镜?因为他们不会 C#。',
|
||||||
|
'Charging the laser... pew pew!': '正在给激光充电...砰砰!',
|
||||||
|
'Dividing by zero... just kidding!': '除以零...只是开玩笑!',
|
||||||
|
'Looking for an adult superviso... I mean, processing.':
|
||||||
|
'正在寻找成人监督...我是说,处理中。',
|
||||||
|
'Making it go beep boop.': '让它发出哔哔声。',
|
||||||
|
'Buffering... because even AIs need a moment.':
|
||||||
|
'正在缓冲...因为即使是 AI 也需要片刻。',
|
||||||
|
'Entangling quantum particles for a faster response...':
|
||||||
|
'正在纠缠量子粒子以获得更快的回复...',
|
||||||
|
'Polishing the chrome... on the algorithms.': '正在打磨铬...在算法上。',
|
||||||
|
'Are you not entertained? (Working on it!)': '你不觉得有趣吗?(正在努力!)',
|
||||||
|
'Summoning the code gremlins... to help, of course.':
|
||||||
|
'正在召唤代码小精灵...当然是来帮忙的。',
|
||||||
|
'Just waiting for the dial-up tone to finish...': '只是等待拨号音结束...',
|
||||||
|
'Recalibrating the humor-o-meter.': '正在重新校准幽默计。',
|
||||||
|
'My other loading screen is even funnier.': '我的另一个加载屏幕更有趣。',
|
||||||
|
"Pretty sure there's a cat walking on the keyboard somewhere...":
|
||||||
|
'很确定有只猫在某个地方键盘上走...',
|
||||||
|
'Enhancing... Enhancing... Still loading.':
|
||||||
|
'正在增强...正在增强...仍在加载。',
|
||||||
|
"It's not a bug, it's a feature... of this loading screen.":
|
||||||
|
'这不是一个错误,这是一个功能...这个加载屏幕的功能。',
|
||||||
|
'Have you tried turning it off and on again? (The loading screen, not me.)':
|
||||||
|
'你试过把它关掉再打开吗?(加载屏幕,不是我。)',
|
||||||
|
'Constructing additional pylons...': '正在建造额外的能量塔...',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import type {
|
|||||||
CLIControlSetModelRequest,
|
CLIControlSetModelRequest,
|
||||||
CLIMcpServerConfig,
|
CLIMcpServerConfig,
|
||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
import { getAvailableCommands } from '../../../nonInteractiveCliCommands.js';
|
import { CommandService } from '../../../services/CommandService.js';
|
||||||
|
import { BuiltinCommandLoader } from '../../../services/BuiltinCommandLoader.js';
|
||||||
import {
|
import {
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
AuthProviderType,
|
AuthProviderType,
|
||||||
@@ -406,7 +407,7 @@ export class SystemController extends BaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load slash command names using getAvailableCommands
|
* Load slash command names using CommandService
|
||||||
*
|
*
|
||||||
* @param signal - AbortSignal to respect for cancellation
|
* @param signal - AbortSignal to respect for cancellation
|
||||||
* @returns Promise resolving to array of slash command names
|
* @returns Promise resolving to array of slash command names
|
||||||
@@ -417,14 +418,21 @@ export class SystemController extends BaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const commands = await getAvailableCommands(this.context.config, signal);
|
const service = await CommandService.create(
|
||||||
|
[new BuiltinCommandLoader(this.context.config)],
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract command names and sort
|
const names = new Set<string>();
|
||||||
return commands.map((cmd) => cmd.name).sort();
|
const commands = service.getCommands();
|
||||||
|
for (const command of commands) {
|
||||||
|
names.add(command.name);
|
||||||
|
}
|
||||||
|
return Array.from(names).sort();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Check if the error is due to abort
|
// Check if the error is due to abort
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
|
|||||||
@@ -630,67 +630,6 @@ describe('BaseJsonOutputAdapter', () => {
|
|||||||
|
|
||||||
expect(state.blocks).toHaveLength(0);
|
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', () => {
|
describe('appendToolUse', () => {
|
||||||
|
|||||||
@@ -816,18 +816,9 @@ export abstract class BaseJsonOutputAdapter {
|
|||||||
parentToolUseId?: string | null,
|
parentToolUseId?: string | null,
|
||||||
): void {
|
): void {
|
||||||
const actualParentToolUseId = parentToolUseId ?? null;
|
const actualParentToolUseId = parentToolUseId ?? null;
|
||||||
|
const fragment = [subject?.trim(), description?.trim()]
|
||||||
// Build fragment without trimming to preserve whitespace in streaming content
|
.filter((value) => value && value.length > 0)
|
||||||
// Only filter out null/undefined/empty values
|
.join(': ');
|
||||||
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) {
|
if (!fragment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,68 +323,6 @@ 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', () => {
|
it('should append tool use from ToolCallRequest events', () => {
|
||||||
adapter.processEvent({
|
adapter.processEvent({
|
||||||
type: GeminiEventType.ToolCallRequest,
|
type: GeminiEventType.ToolCallRequest,
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ describe('runNonInteractive', () => {
|
|||||||
let mockShutdownTelemetry: Mock;
|
let mockShutdownTelemetry: Mock;
|
||||||
let consoleErrorSpy: MockInstance;
|
let consoleErrorSpy: MockInstance;
|
||||||
let processStdoutSpy: MockInstance;
|
let processStdoutSpy: MockInstance;
|
||||||
let processStderrSpy: MockInstance;
|
|
||||||
let mockGeminiClient: {
|
let mockGeminiClient: {
|
||||||
sendMessageStream: Mock;
|
sendMessageStream: Mock;
|
||||||
getChatRecordingService: Mock;
|
getChatRecordingService: Mock;
|
||||||
@@ -87,9 +86,6 @@ describe('runNonInteractive', () => {
|
|||||||
processStdoutSpy = vi
|
processStdoutSpy = vi
|
||||||
.spyOn(process.stdout, 'write')
|
.spyOn(process.stdout, 'write')
|
||||||
.mockImplementation(() => true);
|
.mockImplementation(() => true);
|
||||||
processStderrSpy = vi
|
|
||||||
.spyOn(process.stderr, 'write')
|
|
||||||
.mockImplementation(() => true);
|
|
||||||
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||||
throw new Error(`process.exit(${code}) called`);
|
throw new Error(`process.exit(${code}) called`);
|
||||||
});
|
});
|
||||||
@@ -143,8 +139,6 @@ describe('runNonInteractive', () => {
|
|||||||
setModel: vi.fn(async (model: string) => {
|
setModel: vi.fn(async (model: string) => {
|
||||||
currentModel = model;
|
currentModel = model;
|
||||||
}),
|
}),
|
||||||
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
|
|
||||||
isInteractive: vi.fn().mockReturnValue(false),
|
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
mockSettings = {
|
mockSettings = {
|
||||||
@@ -298,9 +292,7 @@ describe('runNonInteractive', () => {
|
|||||||
mockConfig,
|
mockConfig,
|
||||||
expect.objectContaining({ name: 'testTool' }),
|
expect.objectContaining({ name: 'testTool' }),
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
expect.objectContaining({
|
undefined,
|
||||||
outputUpdateHandler: expect.any(Function),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
// Verify first call has isContinuation: false
|
// Verify first call has isContinuation: false
|
||||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||||
@@ -773,52 +765,6 @@ 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 () => {
|
it('should handle FatalInputError with custom exit code in JSON format', async () => {
|
||||||
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON);
|
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON);
|
||||||
setupMetricsMock();
|
setupMetricsMock();
|
||||||
@@ -906,7 +852,7 @@ describe('runNonInteractive', () => {
|
|||||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
|
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle command that requires confirmation by returning early', async () => {
|
it('should throw FatalInputError if a command requires confirmation', async () => {
|
||||||
const mockCommand = {
|
const mockCommand = {
|
||||||
name: 'confirm',
|
name: 'confirm',
|
||||||
description: 'a command that needs confirmation',
|
description: 'a command that needs confirmation',
|
||||||
@@ -918,16 +864,15 @@ describe('runNonInteractive', () => {
|
|||||||
};
|
};
|
||||||
mockGetCommands.mockReturnValue([mockCommand]);
|
mockGetCommands.mockReturnValue([mockCommand]);
|
||||||
|
|
||||||
await runNonInteractive(
|
await expect(
|
||||||
mockConfig,
|
runNonInteractive(
|
||||||
mockSettings,
|
mockConfig,
|
||||||
'/confirm',
|
mockSettings,
|
||||||
'prompt-id-confirm',
|
'/confirm',
|
||||||
);
|
'prompt-id-confirm',
|
||||||
|
),
|
||||||
// Should write error message to stderr
|
).rejects.toThrow(
|
||||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
'Exiting due to a confirmation prompt requested by the command.',
|
||||||
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.\n',
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -964,30 +909,7 @@ describe('runNonInteractive', () => {
|
|||||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
|
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle known but unsupported slash commands like /help by returning early', async () => {
|
it('should throw for unhandled command result types', async () => {
|
||||||
// Mock a built-in command that exists but is not in the allowed list
|
|
||||||
const mockHelpCommand = {
|
|
||||||
name: 'help',
|
|
||||||
description: 'Show help',
|
|
||||||
kind: CommandKind.BUILT_IN,
|
|
||||||
action: vi.fn(),
|
|
||||||
};
|
|
||||||
mockGetCommands.mockReturnValue([mockHelpCommand]);
|
|
||||||
|
|
||||||
await runNonInteractive(
|
|
||||||
mockConfig,
|
|
||||||
mockSettings,
|
|
||||||
'/help',
|
|
||||||
'prompt-id-help',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should write error message to stderr
|
|
||||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
|
||||||
'The command "/help" is not supported in non-interactive mode.\n',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle unhandled command result types by returning early with error', async () => {
|
|
||||||
const mockCommand = {
|
const mockCommand = {
|
||||||
name: 'noaction',
|
name: 'noaction',
|
||||||
description: 'unhandled type',
|
description: 'unhandled type',
|
||||||
@@ -998,16 +920,15 @@ describe('runNonInteractive', () => {
|
|||||||
};
|
};
|
||||||
mockGetCommands.mockReturnValue([mockCommand]);
|
mockGetCommands.mockReturnValue([mockCommand]);
|
||||||
|
|
||||||
await runNonInteractive(
|
await expect(
|
||||||
mockConfig,
|
runNonInteractive(
|
||||||
mockSettings,
|
mockConfig,
|
||||||
'/noaction',
|
mockSettings,
|
||||||
'prompt-id-unhandled',
|
'/noaction',
|
||||||
);
|
'prompt-id-unhandled',
|
||||||
|
),
|
||||||
// Should write error message to stderr
|
).rejects.toThrow(
|
||||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
'Exiting due to command result that is not supported in non-interactive mode.',
|
||||||
'Unknown command result type: unhandled\n',
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1825,84 +1746,4 @@ describe('runNonInteractive', () => {
|
|||||||
{ isContinuation: false },
|
{ 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,11 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
|
||||||
Config,
|
|
||||||
ToolCallRequestInfo,
|
|
||||||
ToolResultDisplay,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||||
import type { LoadedSettings } from './config/settings.js';
|
import type { LoadedSettings } from './config/settings.js';
|
||||||
import {
|
import {
|
||||||
@@ -46,55 +42,6 @@ import {
|
|||||||
computeUsageFromMetrics,
|
computeUsageFromMetrics,
|
||||||
} from './utils/nonInteractiveHelpers.js';
|
} from './utils/nonInteractiveHelpers.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Emits a final message for slash command results.
|
|
||||||
* Note: systemMessage should already be emitted before calling this function.
|
|
||||||
*/
|
|
||||||
async function emitNonInteractiveFinalMessage(params: {
|
|
||||||
message: string;
|
|
||||||
isError: boolean;
|
|
||||||
adapter?: JsonOutputAdapterInterface;
|
|
||||||
config: Config;
|
|
||||||
startTimeMs: number;
|
|
||||||
}): Promise<void> {
|
|
||||||
const { message, isError, adapter, config } = params;
|
|
||||||
|
|
||||||
if (!adapter) {
|
|
||||||
// Text output mode: write directly to stdout/stderr
|
|
||||||
const target = isError ? process.stderr : process.stdout;
|
|
||||||
target.write(`${message}\n`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON output mode: emit assistant message and result
|
|
||||||
// (systemMessage should already be emitted by caller)
|
|
||||||
adapter.startAssistantMessage();
|
|
||||||
adapter.processEvent({
|
|
||||||
type: GeminiEventType.Content,
|
|
||||||
value: message,
|
|
||||||
} as unknown as Parameters<JsonOutputAdapterInterface['processEvent']>[0]);
|
|
||||||
adapter.finalizeAssistantMessage();
|
|
||||||
|
|
||||||
const metrics = uiTelemetryService.getMetrics();
|
|
||||||
const usage = computeUsageFromMetrics(metrics);
|
|
||||||
const outputFormat = config.getOutputFormat();
|
|
||||||
const stats =
|
|
||||||
outputFormat === OutputFormat.JSON
|
|
||||||
? uiTelemetryService.getMetrics()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
adapter.emitResult({
|
|
||||||
isError,
|
|
||||||
durationMs: Date.now() - params.startTimeMs,
|
|
||||||
apiDurationMs: 0,
|
|
||||||
numTurns: 0,
|
|
||||||
errorMessage: isError ? message : undefined,
|
|
||||||
usage,
|
|
||||||
stats,
|
|
||||||
summary: message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides optional overrides for `runNonInteractive` execution.
|
* Provides optional overrides for `runNonInteractive` execution.
|
||||||
*
|
*
|
||||||
@@ -168,16 +115,6 @@ export async function runNonInteractive(
|
|||||||
process.on('SIGINT', shutdownHandler);
|
process.on('SIGINT', shutdownHandler);
|
||||||
process.on('SIGTERM', shutdownHandler);
|
process.on('SIGTERM', shutdownHandler);
|
||||||
|
|
||||||
// Emit systemMessage first (always the first message in JSON mode)
|
|
||||||
if (adapter) {
|
|
||||||
const systemMessage = await buildSystemMessage(
|
|
||||||
config,
|
|
||||||
sessionId,
|
|
||||||
permissionMode,
|
|
||||||
);
|
|
||||||
adapter.emitMessage(systemMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
|
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
|
||||||
options.userMessage,
|
options.userMessage,
|
||||||
);
|
);
|
||||||
@@ -191,45 +128,10 @@ export async function runNonInteractive(
|
|||||||
config,
|
config,
|
||||||
settings,
|
settings,
|
||||||
);
|
);
|
||||||
switch (slashCommandResult.type) {
|
if (slashCommandResult) {
|
||||||
case 'submit_prompt':
|
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
|
||||||
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
|
initialPartList = slashCommandResult as PartListUnion;
|
||||||
initialPartList = slashCommandResult.content;
|
slashHandled = true;
|
||||||
slashHandled = true;
|
|
||||||
break;
|
|
||||||
case 'message': {
|
|
||||||
// systemMessage already emitted above
|
|
||||||
await emitNonInteractiveFinalMessage({
|
|
||||||
message: slashCommandResult.content,
|
|
||||||
isError: slashCommandResult.messageType === 'error',
|
|
||||||
adapter,
|
|
||||||
config,
|
|
||||||
startTimeMs: startTime,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case 'stream_messages':
|
|
||||||
throw new FatalInputError(
|
|
||||||
'Stream messages mode is not supported in non-interactive CLI',
|
|
||||||
);
|
|
||||||
case 'unsupported': {
|
|
||||||
await emitNonInteractiveFinalMessage({
|
|
||||||
message: slashCommandResult.reason,
|
|
||||||
isError: true,
|
|
||||||
adapter,
|
|
||||||
config,
|
|
||||||
startTimeMs: startTime,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case 'no_command':
|
|
||||||
break;
|
|
||||||
default: {
|
|
||||||
const _exhaustive: never = slashCommandResult;
|
|
||||||
throw new FatalInputError(
|
|
||||||
`Unhandled slash command result type: ${(_exhaustive as { type: string }).type}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +163,15 @@ export async function runNonInteractive(
|
|||||||
const initialParts = normalizePartList(initialPartList);
|
const initialParts = normalizePartList(initialPartList);
|
||||||
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
|
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
|
||||||
|
|
||||||
|
if (adapter) {
|
||||||
|
const systemMessage = await buildSystemMessage(
|
||||||
|
config,
|
||||||
|
sessionId,
|
||||||
|
permissionMode,
|
||||||
|
);
|
||||||
|
adapter.emitMessage(systemMessage);
|
||||||
|
}
|
||||||
|
|
||||||
let isFirstTurn = true;
|
let isFirstTurn = true;
|
||||||
while (true) {
|
while (true) {
|
||||||
turnCount++;
|
turnCount++;
|
||||||
@@ -312,8 +223,6 @@ export async function runNonInteractive(
|
|||||||
config.getContentGeneratorConfig()?.authType,
|
config.getContentGeneratorConfig()?.authType,
|
||||||
);
|
);
|
||||||
process.stderr.write(`${errorText}\n`);
|
process.stderr.write(`${errorText}\n`);
|
||||||
// Throw error to exit with non-zero code
|
|
||||||
throw new Error(errorText);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,7 +248,7 @@ export async function runNonInteractive(
|
|||||||
? options.controlService.permission.getToolCallUpdateCallback()
|
? options.controlService.permission.getToolCallUpdateCallback()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Create output handler for Task tool (for subagent execution)
|
// Only pass outputUpdateHandler for Task tool
|
||||||
const isTaskTool = finalRequestInfo.name === 'task';
|
const isTaskTool = finalRequestInfo.name === 'task';
|
||||||
const taskToolProgress = isTaskTool
|
const taskToolProgress = isTaskTool
|
||||||
? createTaskToolProgressHandler(
|
? createTaskToolProgressHandler(
|
||||||
@@ -349,41 +258,20 @@ export async function runNonInteractive(
|
|||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const taskToolProgressHandler = taskToolProgress?.handler;
|
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(
|
const toolResponse = await executeToolCall(
|
||||||
config,
|
config,
|
||||||
finalRequestInfo,
|
finalRequestInfo,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
outputUpdateHandler || toolCallUpdateCallback
|
isTaskTool && taskToolProgressHandler
|
||||||
? {
|
? {
|
||||||
...(outputUpdateHandler && { outputUpdateHandler }),
|
outputUpdateHandler: taskToolProgressHandler,
|
||||||
...(toolCallUpdateCallback && {
|
onToolCallsUpdate: toolCallUpdateCallback,
|
||||||
onToolCallsUpdate: toolCallUpdateCallback,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
: undefined,
|
: toolCallUpdateCallback
|
||||||
|
? {
|
||||||
|
onToolCallsUpdate: toolCallUpdateCallback,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Note: In JSON mode, subagent messages are automatically added to the main
|
// Note: In JSON mode, subagent messages are automatically added to the main
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Qwen Team
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
|
||||||
import type { LoadedSettings } from './config/settings.js';
|
|
||||||
import { CommandKind } from './ui/commands/types.js';
|
|
||||||
|
|
||||||
// Mock the CommandService
|
|
||||||
const mockGetCommands = vi.hoisted(() => vi.fn());
|
|
||||||
const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
|
|
||||||
vi.mock('./services/CommandService.js', () => ({
|
|
||||||
CommandService: {
|
|
||||||
create: mockCommandServiceCreate,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('handleSlashCommand', () => {
|
|
||||||
let mockConfig: Config;
|
|
||||||
let mockSettings: LoadedSettings;
|
|
||||||
let abortController: AbortController;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockCommandServiceCreate.mockResolvedValue({
|
|
||||||
getCommands: mockGetCommands,
|
|
||||||
});
|
|
||||||
|
|
||||||
mockConfig = {
|
|
||||||
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
|
|
||||||
isInteractive: vi.fn().mockReturnValue(false),
|
|
||||||
getSessionId: vi.fn().mockReturnValue('test-session'),
|
|
||||||
getFolderTrustFeature: vi.fn().mockReturnValue(false),
|
|
||||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
|
||||||
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
|
|
||||||
storage: {},
|
|
||||||
} as unknown as Config;
|
|
||||||
|
|
||||||
mockSettings = {
|
|
||||||
system: { path: '', settings: {} },
|
|
||||||
systemDefaults: { path: '', settings: {} },
|
|
||||||
user: { path: '', settings: {} },
|
|
||||||
workspace: { path: '', settings: {} },
|
|
||||||
} as LoadedSettings;
|
|
||||||
|
|
||||||
abortController = new AbortController();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return no_command for non-slash input', async () => {
|
|
||||||
const result = await handleSlashCommand(
|
|
||||||
'regular text',
|
|
||||||
abortController,
|
|
||||||
mockConfig,
|
|
||||||
mockSettings,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.type).toBe('no_command');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return no_command for unknown slash commands', async () => {
|
|
||||||
mockGetCommands.mockReturnValue([]);
|
|
||||||
|
|
||||||
const result = await handleSlashCommand(
|
|
||||||
'/unknowncommand',
|
|
||||||
abortController,
|
|
||||||
mockConfig,
|
|
||||||
mockSettings,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.type).toBe('no_command');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return unsupported for known built-in commands not in allowed list', async () => {
|
|
||||||
const mockHelpCommand = {
|
|
||||||
name: 'help',
|
|
||||||
description: 'Show help',
|
|
||||||
kind: CommandKind.BUILT_IN,
|
|
||||||
action: vi.fn(),
|
|
||||||
};
|
|
||||||
mockGetCommands.mockReturnValue([mockHelpCommand]);
|
|
||||||
|
|
||||||
const result = await handleSlashCommand(
|
|
||||||
'/help',
|
|
||||||
abortController,
|
|
||||||
mockConfig,
|
|
||||||
mockSettings,
|
|
||||||
[], // Empty allowed list
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.type).toBe('unsupported');
|
|
||||||
if (result.type === 'unsupported') {
|
|
||||||
expect(result.reason).toContain('/help');
|
|
||||||
expect(result.reason).toContain('not supported');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return unsupported for /help when using default allowed list', async () => {
|
|
||||||
const mockHelpCommand = {
|
|
||||||
name: 'help',
|
|
||||||
description: 'Show help',
|
|
||||||
kind: CommandKind.BUILT_IN,
|
|
||||||
action: vi.fn(),
|
|
||||||
};
|
|
||||||
mockGetCommands.mockReturnValue([mockHelpCommand]);
|
|
||||||
|
|
||||||
const result = await handleSlashCommand(
|
|
||||||
'/help',
|
|
||||||
abortController,
|
|
||||||
mockConfig,
|
|
||||||
mockSettings,
|
|
||||||
// Default allowed list: ['init', 'summary', 'compress']
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.type).toBe('unsupported');
|
|
||||||
if (result.type === 'unsupported') {
|
|
||||||
expect(result.reason).toBe(
|
|
||||||
'The command "/help" is not supported in non-interactive mode.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should execute allowed built-in commands', async () => {
|
|
||||||
const mockInitCommand = {
|
|
||||||
name: 'init',
|
|
||||||
description: 'Initialize project',
|
|
||||||
kind: CommandKind.BUILT_IN,
|
|
||||||
action: vi.fn().mockResolvedValue({
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'info',
|
|
||||||
content: 'Project initialized',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
mockGetCommands.mockReturnValue([mockInitCommand]);
|
|
||||||
|
|
||||||
const result = await handleSlashCommand(
|
|
||||||
'/init',
|
|
||||||
abortController,
|
|
||||||
mockConfig,
|
|
||||||
mockSettings,
|
|
||||||
['init'], // init is in the allowed list
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.type).toBe('message');
|
|
||||||
if (result.type === 'message') {
|
|
||||||
expect(result.content).toBe('Project initialized');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should execute file commands regardless of allowed list', async () => {
|
|
||||||
const mockFileCommand = {
|
|
||||||
name: 'custom',
|
|
||||||
description: 'Custom file command',
|
|
||||||
kind: CommandKind.FILE,
|
|
||||||
action: vi.fn().mockResolvedValue({
|
|
||||||
type: 'submit_prompt',
|
|
||||||
content: [{ text: 'Custom prompt' }],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
mockGetCommands.mockReturnValue([mockFileCommand]);
|
|
||||||
|
|
||||||
const result = await handleSlashCommand(
|
|
||||||
'/custom',
|
|
||||||
abortController,
|
|
||||||
mockConfig,
|
|
||||||
mockSettings,
|
|
||||||
[], // Empty allowed list, but FILE commands should still work
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.type).toBe('submit_prompt');
|
|
||||||
if (result.type === 'submit_prompt') {
|
|
||||||
expect(result.content).toEqual([{ text: 'Custom prompt' }]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return unsupported for other built-in commands like /quit', async () => {
|
|
||||||
const mockQuitCommand = {
|
|
||||||
name: 'quit',
|
|
||||||
description: 'Quit application',
|
|
||||||
kind: CommandKind.BUILT_IN,
|
|
||||||
action: vi.fn(),
|
|
||||||
};
|
|
||||||
mockGetCommands.mockReturnValue([mockQuitCommand]);
|
|
||||||
|
|
||||||
const result = await handleSlashCommand(
|
|
||||||
'/quit',
|
|
||||||
abortController,
|
|
||||||
mockConfig,
|
|
||||||
mockSettings,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.type).toBe('unsupported');
|
|
||||||
if (result.type === 'unsupported') {
|
|
||||||
expect(result.reason).toContain('/quit');
|
|
||||||
expect(result.reason).toContain('not supported');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle command with no action', async () => {
|
|
||||||
const mockCommand = {
|
|
||||||
name: 'noaction',
|
|
||||||
description: 'Command without action',
|
|
||||||
kind: CommandKind.FILE,
|
|
||||||
// No action property
|
|
||||||
};
|
|
||||||
mockGetCommands.mockReturnValue([mockCommand]);
|
|
||||||
|
|
||||||
const result = await handleSlashCommand(
|
|
||||||
'/noaction',
|
|
||||||
abortController,
|
|
||||||
mockConfig,
|
|
||||||
mockSettings,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.type).toBe('no_command');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return message when command returns void', async () => {
|
|
||||||
const mockCommand = {
|
|
||||||
name: 'voidcmd',
|
|
||||||
description: 'Command that returns void',
|
|
||||||
kind: CommandKind.FILE,
|
|
||||||
action: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
mockGetCommands.mockReturnValue([mockCommand]);
|
|
||||||
|
|
||||||
const result = await handleSlashCommand(
|
|
||||||
'/voidcmd',
|
|
||||||
abortController,
|
|
||||||
mockConfig,
|
|
||||||
mockSettings,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.type).toBe('message');
|
|
||||||
if (result.type === 'message') {
|
|
||||||
expect(result.content).toBe('Command executed successfully.');
|
|
||||||
expect(result.messageType).toBe('info');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import type { PartListUnion } from '@google/genai';
|
import type { PartListUnion } from '@google/genai';
|
||||||
import { parseSlashCommand } from './utils/commands.js';
|
import { parseSlashCommand } from './utils/commands.js';
|
||||||
import {
|
import {
|
||||||
|
FatalInputError,
|
||||||
Logger,
|
Logger,
|
||||||
uiTelemetryService,
|
uiTelemetryService,
|
||||||
type Config,
|
type Config,
|
||||||
@@ -18,164 +19,10 @@ import {
|
|||||||
CommandKind,
|
CommandKind,
|
||||||
type CommandContext,
|
type CommandContext,
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
type SlashCommandActionReturn,
|
|
||||||
} from './ui/commands/types.js';
|
} from './ui/commands/types.js';
|
||||||
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
|
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
|
||||||
import type { LoadedSettings } from './config/settings.js';
|
import type { LoadedSettings } from './config/settings.js';
|
||||||
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
|
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
|
||||||
import { t } from './i18n/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Built-in commands that are allowed in non-interactive modes (CLI and ACP).
|
|
||||||
* Only safe, read-only commands that don't require interactive UI.
|
|
||||||
*
|
|
||||||
* These commands are:
|
|
||||||
* - init: Initialize project configuration
|
|
||||||
* - summary: Generate session summary
|
|
||||||
* - compress: Compress conversation history
|
|
||||||
*/
|
|
||||||
export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
|
|
||||||
'init',
|
|
||||||
'summary',
|
|
||||||
'compress',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of handling a slash command in non-interactive mode.
|
|
||||||
*
|
|
||||||
* Supported types:
|
|
||||||
* - 'submit_prompt': Submits content to the model (supports all modes)
|
|
||||||
* - 'message': Returns a single message (supports non-interactive JSON/text only)
|
|
||||||
* - 'stream_messages': Streams multiple messages (supports ACP only)
|
|
||||||
* - 'unsupported': Command cannot be executed in this mode
|
|
||||||
* - 'no_command': No command was found or executed
|
|
||||||
*/
|
|
||||||
export type NonInteractiveSlashCommandResult =
|
|
||||||
| {
|
|
||||||
type: 'submit_prompt';
|
|
||||||
content: PartListUnion;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'message';
|
|
||||||
messageType: 'info' | 'error';
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'stream_messages';
|
|
||||||
messages: AsyncGenerator<
|
|
||||||
{ messageType: 'info' | 'error'; content: string },
|
|
||||||
void,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'unsupported';
|
|
||||||
reason: string;
|
|
||||||
originalType: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'no_command';
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a SlashCommandActionReturn to a NonInteractiveSlashCommandResult.
|
|
||||||
*
|
|
||||||
* Only the following result types are supported in non-interactive mode:
|
|
||||||
* - submit_prompt: Submits content to the model (all modes)
|
|
||||||
* - message: Returns a single message (non-interactive JSON/text only)
|
|
||||||
* - stream_messages: Streams multiple messages (ACP only)
|
|
||||||
*
|
|
||||||
* All other result types are converted to 'unsupported'.
|
|
||||||
*
|
|
||||||
* @param result The result from executing a slash command action
|
|
||||||
* @returns A NonInteractiveSlashCommandResult describing the outcome
|
|
||||||
*/
|
|
||||||
function handleCommandResult(
|
|
||||||
result: SlashCommandActionReturn,
|
|
||||||
): NonInteractiveSlashCommandResult {
|
|
||||||
switch (result.type) {
|
|
||||||
case 'submit_prompt':
|
|
||||||
return {
|
|
||||||
type: 'submit_prompt',
|
|
||||||
content: result.content,
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'message':
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: result.messageType,
|
|
||||||
content: result.content,
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'stream_messages':
|
|
||||||
return {
|
|
||||||
type: 'stream_messages',
|
|
||||||
messages: result.messages,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Currently return types below are never generated due to the
|
|
||||||
* whitelist of allowed slash commands in ACP and non-interactive mode.
|
|
||||||
* We'll try to add more supported return types in the future.
|
|
||||||
*/
|
|
||||||
case 'tool':
|
|
||||||
return {
|
|
||||||
type: 'unsupported',
|
|
||||||
reason:
|
|
||||||
'Tool execution from slash commands is not supported in non-interactive mode.',
|
|
||||||
originalType: 'tool',
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'quit':
|
|
||||||
return {
|
|
||||||
type: 'unsupported',
|
|
||||||
reason:
|
|
||||||
'Quit command is not supported in non-interactive mode. The process will exit naturally after completion.',
|
|
||||||
originalType: 'quit',
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'dialog':
|
|
||||||
return {
|
|
||||||
type: 'unsupported',
|
|
||||||
reason: `Dialog '${result.dialog}' cannot be opened in non-interactive mode.`,
|
|
||||||
originalType: 'dialog',
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'load_history':
|
|
||||||
return {
|
|
||||||
type: 'unsupported',
|
|
||||||
reason:
|
|
||||||
'Loading history is not supported in non-interactive mode. Each invocation starts with a fresh context.',
|
|
||||||
originalType: 'load_history',
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'confirm_shell_commands':
|
|
||||||
return {
|
|
||||||
type: 'unsupported',
|
|
||||||
reason:
|
|
||||||
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.',
|
|
||||||
originalType: 'confirm_shell_commands',
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'confirm_action':
|
|
||||||
return {
|
|
||||||
type: 'unsupported',
|
|
||||||
reason:
|
|
||||||
'Action confirmation is not supported in non-interactive mode. Commands requiring confirmation cannot be executed.',
|
|
||||||
originalType: 'confirm_action',
|
|
||||||
};
|
|
||||||
|
|
||||||
default: {
|
|
||||||
// Exhaustiveness check
|
|
||||||
const _exhaustive: never = result;
|
|
||||||
return {
|
|
||||||
type: 'unsupported',
|
|
||||||
reason: `Unknown command result type: ${(_exhaustive as SlashCommandActionReturn).type}`,
|
|
||||||
originalType: 'unknown',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters commands based on the allowed built-in command names.
|
* Filters commands based on the allowed built-in command names.
|
||||||
@@ -215,146 +62,122 @@ function filterCommandsForNonInteractive(
|
|||||||
* @param config The configuration object
|
* @param config The configuration object
|
||||||
* @param settings The loaded settings
|
* @param settings The loaded settings
|
||||||
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
||||||
* allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress).
|
* allowed. If not provided or empty, only file commands are available.
|
||||||
* Pass an empty array to only allow file commands.
|
* @returns A Promise that resolves to `PartListUnion` if a valid command is
|
||||||
* @returns A Promise that resolves to a `NonInteractiveSlashCommandResult` describing
|
* found and results in a prompt, or `undefined` otherwise.
|
||||||
* the outcome of the command execution.
|
* @throws {FatalInputError} if the command result is not supported in
|
||||||
|
* non-interactive mode.
|
||||||
*/
|
*/
|
||||||
export const handleSlashCommand = async (
|
export const handleSlashCommand = async (
|
||||||
rawQuery: string,
|
rawQuery: string,
|
||||||
abortController: AbortController,
|
abortController: AbortController,
|
||||||
config: Config,
|
config: Config,
|
||||||
settings: LoadedSettings,
|
settings: LoadedSettings,
|
||||||
allowedBuiltinCommandNames: string[] = [
|
allowedBuiltinCommandNames?: string[],
|
||||||
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
|
): Promise<PartListUnion | undefined> => {
|
||||||
],
|
|
||||||
): Promise<NonInteractiveSlashCommandResult> => {
|
|
||||||
const trimmed = rawQuery.trim();
|
const trimmed = rawQuery.trim();
|
||||||
if (!trimmed.startsWith('/')) {
|
if (!trimmed.startsWith('/')) {
|
||||||
return { type: 'no_command' };
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAcpMode = config.getExperimentalZedIntegration();
|
|
||||||
const isInteractive = config.isInteractive();
|
|
||||||
|
|
||||||
const executionMode = isAcpMode
|
|
||||||
? 'acp'
|
|
||||||
: isInteractive
|
|
||||||
? 'interactive'
|
|
||||||
: 'non_interactive';
|
|
||||||
|
|
||||||
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||||
|
|
||||||
// Load all commands to check if the command exists but is not allowed
|
// Only load BuiltinCommandLoader if there are allowed built-in commands
|
||||||
const allLoaders = [
|
const loaders =
|
||||||
new BuiltinCommandLoader(config),
|
allowedBuiltinSet.size > 0
|
||||||
new FileCommandLoader(config),
|
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
|
||||||
];
|
: [new FileCommandLoader(config)];
|
||||||
|
|
||||||
const commandService = await CommandService.create(
|
const commandService = await CommandService.create(
|
||||||
allLoaders,
|
loaders,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
);
|
);
|
||||||
const allCommands = commandService.getCommands();
|
const commands = commandService.getCommands();
|
||||||
const filteredCommands = filterCommandsForNonInteractive(
|
const filteredCommands = filterCommandsForNonInteractive(
|
||||||
allCommands,
|
commands,
|
||||||
allowedBuiltinSet,
|
allowedBuiltinSet,
|
||||||
);
|
);
|
||||||
|
|
||||||
// First, try to parse with filtered commands
|
|
||||||
const { commandToExecute, args } = parseSlashCommand(
|
const { commandToExecute, args } = parseSlashCommand(
|
||||||
rawQuery,
|
rawQuery,
|
||||||
filteredCommands,
|
filteredCommands,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!commandToExecute) {
|
if (commandToExecute) {
|
||||||
// Check if this is a known command that's just not allowed
|
if (commandToExecute.action) {
|
||||||
const { commandToExecute: knownCommand } = parseSlashCommand(
|
// Not used by custom commands but may be in the future.
|
||||||
rawQuery,
|
const sessionStats: SessionStatsState = {
|
||||||
allCommands,
|
sessionId: config?.getSessionId(),
|
||||||
);
|
sessionStartTime: new Date(),
|
||||||
|
metrics: uiTelemetryService.getMetrics(),
|
||||||
if (knownCommand) {
|
lastPromptTokenCount: 0,
|
||||||
// Command exists but is not allowed in non-interactive mode
|
promptCount: 1,
|
||||||
return {
|
|
||||||
type: 'unsupported',
|
|
||||||
reason: t(
|
|
||||||
'The command "/{{command}}" is not supported in non-interactive mode.',
|
|
||||||
{ command: knownCommand.name },
|
|
||||||
),
|
|
||||||
originalType: 'filtered_command',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const logger = new Logger(config?.getSessionId() || '', config?.storage);
|
||||||
|
|
||||||
|
const context: CommandContext = {
|
||||||
|
services: {
|
||||||
|
config,
|
||||||
|
settings,
|
||||||
|
git: undefined,
|
||||||
|
logger,
|
||||||
|
},
|
||||||
|
ui: createNonInteractiveUI(),
|
||||||
|
session: {
|
||||||
|
stats: sessionStats,
|
||||||
|
sessionShellAllowlist: new Set(),
|
||||||
|
},
|
||||||
|
invocation: {
|
||||||
|
raw: trimmed,
|
||||||
|
name: commandToExecute.name,
|
||||||
|
args,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await commandToExecute.action(context, args);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
switch (result.type) {
|
||||||
|
case 'submit_prompt':
|
||||||
|
return result.content;
|
||||||
|
case 'confirm_shell_commands':
|
||||||
|
// This result indicates a command attempted to confirm shell commands.
|
||||||
|
// However note that currently, ShellTool is excluded in non-interactive
|
||||||
|
// mode unless 'YOLO mode' is active, so confirmation actually won't
|
||||||
|
// occur because of YOLO mode.
|
||||||
|
// This ensures that if a command *does* request confirmation (e.g.
|
||||||
|
// in the future with more granular permissions), it's handled appropriately.
|
||||||
|
throw new FatalInputError(
|
||||||
|
'Exiting due to a confirmation prompt requested by the command.',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new FatalInputError(
|
||||||
|
'Exiting due to command result that is not supported in non-interactive mode.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { type: 'no_command' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!commandToExecute.action) {
|
return;
|
||||||
return { type: 'no_command' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not used by custom commands but may be in the future.
|
|
||||||
const sessionStats: SessionStatsState = {
|
|
||||||
sessionId: config?.getSessionId(),
|
|
||||||
sessionStartTime: new Date(),
|
|
||||||
metrics: uiTelemetryService.getMetrics(),
|
|
||||||
lastPromptTokenCount: 0,
|
|
||||||
promptCount: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const logger = new Logger(config?.getSessionId() || '', config?.storage);
|
|
||||||
|
|
||||||
const context: CommandContext = {
|
|
||||||
executionMode,
|
|
||||||
services: {
|
|
||||||
config,
|
|
||||||
settings,
|
|
||||||
git: undefined,
|
|
||||||
logger,
|
|
||||||
},
|
|
||||||
ui: createNonInteractiveUI(),
|
|
||||||
session: {
|
|
||||||
stats: sessionStats,
|
|
||||||
sessionShellAllowlist: new Set(),
|
|
||||||
},
|
|
||||||
invocation: {
|
|
||||||
raw: trimmed,
|
|
||||||
name: commandToExecute.name,
|
|
||||||
args,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await commandToExecute.action(context, args);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
// Command executed but returned no result (e.g., void return)
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'info',
|
|
||||||
content: 'Command executed successfully.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle different result types
|
|
||||||
return handleCommandResult(result);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves all available slash commands for the current configuration.
|
* Retrieves all available slash commands for the current configuration.
|
||||||
*
|
*
|
||||||
* @param config The configuration object
|
* @param config The configuration object
|
||||||
|
* @param settings The loaded settings
|
||||||
* @param abortSignal Signal to cancel the loading process
|
* @param abortSignal Signal to cancel the loading process
|
||||||
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
||||||
* allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress).
|
* allowed. If not provided or empty, only file commands are available.
|
||||||
* Pass an empty array to only include file commands.
|
|
||||||
* @returns A Promise that resolves to an array of SlashCommand objects
|
* @returns A Promise that resolves to an array of SlashCommand objects
|
||||||
*/
|
*/
|
||||||
export const getAvailableCommands = async (
|
export const getAvailableCommands = async (
|
||||||
config: Config,
|
config: Config,
|
||||||
|
settings: LoadedSettings,
|
||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
allowedBuiltinCommandNames: string[] = [
|
allowedBuiltinCommandNames?: string[],
|
||||||
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
|
|
||||||
],
|
|
||||||
): Promise<SlashCommand[]> => {
|
): Promise<SlashCommand[]> => {
|
||||||
try {
|
try {
|
||||||
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ describe('ShellProcessor', () => {
|
|||||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||||
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
|
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
|
||||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||||
getAllowedTools: vi.fn().mockReturnValue([]),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
context = createMockCommandContext({
|
context = createMockCommandContext({
|
||||||
@@ -197,35 +196,6 @@ 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 () => {
|
it('should NOT throw ConfirmationRequiredError if a command is not allowed but approval mode is YOLO', async () => {
|
||||||
const processor = new ShellProcessor('test-command');
|
const processor = new ShellProcessor('test-command');
|
||||||
const prompt: PromptPipelineContent = createPromptPipelineContent(
|
const prompt: PromptPipelineContent = createPromptPipelineContent(
|
||||||
|
|||||||
@@ -7,13 +7,11 @@
|
|||||||
import {
|
import {
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
checkCommandPermissions,
|
checkCommandPermissions,
|
||||||
doesToolInvocationMatch,
|
|
||||||
escapeShellArg,
|
escapeShellArg,
|
||||||
getShellConfiguration,
|
getShellConfiguration,
|
||||||
ShellExecutionService,
|
ShellExecutionService,
|
||||||
flatMapTextParts,
|
flatMapTextParts,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} 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 { CommandContext } from '../../ui/commands/types.js';
|
||||||
import type { IPromptProcessor, PromptPipelineContent } from './types.js';
|
import type { IPromptProcessor, PromptPipelineContent } from './types.js';
|
||||||
@@ -126,15 +124,6 @@ export class ShellProcessor implements IPromptProcessor {
|
|||||||
// Security check on the final, escaped command string.
|
// Security check on the final, escaped command string.
|
||||||
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
|
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
|
||||||
checkCommandPermissions(command, config, sessionShellAllowlist);
|
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 (!allAllowed) {
|
||||||
if (isHardDenial) {
|
if (isHardDenial) {
|
||||||
@@ -143,17 +132,10 @@ export class ShellProcessor implements IPromptProcessor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the command is allowed by settings, skip confirmation.
|
|
||||||
if (isAllowedBySettings) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not a hard denial, respect YOLO mode and auto-approve.
|
// If not a hard denial, respect YOLO mode and auto-approve.
|
||||||
if (config.getApprovalMode() === ApprovalMode.YOLO) {
|
if (config.getApprovalMode() !== ApprovalMode.YOLO) {
|
||||||
continue;
|
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
|
||||||
}
|
}
|
||||||
|
|
||||||
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,10 @@
|
|||||||
|
|
||||||
import { render } from 'ink-testing-library';
|
import { render } from 'ink-testing-library';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
|
||||||
import { LoadedSettings } from '../config/settings.js';
|
import { LoadedSettings } from '../config/settings.js';
|
||||||
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
|
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
|
||||||
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
|
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
|
||||||
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
|
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
|
||||||
import { ConfigContext } from '../ui/contexts/ConfigContext.js';
|
|
||||||
|
|
||||||
const mockSettings = new LoadedSettings(
|
const mockSettings = new LoadedSettings(
|
||||||
{ path: '', settings: {}, originalSettings: {} },
|
{ path: '', settings: {}, originalSettings: {} },
|
||||||
@@ -24,24 +22,14 @@ const mockSettings = new LoadedSettings(
|
|||||||
|
|
||||||
export const renderWithProviders = (
|
export const renderWithProviders = (
|
||||||
component: React.ReactElement,
|
component: React.ReactElement,
|
||||||
{
|
{ shellFocus = true, settings = mockSettings } = {},
|
||||||
shellFocus = true,
|
|
||||||
settings = mockSettings,
|
|
||||||
config = undefined,
|
|
||||||
}: {
|
|
||||||
shellFocus?: boolean;
|
|
||||||
settings?: LoadedSettings;
|
|
||||||
config?: Config;
|
|
||||||
} = {},
|
|
||||||
): ReturnType<typeof render> =>
|
): ReturnType<typeof render> =>
|
||||||
render(
|
render(
|
||||||
<SettingsContext.Provider value={settings}>
|
<SettingsContext.Provider value={settings}>
|
||||||
<ConfigContext.Provider value={config}>
|
<ShellFocusContext.Provider value={shellFocus}>
|
||||||
<ShellFocusContext.Provider value={shellFocus}>
|
<KeypressProvider kittyProtocolEnabled={true}>
|
||||||
<KeypressProvider kittyProtocolEnabled={true}>
|
{component}
|
||||||
{component}
|
</KeypressProvider>
|
||||||
</KeypressProvider>
|
</ShellFocusContext.Provider>
|
||||||
</ShellFocusContext.Provider>
|
|
||||||
</ConfigContext.Provider>
|
|
||||||
</SettingsContext.Provider>,
|
</SettingsContext.Provider>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
type Config,
|
type Config,
|
||||||
type IdeInfo,
|
type IdeInfo,
|
||||||
type IdeContext,
|
type IdeContext,
|
||||||
|
DEFAULT_GEMINI_FLASH_MODEL,
|
||||||
IdeClient,
|
IdeClient,
|
||||||
ideContextStore,
|
ideContextStore,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
@@ -179,10 +180,15 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper to determine the current model (polled, since Config has no model-change event).
|
// Helper to determine the effective model, considering the fallback state.
|
||||||
const getCurrentModel = useCallback(() => config.getModel(), [config]);
|
const getEffectiveModel = useCallback(() => {
|
||||||
|
if (config.isInFallbackMode()) {
|
||||||
|
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||||
|
}
|
||||||
|
return config.getModel();
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
const [currentModel, setCurrentModel] = useState(getCurrentModel());
|
const [currentModel, setCurrentModel] = useState(getEffectiveModel());
|
||||||
|
|
||||||
const [isConfigInitialized, setConfigInitialized] = useState(false);
|
const [isConfigInitialized, setConfigInitialized] = useState(false);
|
||||||
|
|
||||||
@@ -235,12 +241,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
[historyManager.addItem],
|
[historyManager.addItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Watch for model changes (e.g., user switches model via /model)
|
// Watch for model changes (e.g., from Flash fallback)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkModelChange = () => {
|
const checkModelChange = () => {
|
||||||
const model = getCurrentModel();
|
const effectiveModel = getEffectiveModel();
|
||||||
if (model !== currentModel) {
|
if (effectiveModel !== currentModel) {
|
||||||
setCurrentModel(model);
|
setCurrentModel(effectiveModel);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -248,7 +254,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const interval = setInterval(checkModelChange, 1000); // Check every second
|
const interval = setInterval(checkModelChange, 1000); // Check every second
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [config, currentModel, getCurrentModel]);
|
}, [config, currentModel, getEffectiveModel]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
consoleMessages,
|
consoleMessages,
|
||||||
@@ -370,36 +376,37 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
// Check for enforced auth type mismatch
|
// Check for enforced auth type mismatch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for initialization error first
|
// Check for initialization error first
|
||||||
const currentAuthType = config.modelsConfig.getCurrentAuthType();
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
settings.merged.security?.auth?.enforcedType &&
|
settings.merged.security?.auth?.enforcedType &&
|
||||||
currentAuthType &&
|
settings.merged.security?.auth.selectedType &&
|
||||||
settings.merged.security?.auth.enforcedType !== currentAuthType
|
settings.merged.security?.auth.enforcedType !==
|
||||||
|
settings.merged.security?.auth.selectedType
|
||||||
) {
|
) {
|
||||||
onAuthError(
|
onAuthError(
|
||||||
t(
|
t(
|
||||||
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
|
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
|
||||||
{
|
{
|
||||||
enforcedType: String(settings.merged.security?.auth.enforcedType),
|
enforcedType: settings.merged.security?.auth.enforcedType,
|
||||||
currentType: String(currentAuthType),
|
currentType: settings.merged.security?.auth.selectedType,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (!settings.merged.security?.auth?.useExternal) {
|
} else if (
|
||||||
// If no authType is selected yet, allow the auth UI flow to prompt the user.
|
settings.merged.security?.auth?.selectedType &&
|
||||||
// Only validate credentials once a concrete authType exists.
|
!settings.merged.security?.auth?.useExternal
|
||||||
if (currentAuthType) {
|
) {
|
||||||
const error = validateAuthMethod(currentAuthType, config);
|
const error = validateAuthMethod(
|
||||||
if (error) {
|
settings.merged.security.auth.selectedType,
|
||||||
onAuthError(error);
|
);
|
||||||
}
|
if (error) {
|
||||||
|
onAuthError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
settings.merged.security?.auth?.selectedType,
|
||||||
settings.merged.security?.auth?.enforcedType,
|
settings.merged.security?.auth?.enforcedType,
|
||||||
settings.merged.security?.auth?.useExternal,
|
settings.merged.security?.auth?.useExternal,
|
||||||
config,
|
|
||||||
onAuthError,
|
onAuthError,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -918,12 +925,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const handleIdePromptComplete = useCallback(
|
const handleIdePromptComplete = useCallback(
|
||||||
(result: IdeIntegrationNudgeResult) => {
|
(result: IdeIntegrationNudgeResult) => {
|
||||||
if (result.userSelection === 'yes') {
|
if (result.userSelection === 'yes') {
|
||||||
// Check whether the extension has been pre-installed
|
handleSlashCommand('/ide install');
|
||||||
if (result.isExtensionPreInstalled) {
|
|
||||||
handleSlashCommand('/ide enable');
|
|
||||||
} else {
|
|
||||||
handleSlashCommand('/ide install');
|
|
||||||
}
|
|
||||||
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||||
} else if (result.userSelection === 'dismiss') {
|
} else if (result.userSelection === 'dismiss') {
|
||||||
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export function IdeIntegrationNudge({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { displayName: ideName } = ide;
|
const { displayName: ideName } = ide;
|
||||||
const isInSandbox = !!process.env['SANDBOX'];
|
|
||||||
// Assume extension is already installed if the env variables are set.
|
// Assume extension is already installed if the env variables are set.
|
||||||
const isExtensionPreInstalled =
|
const isExtensionPreInstalled =
|
||||||
!!process.env['QWEN_CODE_IDE_SERVER_PORT'] &&
|
!!process.env['QWEN_CODE_IDE_SERVER_PORT'] &&
|
||||||
@@ -71,15 +70,13 @@ export function IdeIntegrationNudge({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const installText = isInSandbox
|
const installText = isExtensionPreInstalled
|
||||||
? `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.`
|
? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${
|
||||||
: isExtensionPreInstalled
|
ideName ?? 'your editor'
|
||||||
? `If you select Yes, the CLI will connect to your ${
|
}.`
|
||||||
ideName ?? 'editor'
|
: `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${
|
||||||
} and have access to your open files and display diffs directly.`
|
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'
|
|
||||||
}.`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { AuthDialog } from './AuthDialog.js';
|
import { AuthDialog } from './AuthDialog.js';
|
||||||
import { LoadedSettings } from '../../config/settings.js';
|
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
|
||||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||||
import { renderWithProviders } from '../../test-utils/render.js';
|
import { renderWithProviders } from '../../test-utils/render.js';
|
||||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||||
@@ -44,24 +43,17 @@ const renderAuthDialog = (
|
|||||||
settings: LoadedSettings,
|
settings: LoadedSettings,
|
||||||
uiStateOverrides: Partial<UIState> = {},
|
uiStateOverrides: Partial<UIState> = {},
|
||||||
uiActionsOverrides: Partial<UIActions> = {},
|
uiActionsOverrides: Partial<UIActions> = {},
|
||||||
configAuthType: AuthType | undefined = undefined,
|
|
||||||
configApiKey: string | undefined = undefined,
|
|
||||||
) => {
|
) => {
|
||||||
const uiState = createMockUIState(uiStateOverrides);
|
const uiState = createMockUIState(uiStateOverrides);
|
||||||
const uiActions = createMockUIActions(uiActionsOverrides);
|
const uiActions = createMockUIActions(uiActionsOverrides);
|
||||||
|
|
||||||
const mockConfig = {
|
|
||||||
getAuthType: vi.fn(() => configAuthType),
|
|
||||||
getContentGeneratorConfig: vi.fn(() => ({ apiKey: configApiKey })),
|
|
||||||
} as unknown as Config;
|
|
||||||
|
|
||||||
return renderWithProviders(
|
return renderWithProviders(
|
||||||
<UIStateContext.Provider value={uiState}>
|
<UIStateContext.Provider value={uiState}>
|
||||||
<UIActionsContext.Provider value={uiActions}>
|
<UIActionsContext.Provider value={uiActions}>
|
||||||
<AuthDialog />
|
<AuthDialog />
|
||||||
</UIActionsContext.Provider>
|
</UIActionsContext.Provider>
|
||||||
</UIStateContext.Provider>,
|
</UIStateContext.Provider>,
|
||||||
{ settings, config: mockConfig },
|
{ settings },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -429,7 +421,6 @@ describe('AuthDialog', () => {
|
|||||||
settings,
|
settings,
|
||||||
{},
|
{},
|
||||||
{ handleAuthSelect },
|
{ handleAuthSelect },
|
||||||
undefined, // config.getAuthType() returns undefined
|
|
||||||
);
|
);
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
@@ -484,7 +475,6 @@ describe('AuthDialog', () => {
|
|||||||
settings,
|
settings,
|
||||||
{ authError: 'Initial error' },
|
{ authError: 'Initial error' },
|
||||||
{ handleAuthSelect },
|
{ handleAuthSelect },
|
||||||
undefined, // config.getAuthType() returns undefined
|
|
||||||
);
|
);
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
@@ -538,7 +528,6 @@ describe('AuthDialog', () => {
|
|||||||
settings,
|
settings,
|
||||||
{},
|
{},
|
||||||
{ handleAuthSelect },
|
{ handleAuthSelect },
|
||||||
AuthType.USE_OPENAI, // config.getAuthType() returns USE_OPENAI
|
|
||||||
);
|
);
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
@@ -547,7 +536,7 @@ describe('AuthDialog', () => {
|
|||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
// Should call handleAuthSelect with undefined to exit
|
// Should call handleAuthSelect with undefined to exit
|
||||||
expect(handleAuthSelect).toHaveBeenCalledWith(undefined);
|
expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import type React from 'react';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
|
import { SettingScope } from '../../config/settings.js';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||||
import { useConfig } from '../contexts/ConfigContext.js';
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
function parseDefaultAuthType(
|
function parseDefaultAuthType(
|
||||||
@@ -31,7 +32,7 @@ function parseDefaultAuthType(
|
|||||||
export function AuthDialog(): React.JSX.Element {
|
export function AuthDialog(): React.JSX.Element {
|
||||||
const { pendingAuthType, authError } = useUIState();
|
const { pendingAuthType, authError } = useUIState();
|
||||||
const { handleAuthSelect: onAuthSelect } = useUIActions();
|
const { handleAuthSelect: onAuthSelect } = useUIActions();
|
||||||
const config = useConfig();
|
const settings = useSettings();
|
||||||
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
@@ -57,10 +58,9 @@ export function AuthDialog(): React.JSX.Element {
|
|||||||
return item.value === pendingAuthType;
|
return item.value === pendingAuthType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: config.getAuthType() - the source of truth
|
// Priority 2: settings.merged.security?.auth?.selectedType
|
||||||
const currentAuthType = config.getAuthType();
|
if (settings.merged.security?.auth?.selectedType) {
|
||||||
if (currentAuthType) {
|
return item.value === settings.merged.security?.auth?.selectedType;
|
||||||
return item.value === currentAuthType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
|
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
|
||||||
@@ -76,7 +76,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey);
|
const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey);
|
||||||
const currentSelectedAuthType =
|
const currentSelectedAuthType =
|
||||||
selectedIndex !== null
|
selectedIndex !== null
|
||||||
? items[selectedIndex]?.value
|
? items[selectedIndex]?.value
|
||||||
@@ -84,7 +84,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||||||
|
|
||||||
const handleAuthSelect = async (authMethod: AuthType) => {
|
const handleAuthSelect = async (authMethod: AuthType) => {
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
await onAuthSelect(authMethod);
|
await onAuthSelect(authMethod, SettingScope.User);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHighlight = (authMethod: AuthType) => {
|
const handleHighlight = (authMethod: AuthType) => {
|
||||||
@@ -100,7 +100,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (config.getAuthType() === undefined) {
|
if (settings.merged.security?.auth?.selectedType === undefined) {
|
||||||
// Prevent exiting if no auth method is set
|
// Prevent exiting if no auth method is set
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
t(
|
t(
|
||||||
@@ -109,7 +109,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onAuthSelect(undefined);
|
onAuthSelect(undefined, SettingScope.User);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ isActive: true },
|
{ isActive: true },
|
||||||
|
|||||||
@@ -4,16 +4,16 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
AuthEvent,
|
AuthEvent,
|
||||||
AuthType,
|
AuthType,
|
||||||
|
clearCachedCredentialFile,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
logAuth,
|
logAuth,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import type { LoadedSettings } from '../../config/settings.js';
|
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||||
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
|
||||||
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||||
import { useQwenAuth } from '../hooks/useQwenAuth.js';
|
import { useQwenAuth } from '../hooks/useQwenAuth.js';
|
||||||
import { AuthState, MessageType } from '../types.js';
|
import { AuthState, MessageType } from '../types.js';
|
||||||
@@ -27,7 +27,8 @@ export const useAuthCommand = (
|
|||||||
config: Config,
|
config: Config,
|
||||||
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
|
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
|
||||||
) => {
|
) => {
|
||||||
const unAuthenticated = config.getAuthType() === undefined;
|
const unAuthenticated =
|
||||||
|
settings.merged.security?.auth?.selectedType === undefined;
|
||||||
|
|
||||||
const [authState, setAuthState] = useState<AuthState>(
|
const [authState, setAuthState] = useState<AuthState>(
|
||||||
unAuthenticated ? AuthState.Updating : AuthState.Unauthenticated,
|
unAuthenticated ? AuthState.Updating : AuthState.Unauthenticated,
|
||||||
@@ -80,35 +81,35 @@ export const useAuthCommand = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleAuthSuccess = useCallback(
|
const handleAuthSuccess = useCallback(
|
||||||
async (authType: AuthType, credentials?: OpenAICredentials) => {
|
async (
|
||||||
|
authType: AuthType,
|
||||||
|
scope: SettingScope,
|
||||||
|
credentials?: OpenAICredentials,
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const authTypeScope = getPersistScopeForModelSelection(settings);
|
settings.setValue(scope, 'security.auth.selectedType', authType);
|
||||||
settings.setValue(
|
|
||||||
authTypeScope,
|
|
||||||
'security.auth.selectedType',
|
|
||||||
authType,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only update credentials if not switching to QWEN_OAUTH,
|
// Only update credentials if not switching to QWEN_OAUTH,
|
||||||
// so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
|
// so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
|
||||||
if (authType !== AuthType.QWEN_OAUTH && credentials) {
|
if (authType !== AuthType.QWEN_OAUTH && credentials) {
|
||||||
if (credentials?.apiKey != null) {
|
if (credentials?.apiKey != null) {
|
||||||
settings.setValue(
|
settings.setValue(
|
||||||
authTypeScope,
|
scope,
|
||||||
'security.auth.apiKey',
|
'security.auth.apiKey',
|
||||||
credentials.apiKey,
|
credentials.apiKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (credentials?.baseUrl != null) {
|
if (credentials?.baseUrl != null) {
|
||||||
settings.setValue(
|
settings.setValue(
|
||||||
authTypeScope,
|
scope,
|
||||||
'security.auth.baseUrl',
|
'security.auth.baseUrl',
|
||||||
credentials.baseUrl,
|
credentials.baseUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (credentials?.model != null) {
|
if (credentials?.model != null) {
|
||||||
settings.setValue(authTypeScope, 'model.name', credentials.model);
|
settings.setValue(scope, 'model.name', credentials.model);
|
||||||
}
|
}
|
||||||
|
await clearCachedCredentialFile();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleAuthFailure(error);
|
handleAuthFailure(error);
|
||||||
@@ -140,10 +141,14 @@ export const useAuthCommand = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const performAuth = useCallback(
|
const performAuth = useCallback(
|
||||||
async (authType: AuthType, credentials?: OpenAICredentials) => {
|
async (
|
||||||
|
authType: AuthType,
|
||||||
|
scope: SettingScope,
|
||||||
|
credentials?: OpenAICredentials,
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
await config.refreshAuth(authType);
|
await config.refreshAuth(authType);
|
||||||
handleAuthSuccess(authType, credentials);
|
handleAuthSuccess(authType, scope, credentials);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleAuthFailure(e);
|
handleAuthFailure(e);
|
||||||
}
|
}
|
||||||
@@ -151,51 +156,18 @@ export const useAuthCommand = (
|
|||||||
[config, handleAuthSuccess, handleAuthFailure],
|
[config, handleAuthSuccess, handleAuthFailure],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isProviderManagedModel = useCallback(
|
|
||||||
(authType: AuthType, modelId: string | undefined) => {
|
|
||||||
if (!modelId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelProviders = settings.merged.modelProviders as
|
|
||||||
| ModelProvidersConfig
|
|
||||||
| undefined;
|
|
||||||
if (!modelProviders) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const providerModels = modelProviders[authType];
|
|
||||||
if (!Array.isArray(providerModels)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return providerModels.some(
|
|
||||||
(providerModel) => providerModel.id === modelId,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[settings],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAuthSelect = useCallback(
|
const handleAuthSelect = useCallback(
|
||||||
async (authType: AuthType | undefined, credentials?: OpenAICredentials) => {
|
async (
|
||||||
|
authType: AuthType | undefined,
|
||||||
|
scope: SettingScope,
|
||||||
|
credentials?: OpenAICredentials,
|
||||||
|
) => {
|
||||||
if (!authType) {
|
if (!authType) {
|
||||||
setIsAuthDialogOpen(false);
|
setIsAuthDialogOpen(false);
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
authType === AuthType.USE_OPENAI &&
|
|
||||||
credentials?.model &&
|
|
||||||
isProviderManagedModel(authType, credentials.model)
|
|
||||||
) {
|
|
||||||
onAuthError(
|
|
||||||
t(
|
|
||||||
'Model "{{modelName}}" is managed via settings.modelProviders. Please complete the fields in settings, or use another model id.',
|
|
||||||
{ modelName: credentials.model },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPendingAuthType(authType);
|
setPendingAuthType(authType);
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
setIsAuthDialogOpen(false);
|
setIsAuthDialogOpen(false);
|
||||||
@@ -208,14 +180,14 @@ export const useAuthCommand = (
|
|||||||
baseUrl: credentials.baseUrl,
|
baseUrl: credentials.baseUrl,
|
||||||
model: credentials.model,
|
model: credentials.model,
|
||||||
});
|
});
|
||||||
await performAuth(authType, credentials);
|
await performAuth(authType, scope, credentials);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await performAuth(authType);
|
await performAuth(authType, scope);
|
||||||
},
|
},
|
||||||
[config, performAuth, isProviderManagedModel, onAuthError],
|
[config, performAuth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openAuthDialog = useCallback(() => {
|
const openAuthDialog = useCallback(() => {
|
||||||
|
|||||||
@@ -4,28 +4,31 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { approvalModeCommand } from './approvalModeCommand.js';
|
import { approvalModeCommand } from './approvalModeCommand.js';
|
||||||
import {
|
import {
|
||||||
type CommandContext,
|
type CommandContext,
|
||||||
CommandKind,
|
CommandKind,
|
||||||
type OpenDialogActionReturn,
|
type OpenDialogActionReturn,
|
||||||
type MessageActionReturn,
|
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
|
|
||||||
describe('approvalModeCommand', () => {
|
describe('approvalModeCommand', () => {
|
||||||
let mockContext: CommandContext;
|
let mockContext: CommandContext;
|
||||||
let mockSetApprovalMode: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockSetApprovalMode = vi.fn();
|
|
||||||
mockContext = createMockCommandContext({
|
mockContext = createMockCommandContext({
|
||||||
services: {
|
services: {
|
||||||
config: {
|
config: {
|
||||||
getApprovalMode: () => 'default',
|
getApprovalMode: () => 'default',
|
||||||
setApprovalMode: mockSetApprovalMode,
|
setApprovalMode: () => {},
|
||||||
},
|
},
|
||||||
|
settings: {
|
||||||
|
merged: {},
|
||||||
|
setValue: () => {},
|
||||||
|
forScope: () => ({}),
|
||||||
|
} as unknown as LoadedSettings,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -38,7 +41,7 @@ describe('approvalModeCommand', () => {
|
|||||||
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
|
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should open approval mode dialog when invoked without arguments', async () => {
|
it('should open approval mode dialog when invoked', async () => {
|
||||||
const result = (await approvalModeCommand.action?.(
|
const result = (await approvalModeCommand.action?.(
|
||||||
mockContext,
|
mockContext,
|
||||||
'',
|
'',
|
||||||
@@ -48,123 +51,16 @@ describe('approvalModeCommand', () => {
|
|||||||
expect(result.dialog).toBe('approval-mode');
|
expect(result.dialog).toBe('approval-mode');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should open approval mode dialog when invoked with whitespace only', async () => {
|
it('should open approval mode dialog with arguments (ignored)', async () => {
|
||||||
const result = (await approvalModeCommand.action?.(
|
const result = (await approvalModeCommand.action?.(
|
||||||
mockContext,
|
mockContext,
|
||||||
' ',
|
'some arguments',
|
||||||
)) as OpenDialogActionReturn;
|
)) as OpenDialogActionReturn;
|
||||||
|
|
||||||
expect(result.type).toBe('dialog');
|
expect(result.type).toBe('dialog');
|
||||||
expect(result.dialog).toBe('approval-mode');
|
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', () => {
|
it('should not have subcommands', () => {
|
||||||
expect(approvalModeCommand.subCommands).toBeUndefined();
|
expect(approvalModeCommand.subCommands).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,25 +8,9 @@ import type {
|
|||||||
SlashCommand,
|
SlashCommand,
|
||||||
CommandContext,
|
CommandContext,
|
||||||
OpenDialogActionReturn,
|
OpenDialogActionReturn,
|
||||||
MessageActionReturn,
|
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
import { t } from '../../i18n/index.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 = {
|
export const approvalModeCommand: SlashCommand = {
|
||||||
name: 'approval-mode',
|
name: 'approval-mode',
|
||||||
@@ -35,49 +19,10 @@ export const approvalModeCommand: SlashCommand = {
|
|||||||
},
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (
|
action: async (
|
||||||
context: CommandContext,
|
_context: CommandContext,
|
||||||
args: string,
|
_args: string,
|
||||||
): Promise<OpenDialogActionReturn | MessageActionReturn> => {
|
): Promise<OpenDialogActionReturn> => ({
|
||||||
const mode = parseApprovalModeArg(args);
|
type: 'dialog',
|
||||||
|
dialog: 'approval-mode',
|
||||||
// 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 }),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ export const compressCommand: SlashCommand = {
|
|||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
const { ui } = context;
|
const { ui } = context;
|
||||||
const executionMode = context.executionMode ?? 'interactive';
|
if (ui.pendingItem) {
|
||||||
|
|
||||||
if (executionMode === 'interactive' && ui.pendingItem) {
|
|
||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
@@ -42,80 +40,13 @@ export const compressCommand: SlashCommand = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = context.services.config;
|
|
||||||
const geminiClient = config?.getGeminiClient();
|
|
||||||
if (!config || !geminiClient) {
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'error',
|
|
||||||
content: t('Config not loaded.'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const doCompress = async () => {
|
|
||||||
const promptId = `compress-${Date.now()}`;
|
|
||||||
return await geminiClient.tryCompressChat(promptId, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (executionMode === 'acp') {
|
|
||||||
const messages = async function* () {
|
|
||||||
try {
|
|
||||||
yield {
|
|
||||||
messageType: 'info' as const,
|
|
||||||
content: 'Compressing context...',
|
|
||||||
};
|
|
||||||
const compressed = await doCompress();
|
|
||||||
if (!compressed) {
|
|
||||||
yield {
|
|
||||||
messageType: 'error' as const,
|
|
||||||
content: t('Failed to compress chat history.'),
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
yield {
|
|
||||||
messageType: 'info' as const,
|
|
||||||
content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
yield {
|
|
||||||
messageType: 'error' as const,
|
|
||||||
content: t('Failed to compress chat history: {{error}}', {
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { type: 'stream_messages', messages: messages() };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (executionMode === 'interactive') {
|
ui.setPendingItem(pendingMessage);
|
||||||
ui.setPendingItem(pendingMessage);
|
const promptId = `compress-${Date.now()}`;
|
||||||
}
|
const compressed = await context.services.config
|
||||||
|
?.getGeminiClient()
|
||||||
const compressed = await doCompress();
|
?.tryCompressChat(promptId, true);
|
||||||
|
if (compressed) {
|
||||||
if (!compressed) {
|
|
||||||
if (executionMode === 'interactive') {
|
|
||||||
ui.addItem(
|
|
||||||
{
|
|
||||||
type: MessageType.ERROR,
|
|
||||||
text: t('Failed to compress chat history.'),
|
|
||||||
},
|
|
||||||
Date.now(),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'error',
|
|
||||||
content: t('Failed to compress chat history.'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (executionMode === 'interactive') {
|
|
||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.COMPRESSION,
|
type: MessageType.COMPRESSION,
|
||||||
@@ -128,39 +59,27 @@ export const compressCommand: SlashCommand = {
|
|||||||
} as HistoryItemCompression,
|
} as HistoryItemCompression,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'info',
|
|
||||||
content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (executionMode === 'interactive') {
|
|
||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: t('Failed to compress chat history: {{error}}', {
|
text: t('Failed to compress chat history.'),
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
return {
|
ui.addItem(
|
||||||
type: 'message',
|
{
|
||||||
messageType: 'error',
|
type: MessageType.ERROR,
|
||||||
content: t('Failed to compress chat history: {{error}}', {
|
text: t('Failed to compress chat history: {{error}}', {
|
||||||
error: e instanceof Error ? e.message : String(e),
|
error: e instanceof Error ? e.message : String(e),
|
||||||
}),
|
}),
|
||||||
};
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (executionMode === 'interactive') {
|
ui.setPendingItem(null);
|
||||||
ui.setPendingItem(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -191,23 +191,11 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
const installer = getIdeInstaller(currentIDE);
|
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) {
|
if (!installer) {
|
||||||
const ideName = ideClient.getDetectedIdeDisplayName();
|
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: 'error',
|
type: 'error',
|
||||||
text: `Automatic installation is not supported for ${ideName}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
|
text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,16 +13,6 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
|
|||||||
vi.mock('../../i18n/index.js', () => ({
|
vi.mock('../../i18n/index.js', () => ({
|
||||||
setLanguageAsync: vi.fn().mockResolvedValue(undefined),
|
setLanguageAsync: vi.fn().mockResolvedValue(undefined),
|
||||||
getCurrentLanguage: vi.fn().mockReturnValue('en'),
|
getCurrentLanguage: vi.fn().mockReturnValue('en'),
|
||||||
detectSystemLanguage: vi.fn().mockReturnValue('en'),
|
|
||||||
getLanguageNameFromLocale: vi.fn((locale: string) => {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
zh: 'Chinese',
|
|
||||||
en: 'English',
|
|
||||||
ru: 'Russian',
|
|
||||||
de: 'German',
|
|
||||||
};
|
|
||||||
return map[locale] || 'English';
|
|
||||||
}),
|
|
||||||
t: vi.fn((key: string) => key),
|
t: vi.fn((key: string) => key),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -71,10 +61,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||||||
|
|
||||||
// Import modules after mocking
|
// Import modules after mocking
|
||||||
import * as i18n from '../../i18n/index.js';
|
import * as i18n from '../../i18n/index.js';
|
||||||
import {
|
import { languageCommand } from './languageCommand.js';
|
||||||
languageCommand,
|
|
||||||
initializeLlmOutputLanguage,
|
|
||||||
} from './languageCommand.js';
|
|
||||||
|
|
||||||
describe('languageCommand', () => {
|
describe('languageCommand', () => {
|
||||||
let mockContext: CommandContext;
|
let mockContext: CommandContext;
|
||||||
@@ -199,39 +186,6 @@ describe('languageCommand', () => {
|
|||||||
content: expect.stringContaining('Chinese'),
|
content: expect.stringContaining('Chinese'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should parse Unicode LLM output language from marker', async () => {
|
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
||||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
||||||
[
|
|
||||||
'# ⚠️ CRITICAL: 中文 Output Language Rule - HIGHEST PRIORITY ⚠️',
|
|
||||||
'<!-- qwen-code:llm-output-language: 中文 -->',
|
|
||||||
'',
|
|
||||||
'Some other content...',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mocked(i18n.t).mockImplementation(
|
|
||||||
(key: string, params?: Record<string, string>) => {
|
|
||||||
if (params && key.includes('{{lang}}')) {
|
|
||||||
return key.replace('{{lang}}', params['lang'] || '');
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!languageCommand.action) {
|
|
||||||
throw new Error('The language command must have an action.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await languageCommand.action(mockContext, '');
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'info',
|
|
||||||
content: expect.stringContaining('中文'),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('main command action - config not available', () => {
|
describe('main command action - config not available', () => {
|
||||||
@@ -446,34 +400,6 @@ describe('languageCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should normalize locale code "ru" to "Russian"', async () => {
|
|
||||||
if (!languageCommand.action) {
|
|
||||||
throw new Error('The language command must have an action.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await languageCommand.action(mockContext, 'output ru');
|
|
||||||
|
|
||||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('output-language.md'),
|
|
||||||
expect.stringContaining('Russian'),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should normalize locale code "de" to "German"', async () => {
|
|
||||||
if (!languageCommand.action) {
|
|
||||||
throw new Error('The language command must have an action.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await languageCommand.action(mockContext, 'output de');
|
|
||||||
|
|
||||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('output-language.md'),
|
|
||||||
expect.stringContaining('German'),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle file write errors gracefully', async () => {
|
it('should handle file write errors gracefully', async () => {
|
||||||
vi.mocked(fs.writeFileSync).mockImplementation(() => {
|
vi.mocked(fs.writeFileSync).mockImplementation(() => {
|
||||||
throw new Error('Permission denied');
|
throw new Error('Permission denied');
|
||||||
@@ -555,8 +481,6 @@ describe('languageCommand', () => {
|
|||||||
const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name);
|
const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name);
|
||||||
expect(nestedNames).toContain('zh-CN');
|
expect(nestedNames).toContain('zh-CN');
|
||||||
expect(nestedNames).toContain('en-US');
|
expect(nestedNames).toContain('en-US');
|
||||||
expect(nestedNames).toContain('ru-RU');
|
|
||||||
expect(nestedNames).toContain('de-DE');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have action that sets language', async () => {
|
it('should have action that sets language', async () => {
|
||||||
@@ -618,9 +542,16 @@ describe('languageCommand', () => {
|
|||||||
const enUSSubcommand = uiSubcommand?.subCommands?.find(
|
const enUSSubcommand = uiSubcommand?.subCommands?.find(
|
||||||
(c) => c.name === 'en-US',
|
(c) => c.name === 'en-US',
|
||||||
);
|
);
|
||||||
const deDESubcommand = uiSubcommand?.subCommands?.find(
|
|
||||||
(c) => c.name === 'de-DE',
|
it('zh-CN should have aliases', () => {
|
||||||
);
|
expect(zhCNSubcommand?.altNames).toContain('zh');
|
||||||
|
expect(zhCNSubcommand?.altNames).toContain('chinese');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('en-US should have aliases', () => {
|
||||||
|
expect(enUSSubcommand?.altNames).toContain('en');
|
||||||
|
expect(enUSSubcommand?.altNames).toContain('english');
|
||||||
|
});
|
||||||
|
|
||||||
it('zh-CN action should set Chinese', async () => {
|
it('zh-CN action should set Chinese', async () => {
|
||||||
if (!zhCNSubcommand?.action) {
|
if (!zhCNSubcommand?.action) {
|
||||||
@@ -652,21 +583,6 @@ describe('languageCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('de-DE action should set German', async () => {
|
|
||||||
if (!deDESubcommand?.action) {
|
|
||||||
throw new Error('de-DE subcommand must have an action.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await deDESubcommand.action(mockContext, '');
|
|
||||||
|
|
||||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('de');
|
|
||||||
expect(result).toEqual({
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'info',
|
|
||||||
content: expect.stringContaining('UI language changed'),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject extra arguments', async () => {
|
it('should reject extra arguments', async () => {
|
||||||
if (!zhCNSubcommand?.action) {
|
if (!zhCNSubcommand?.action) {
|
||||||
throw new Error('zh-CN subcommand must have an action.');
|
throw new Error('zh-CN subcommand must have an action.');
|
||||||
@@ -681,74 +597,4 @@ describe('languageCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('initializeLlmOutputLanguage', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
||||||
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
|
||||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create file when it does not exist', () => {
|
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
||||||
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('en');
|
|
||||||
|
|
||||||
initializeLlmOutputLanguage();
|
|
||||||
|
|
||||||
expect(fs.mkdirSync).toHaveBeenCalled();
|
|
||||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('output-language.md'),
|
|
||||||
expect.stringContaining('English'),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should NOT overwrite existing file', () => {
|
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
||||||
|
|
||||||
initializeLlmOutputLanguage();
|
|
||||||
|
|
||||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect Chinese locale and create Chinese rule file', () => {
|
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
||||||
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh');
|
|
||||||
|
|
||||||
initializeLlmOutputLanguage();
|
|
||||||
|
|
||||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('output-language.md'),
|
|
||||||
expect.stringContaining('Chinese'),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect Russian locale and create Russian rule file', () => {
|
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
||||||
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('ru');
|
|
||||||
|
|
||||||
initializeLlmOutputLanguage();
|
|
||||||
|
|
||||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('output-language.md'),
|
|
||||||
expect.stringContaining('Russian'),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect German locale and create German rule file', () => {
|
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
||||||
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('de');
|
|
||||||
|
|
||||||
initializeLlmOutputLanguage();
|
|
||||||
|
|
||||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('output-language.md'),
|
|
||||||
expect.stringContaining('German'),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
* Copyright 2025 Qwen team
|
* Copyright 2025 Google LLC
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -15,72 +15,51 @@ import { SettingScope } from '../../config/settings.js';
|
|||||||
import {
|
import {
|
||||||
setLanguageAsync,
|
setLanguageAsync,
|
||||||
getCurrentLanguage,
|
getCurrentLanguage,
|
||||||
detectSystemLanguage,
|
|
||||||
getLanguageNameFromLocale,
|
|
||||||
type SupportedLanguage,
|
type SupportedLanguage,
|
||||||
t,
|
t,
|
||||||
} from '../../i18n/index.js';
|
} from '../../i18n/index.js';
|
||||||
import {
|
|
||||||
SUPPORTED_LANGUAGES,
|
|
||||||
type LanguageDefinition,
|
|
||||||
} from '../../i18n/languages.js';
|
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { Storage } from '@qwen-code/qwen-code-core';
|
import { Storage } from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
const LLM_OUTPUT_LANGUAGE_RULE_FILENAME = 'output-language.md';
|
const LLM_OUTPUT_LANGUAGE_RULE_FILENAME = 'output-language.md';
|
||||||
const LLM_OUTPUT_LANGUAGE_MARKER_PREFIX = 'qwen-code:llm-output-language:';
|
|
||||||
|
|
||||||
function parseUiLanguageArg(input: string): SupportedLanguage | null {
|
|
||||||
const lowered = input.trim().toLowerCase();
|
|
||||||
if (!lowered) return null;
|
|
||||||
for (const lang of SUPPORTED_LANGUAGES) {
|
|
||||||
if (
|
|
||||||
lowered === lang.code ||
|
|
||||||
lowered === lang.id.toLowerCase() ||
|
|
||||||
lowered === lang.fullName.toLowerCase()
|
|
||||||
) {
|
|
||||||
return lang.code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUiLanguageDisplay(lang: SupportedLanguage): string {
|
|
||||||
const option = SUPPORTED_LANGUAGES.find((o) => o.code === lang);
|
|
||||||
return option ? `${option.fullName}(${option.id})` : lang;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeLanguageForMarker(language: string): string {
|
|
||||||
// HTML comments cannot contain "--" or end markers like "-->" or "--!>" safely.
|
|
||||||
// Also avoid newlines to keep the marker single-line and robust to parsing.
|
|
||||||
return language
|
|
||||||
.replace(/[\r\n]/g, ' ')
|
|
||||||
.replace(/--!?>/g, '')
|
|
||||||
.replace(/--/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the LLM output language rule template based on the language name.
|
* Generates the LLM output language rule template based on the language name.
|
||||||
*/
|
*/
|
||||||
function generateLlmOutputLanguageRule(language: string): string {
|
function generateLlmOutputLanguageRule(language: string): string {
|
||||||
const markerLanguage = sanitizeLanguageForMarker(language);
|
return `# ⚠️ CRITICAL: ${language} Output Language Rule - HIGHEST PRIORITY ⚠️
|
||||||
return `# Output language preference: ${language}
|
|
||||||
<!-- ${LLM_OUTPUT_LANGUAGE_MARKER_PREFIX} ${markerLanguage} -->
|
|
||||||
|
|
||||||
## Goal
|
## 🚨 MANDATORY RULE - NO EXCEPTIONS 🚨
|
||||||
Prefer responding in **${language}** for normal assistant messages and explanations.
|
|
||||||
|
|
||||||
## Keep technical artifacts unchanged
|
**YOU MUST RESPOND IN ${language.toUpperCase()} FOR EVERY SINGLE OUTPUT, REGARDLESS OF THE USER'S INPUT LANGUAGE.**
|
||||||
Do **not** translate or rewrite:
|
|
||||||
- Code blocks, CLI commands, file paths, stack traces, logs, JSON keys, identifiers
|
|
||||||
- Exact quoted text from the user (keep quotes verbatim)
|
|
||||||
|
|
||||||
## When a conflict exists
|
This is a **NON-NEGOTIABLE** requirement. Even if the user writes in English, says "hi", asks a simple question, or explicitly requests another language, **YOU MUST ALWAYS RESPOND IN ${language.toUpperCase()}.**
|
||||||
If higher-priority instructions (system/developer) require a different behavior, follow them.
|
|
||||||
|
|
||||||
## Tool / system outputs
|
## What Must Be in ${language}
|
||||||
Raw tool/system outputs may contain fixed-format English. Preserve them verbatim, and if needed, add a short **${language}** explanation below.
|
|
||||||
|
**EVERYTHING** you output: conversation replies, tool call descriptions, success/error messages, generated file content (comments, documentation), and all explanatory text.
|
||||||
|
|
||||||
|
**Tool outputs**: All descriptive text from \`read_file\`, \`write_file\`, \`codebase_search\`, \`run_terminal_cmd\`, \`todo_write\`, \`web_search\`, etc. MUST be in ${language}.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### ✅ CORRECT:
|
||||||
|
- User says "hi" → Respond in ${language} (e.g., "Bonjour" if ${language} is French)
|
||||||
|
- Tool result → "已成功读取文件 config.json" (if ${language} is Chinese)
|
||||||
|
- Error → "无法找到指定的文件" (if ${language} is Chinese)
|
||||||
|
|
||||||
|
### ❌ WRONG:
|
||||||
|
- User says "hi" → "Hello" in English
|
||||||
|
- Tool result → "Successfully read file" in English
|
||||||
|
- Error → "File not found" in English
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Code elements (variable/function names, syntax) can remain in English
|
||||||
|
- Comments, documentation, and all other text MUST be in ${language}
|
||||||
|
|
||||||
|
**THIS RULE IS ACTIVE NOW. ALL OUTPUTS MUST BE IN ${language.toUpperCase()}. NO EXCEPTIONS.**
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,80 +73,6 @@ function getLlmOutputLanguageRulePath(): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes a language input to its full English name.
|
|
||||||
* If the input is a known locale code (e.g., "ru", "zh"), converts it to the full name.
|
|
||||||
* Otherwise, returns the input as-is (e.g., "Japanese" stays "Japanese").
|
|
||||||
*/
|
|
||||||
function normalizeLanguageName(language: string): string {
|
|
||||||
const lowered = language.toLowerCase();
|
|
||||||
// Check if it's a known locale code and convert to full name
|
|
||||||
const fullName = getLanguageNameFromLocale(lowered);
|
|
||||||
// If getLanguageNameFromLocale returned a different value, use it
|
|
||||||
// Otherwise, use the original input (preserves case for unknown languages)
|
|
||||||
if (fullName !== 'English' || lowered === 'en') {
|
|
||||||
return fullName;
|
|
||||||
}
|
|
||||||
return language;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractLlmOutputLanguageFromRuleFileContent(
|
|
||||||
content: string,
|
|
||||||
): string | null {
|
|
||||||
// Preferred: machine-readable marker that supports Unicode and spaces.
|
|
||||||
// Example: <!-- qwen-code:llm-output-language: 中文 -->
|
|
||||||
const markerMatch = content.match(
|
|
||||||
new RegExp(
|
|
||||||
String.raw`<!--\s*${LLM_OUTPUT_LANGUAGE_MARKER_PREFIX}\s*(.*?)\s*-->`,
|
|
||||||
'i',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (markerMatch?.[1]) {
|
|
||||||
const lang = markerMatch[1].trim();
|
|
||||||
if (lang) return lang;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward compatibility: parse the heading line.
|
|
||||||
// Example: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY"
|
|
||||||
// Example: "# ⚠️ CRITICAL: 日本語 Output Language Rule - HIGHEST PRIORITY ⚠️"
|
|
||||||
const headingMatch = content.match(
|
|
||||||
/^#.*?CRITICAL:\s*(.*?)\s+Output Language Rule\b/im,
|
|
||||||
);
|
|
||||||
if (headingMatch?.[1]) {
|
|
||||||
const lang = headingMatch[1].trim();
|
|
||||||
if (lang) return lang;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the LLM output language rule file on first startup.
|
|
||||||
* If the file already exists, it is not overwritten (respects user preference).
|
|
||||||
*/
|
|
||||||
export function initializeLlmOutputLanguage(): void {
|
|
||||||
const filePath = getLlmOutputLanguageRulePath();
|
|
||||||
|
|
||||||
// Skip if file already exists (user preference)
|
|
||||||
if (fs.existsSync(filePath)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect system language and map to language name
|
|
||||||
const detectedLocale = detectSystemLanguage();
|
|
||||||
const languageName = getLanguageNameFromLocale(detectedLocale);
|
|
||||||
|
|
||||||
// Generate the rule file
|
|
||||||
const content = generateLlmOutputLanguageRule(languageName);
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
const dir = path.dirname(filePath);
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
|
|
||||||
// Write file
|
|
||||||
fs.writeFileSync(filePath, content, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the current LLM output language from the rule file if it exists.
|
* Gets the current LLM output language from the rule file if it exists.
|
||||||
*/
|
*/
|
||||||
@@ -176,7 +81,12 @@ function getCurrentLlmOutputLanguage(): string | null {
|
|||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
return extractLlmOutputLanguageFromRuleFileContent(content);
|
// Extract language name from the first line
|
||||||
|
// Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY"
|
||||||
|
const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i);
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors
|
// Ignore errors
|
||||||
}
|
}
|
||||||
@@ -217,11 +127,18 @@ async function setUiLanguage(
|
|||||||
// Reload commands to update their descriptions with the new language
|
// Reload commands to update their descriptions with the new language
|
||||||
context.ui.reloadCommands();
|
context.ui.reloadCommands();
|
||||||
|
|
||||||
|
// Map language codes to friendly display names
|
||||||
|
const langDisplayNames: Partial<Record<SupportedLanguage, string>> = {
|
||||||
|
zh: '中文(zh-CN)',
|
||||||
|
en: 'English(en-US)',
|
||||||
|
ru: 'Русский (ru-RU)',
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
content: t('UI language changed to {{lang}}', {
|
content: t('UI language changed to {{lang}}', {
|
||||||
lang: formatUiLanguageDisplay(lang),
|
lang: langDisplayNames[lang] || lang,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -234,9 +151,7 @@ function generateLlmOutputLanguageRuleFile(
|
|||||||
): Promise<MessageActionReturn> {
|
): Promise<MessageActionReturn> {
|
||||||
try {
|
try {
|
||||||
const filePath = getLlmOutputLanguageRulePath();
|
const filePath = getLlmOutputLanguageRulePath();
|
||||||
// Normalize locale codes (e.g., "ru" -> "Russian") to full language names
|
const content = generateLlmOutputLanguageRule(language);
|
||||||
const normalizedLanguage = normalizeLanguageName(language);
|
|
||||||
const content = generateLlmOutputLanguageRule(normalizedLanguage);
|
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
const dir = path.dirname(filePath);
|
const dir = path.dirname(filePath);
|
||||||
@@ -281,6 +196,7 @@ export const languageCommand: SlashCommand = {
|
|||||||
args: string,
|
args: string,
|
||||||
): Promise<SlashCommandActionReturn> => {
|
): Promise<SlashCommandActionReturn> => {
|
||||||
const { services } = context;
|
const { services } = context;
|
||||||
|
|
||||||
if (!services.config) {
|
if (!services.config) {
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
@@ -291,37 +207,18 @@ export const languageCommand: SlashCommand = {
|
|||||||
|
|
||||||
const trimmedArgs = args.trim();
|
const trimmedArgs = args.trim();
|
||||||
|
|
||||||
// Handle subcommands if called directly via action (for tests/backward compatibility)
|
|
||||||
const parts = trimmedArgs.split(/\s+/);
|
|
||||||
const firstArg = parts[0].toLowerCase();
|
|
||||||
const subArgs = parts.slice(1).join(' ');
|
|
||||||
|
|
||||||
if (firstArg === 'ui' || firstArg === 'output') {
|
|
||||||
const subCommand = languageCommand.subCommands?.find(
|
|
||||||
(s) => s.name === firstArg,
|
|
||||||
);
|
|
||||||
if (subCommand?.action) {
|
|
||||||
return subCommand.action(
|
|
||||||
context,
|
|
||||||
subArgs,
|
|
||||||
) as Promise<SlashCommandActionReturn>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no arguments, show current language settings and usage
|
// If no arguments, show current language settings and usage
|
||||||
if (!trimmedArgs) {
|
if (!trimmedArgs) {
|
||||||
const currentUiLang = getCurrentLanguage();
|
const currentUiLang = getCurrentLanguage();
|
||||||
const currentLlmLang = getCurrentLlmOutputLanguage();
|
const currentLlmLang = getCurrentLlmOutputLanguage();
|
||||||
const message = [
|
const message = [
|
||||||
t('Current UI language: {{lang}}', {
|
t('Current UI language: {{lang}}', { lang: currentUiLang }),
|
||||||
lang: formatUiLanguageDisplay(currentUiLang as SupportedLanguage),
|
|
||||||
}),
|
|
||||||
currentLlmLang
|
currentLlmLang
|
||||||
? t('Current LLM output language: {{lang}}', { lang: currentLlmLang })
|
? t('Current LLM output language: {{lang}}', { lang: currentLlmLang })
|
||||||
: t('LLM output language not set'),
|
: t('LLM output language not set'),
|
||||||
'',
|
'',
|
||||||
t('Available subcommands:'),
|
t('Available subcommands:'),
|
||||||
` /language ui [${SUPPORTED_LANGUAGES.map((o) => o.id).join('|')}] - ${t('Set UI language')}`,
|
` /language ui [zh-CN|en-US|ru-RU] - ${t('Set UI language')}`,
|
||||||
` /language output <language> - ${t('Set LLM output language')}`,
|
` /language output <language> - ${t('Set LLM output language')}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
@@ -332,21 +229,115 @@ export const languageCommand: SlashCommand = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle backward compatibility for /language [lang]
|
// Parse subcommand
|
||||||
const targetLang = parseUiLanguageArg(trimmedArgs);
|
const parts = trimmedArgs.split(/\s+/);
|
||||||
if (targetLang) {
|
const subcommand = parts[0].toLowerCase();
|
||||||
|
|
||||||
|
if (subcommand === 'ui') {
|
||||||
|
// Handle /language ui [zh-CN|en-US|ru-RU]
|
||||||
|
if (parts.length === 1) {
|
||||||
|
// Show UI language subcommand help
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: [
|
||||||
|
t('Set UI language'),
|
||||||
|
'',
|
||||||
|
t('Usage: /language ui [zh-CN|en-US|ru-RU]'),
|
||||||
|
'',
|
||||||
|
t('Available options:'),
|
||||||
|
t(' - zh-CN: Simplified Chinese'),
|
||||||
|
t(' - en-US: English'),
|
||||||
|
t(' - ru-RU: Russian'),
|
||||||
|
'',
|
||||||
|
t(
|
||||||
|
'To request additional UI language packs, please open an issue on GitHub.',
|
||||||
|
),
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const langArg = parts[1].toLowerCase();
|
||||||
|
let targetLang: SupportedLanguage | null = null;
|
||||||
|
|
||||||
|
if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') {
|
||||||
|
targetLang = 'en';
|
||||||
|
} else if (
|
||||||
|
langArg === 'zh' ||
|
||||||
|
langArg === 'chinese' ||
|
||||||
|
langArg === '中文' ||
|
||||||
|
langArg === 'zh-cn'
|
||||||
|
) {
|
||||||
|
targetLang = 'zh';
|
||||||
|
} else if (
|
||||||
|
langArg === 'ru' ||
|
||||||
|
langArg === 'ru-RU' ||
|
||||||
|
langArg === 'russian' ||
|
||||||
|
langArg === 'русский'
|
||||||
|
) {
|
||||||
|
targetLang = 'ru';
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: t('Invalid language. Available: en-US, zh-CN, ru-RU'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return setUiLanguage(context, targetLang);
|
||||||
|
} else if (subcommand === 'output') {
|
||||||
|
// Handle /language output <language>
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: [
|
||||||
|
t('Set LLM output language'),
|
||||||
|
'',
|
||||||
|
t('Usage: /language output <language>'),
|
||||||
|
` ${t('Example: /language output 中文')}`,
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join all parts after "output" as the language name
|
||||||
|
const language = parts.slice(1).join(' ');
|
||||||
|
return generateLlmOutputLanguageRuleFile(language);
|
||||||
|
} else {
|
||||||
|
// Backward compatibility: treat as UI language
|
||||||
|
const langArg = trimmedArgs.toLowerCase();
|
||||||
|
let targetLang: SupportedLanguage | null = null;
|
||||||
|
|
||||||
|
if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') {
|
||||||
|
targetLang = 'en';
|
||||||
|
} else if (
|
||||||
|
langArg === 'zh' ||
|
||||||
|
langArg === 'chinese' ||
|
||||||
|
langArg === '中文' ||
|
||||||
|
langArg === 'zh-cn'
|
||||||
|
) {
|
||||||
|
targetLang = 'zh';
|
||||||
|
} else if (
|
||||||
|
langArg === 'ru' ||
|
||||||
|
langArg === 'ru-RU' ||
|
||||||
|
langArg === 'russian' ||
|
||||||
|
langArg === 'русский'
|
||||||
|
) {
|
||||||
|
targetLang = 'ru';
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: [
|
||||||
|
t('Invalid command. Available subcommands:'),
|
||||||
|
' - /language ui [zh-CN|en-US|ru-RU] - ' + t('Set UI language'),
|
||||||
|
' - /language output <language> - ' + t('Set LLM output language'),
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return setUiLanguage(context, targetLang);
|
return setUiLanguage(context, targetLang);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'error',
|
|
||||||
content: [
|
|
||||||
t('Invalid command. Available subcommands:'),
|
|
||||||
` - /language ui [${SUPPORTED_LANGUAGES.map((o) => o.id).join('|')}] - ${t('Set UI language')}`,
|
|
||||||
' - /language output <language> - ' + t('Set LLM output language'),
|
|
||||||
].join('\n'),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
subCommands: [
|
subCommands: [
|
||||||
{
|
{
|
||||||
@@ -367,14 +358,11 @@ export const languageCommand: SlashCommand = {
|
|||||||
content: [
|
content: [
|
||||||
t('Set UI language'),
|
t('Set UI language'),
|
||||||
'',
|
'',
|
||||||
t('Usage: /language ui [{{options}}]', {
|
t('Usage: /language ui [zh-CN|en-US]'),
|
||||||
options: SUPPORTED_LANGUAGES.map((o) => o.id).join('|'),
|
|
||||||
}),
|
|
||||||
'',
|
'',
|
||||||
t('Available options:'),
|
t('Available options:'),
|
||||||
...SUPPORTED_LANGUAGES.map(
|
t(' - zh-CN: Simplified Chinese'),
|
||||||
(o) => ` - ${o.id}: ${t(o.fullName)}`,
|
t(' - en-US: English'),
|
||||||
),
|
|
||||||
'',
|
'',
|
||||||
t(
|
t(
|
||||||
'To request additional UI language packs, please open an issue on GitHub.',
|
'To request additional UI language packs, please open an issue on GitHub.',
|
||||||
@@ -383,20 +371,99 @@ export const languageCommand: SlashCommand = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetLang = parseUiLanguageArg(trimmedArgs);
|
const langArg = trimmedArgs.toLowerCase();
|
||||||
if (!targetLang) {
|
let targetLang: SupportedLanguage | null = null;
|
||||||
|
|
||||||
|
if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') {
|
||||||
|
targetLang = 'en';
|
||||||
|
} else if (
|
||||||
|
langArg === 'zh' ||
|
||||||
|
langArg === 'chinese' ||
|
||||||
|
langArg === '中文' ||
|
||||||
|
langArg === 'zh-cn'
|
||||||
|
) {
|
||||||
|
targetLang = 'zh';
|
||||||
|
} else {
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: t('Invalid language. Available: {{options}}', {
|
content: t('Invalid language. Available: en-US, zh-CN'),
|
||||||
options: SUPPORTED_LANGUAGES.map((o) => o.id).join(','),
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return setUiLanguage(context, targetLang);
|
return setUiLanguage(context, targetLang);
|
||||||
},
|
},
|
||||||
subCommands: SUPPORTED_LANGUAGES.map(createUiLanguageSubCommand),
|
subCommands: [
|
||||||
|
{
|
||||||
|
name: 'zh-CN',
|
||||||
|
altNames: ['zh', 'chinese', '中文'],
|
||||||
|
get description() {
|
||||||
|
return t('Set UI language to Simplified Chinese (zh-CN)');
|
||||||
|
},
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: async (
|
||||||
|
context: CommandContext,
|
||||||
|
args: string,
|
||||||
|
): Promise<MessageActionReturn> => {
|
||||||
|
if (args.trim().length > 0) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: t(
|
||||||
|
'Language subcommands do not accept additional arguments.',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return setUiLanguage(context, 'zh');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'en-US',
|
||||||
|
altNames: ['en', 'english'],
|
||||||
|
get description() {
|
||||||
|
return t('Set UI language to English (en-US)');
|
||||||
|
},
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: async (
|
||||||
|
context: CommandContext,
|
||||||
|
args: string,
|
||||||
|
): Promise<MessageActionReturn> => {
|
||||||
|
if (args.trim().length > 0) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: t(
|
||||||
|
'Language subcommands do not accept additional arguments.',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return setUiLanguage(context, 'en');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ru-RU',
|
||||||
|
altNames: ['ru', 'russian', 'русский'],
|
||||||
|
get description() {
|
||||||
|
return t('Set UI language to Russian (ru-RU)');
|
||||||
|
},
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: async (
|
||||||
|
context: CommandContext,
|
||||||
|
args: string,
|
||||||
|
): Promise<MessageActionReturn> => {
|
||||||
|
if (args.trim().length > 0) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: t(
|
||||||
|
'Language subcommands do not accept additional arguments.',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return setUiLanguage(context, 'ru');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'output',
|
name: 'output',
|
||||||
@@ -429,28 +496,3 @@ export const languageCommand: SlashCommand = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to create a UI language subcommand.
|
|
||||||
*/
|
|
||||||
function createUiLanguageSubCommand(option: LanguageDefinition): SlashCommand {
|
|
||||||
return {
|
|
||||||
name: option.id,
|
|
||||||
get description() {
|
|
||||||
return t('Set UI language to {{name}}', { name: option.fullName });
|
|
||||||
},
|
|
||||||
kind: CommandKind.BUILT_IN,
|
|
||||||
action: async (context, args) => {
|
|
||||||
if (args.trim().length > 0) {
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'error',
|
|
||||||
content: t(
|
|
||||||
'Language subcommands do not accept additional arguments.',
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return setUiLanguage(context, option.code);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,14 +11,9 @@ import type { SlashCommand, type CommandContext } from './types.js';
|
|||||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import type { LoadedSettings } from '../../config/settings.js';
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import {
|
import {
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
loadServerHierarchicalMemory,
|
loadServerHierarchicalMemory,
|
||||||
QWEN_DIR,
|
|
||||||
setGeminiMdFilename,
|
|
||||||
type FileDiscoveryService,
|
type FileDiscoveryService,
|
||||||
type LoadServerHierarchicalMemoryResponse,
|
type LoadServerHierarchicalMemoryResponse,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
@@ -36,18 +31,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('node:fs/promises', () => {
|
|
||||||
const readFile = vi.fn();
|
|
||||||
return {
|
|
||||||
readFile,
|
|
||||||
default: {
|
|
||||||
readFile,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock;
|
const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock;
|
||||||
const mockReadFile = readFile as unknown as Mock;
|
|
||||||
|
|
||||||
describe('memoryCommand', () => {
|
describe('memoryCommand', () => {
|
||||||
let mockContext: CommandContext;
|
let mockContext: CommandContext;
|
||||||
@@ -68,10 +52,6 @@ describe('memoryCommand', () => {
|
|||||||
let mockGetGeminiMdFileCount: Mock;
|
let mockGetGeminiMdFileCount: Mock;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setGeminiMdFilename('QWEN.md');
|
|
||||||
mockReadFile.mockReset();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
|
|
||||||
showCommand = getSubCommand('show');
|
showCommand = getSubCommand('show');
|
||||||
|
|
||||||
mockGetUserMemory = vi.fn();
|
mockGetUserMemory = vi.fn();
|
||||||
@@ -122,52 +102,6 @@ describe('memoryCommand', () => {
|
|||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show project memory from the configured context file', async () => {
|
|
||||||
const projectCommand = showCommand.subCommands?.find(
|
|
||||||
(cmd) => cmd.name === '--project',
|
|
||||||
);
|
|
||||||
if (!projectCommand?.action) throw new Error('Command has no action');
|
|
||||||
|
|
||||||
setGeminiMdFilename('AGENTS.md');
|
|
||||||
vi.spyOn(process, 'cwd').mockReturnValue('/test/project');
|
|
||||||
mockReadFile.mockResolvedValue('project memory');
|
|
||||||
|
|
||||||
await projectCommand.action(mockContext, '');
|
|
||||||
|
|
||||||
const expectedProjectPath = path.join('/test/project', 'AGENTS.md');
|
|
||||||
expect(mockReadFile).toHaveBeenCalledWith(expectedProjectPath, 'utf-8');
|
|
||||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
type: MessageType.INFO,
|
|
||||||
text: expect.stringContaining(expectedProjectPath),
|
|
||||||
},
|
|
||||||
expect.any(Number),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show global memory from the configured context file', async () => {
|
|
||||||
const globalCommand = showCommand.subCommands?.find(
|
|
||||||
(cmd) => cmd.name === '--global',
|
|
||||||
);
|
|
||||||
if (!globalCommand?.action) throw new Error('Command has no action');
|
|
||||||
|
|
||||||
setGeminiMdFilename('AGENTS.md');
|
|
||||||
vi.spyOn(os, 'homedir').mockReturnValue('/home/user');
|
|
||||||
mockReadFile.mockResolvedValue('global memory');
|
|
||||||
|
|
||||||
await globalCommand.action(mockContext, '');
|
|
||||||
|
|
||||||
const expectedGlobalPath = path.join('/home/user', QWEN_DIR, 'AGENTS.md');
|
|
||||||
expect(mockReadFile).toHaveBeenCalledWith(expectedGlobalPath, 'utf-8');
|
|
||||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
type: MessageType.INFO,
|
|
||||||
text: expect.stringContaining('Global memory content'),
|
|
||||||
},
|
|
||||||
expect.any(Number),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/memory add', () => {
|
describe('/memory add', () => {
|
||||||
|
|||||||
@@ -6,13 +6,12 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
getCurrentGeminiMdFilename,
|
|
||||||
loadServerHierarchicalMemory,
|
loadServerHierarchicalMemory,
|
||||||
QWEN_DIR,
|
QWEN_DIR,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'os';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'fs/promises';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
@@ -57,12 +56,7 @@ export const memoryCommand: SlashCommand = {
|
|||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
try {
|
try {
|
||||||
const workingDir =
|
const projectMemoryPath = path.join(process.cwd(), 'QWEN.md');
|
||||||
context.services.config?.getWorkingDir?.() ?? process.cwd();
|
|
||||||
const projectMemoryPath = path.join(
|
|
||||||
workingDir,
|
|
||||||
getCurrentGeminiMdFilename(),
|
|
||||||
);
|
|
||||||
const memoryContent = await fs.readFile(
|
const memoryContent = await fs.readFile(
|
||||||
projectMemoryPath,
|
projectMemoryPath,
|
||||||
'utf-8',
|
'utf-8',
|
||||||
@@ -110,7 +104,7 @@ export const memoryCommand: SlashCommand = {
|
|||||||
const globalMemoryPath = path.join(
|
const globalMemoryPath = path.join(
|
||||||
os.homedir(),
|
os.homedir(),
|
||||||
QWEN_DIR,
|
QWEN_DIR,
|
||||||
getCurrentGeminiMdFilename(),
|
'QWEN.md',
|
||||||
);
|
);
|
||||||
const globalMemoryContent = await fs.readFile(
|
const globalMemoryContent = await fs.readFile(
|
||||||
globalMemoryPath,
|
globalMemoryPath,
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import {
|
|||||||
type ContentGeneratorConfig,
|
type ContentGeneratorConfig,
|
||||||
type Config,
|
type Config,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import * as availableModelsModule from '../models/availableModels.js';
|
||||||
|
|
||||||
|
// Mock the availableModels module
|
||||||
|
vi.mock('../models/availableModels.js', () => ({
|
||||||
|
getAvailableModelsForAuthType: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
// Helper function to create a mock config
|
// Helper function to create a mock config
|
||||||
function createMockConfig(
|
function createMockConfig(
|
||||||
@@ -25,6 +31,9 @@ function createMockConfig(
|
|||||||
|
|
||||||
describe('modelCommand', () => {
|
describe('modelCommand', () => {
|
||||||
let mockContext: CommandContext;
|
let mockContext: CommandContext;
|
||||||
|
const mockGetAvailableModelsForAuthType = vi.mocked(
|
||||||
|
availableModelsModule.getAvailableModelsForAuthType,
|
||||||
|
);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockContext = createMockCommandContext();
|
mockContext = createMockCommandContext();
|
||||||
@@ -78,6 +87,10 @@ describe('modelCommand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return dialog action for QWEN_OAUTH auth type', async () => {
|
it('should return dialog action for QWEN_OAUTH auth type', async () => {
|
||||||
|
mockGetAvailableModelsForAuthType.mockReturnValue([
|
||||||
|
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
|
||||||
|
]);
|
||||||
|
|
||||||
const mockConfig = createMockConfig({
|
const mockConfig = createMockConfig({
|
||||||
model: 'test-model',
|
model: 'test-model',
|
||||||
authType: AuthType.QWEN_OAUTH,
|
authType: AuthType.QWEN_OAUTH,
|
||||||
@@ -92,7 +105,11 @@ describe('modelCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return dialog action for USE_OPENAI auth type', async () => {
|
it('should return dialog action for USE_OPENAI auth type when model is available', async () => {
|
||||||
|
mockGetAvailableModelsForAuthType.mockReturnValue([
|
||||||
|
{ id: 'gpt-4', label: 'gpt-4' },
|
||||||
|
]);
|
||||||
|
|
||||||
const mockConfig = createMockConfig({
|
const mockConfig = createMockConfig({
|
||||||
model: 'test-model',
|
model: 'test-model',
|
||||||
authType: AuthType.USE_OPENAI,
|
authType: AuthType.USE_OPENAI,
|
||||||
@@ -107,7 +124,28 @@ describe('modelCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return dialog action for unsupported auth types', async () => {
|
it('should return error for USE_OPENAI auth type when no model is available', async () => {
|
||||||
|
mockGetAvailableModelsForAuthType.mockReturnValue([]);
|
||||||
|
|
||||||
|
const mockConfig = createMockConfig({
|
||||||
|
model: 'test-model',
|
||||||
|
authType: AuthType.USE_OPENAI,
|
||||||
|
});
|
||||||
|
mockContext.services.config = mockConfig as Config;
|
||||||
|
|
||||||
|
const result = await modelCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content:
|
||||||
|
'No models available for the current authentication type (openai).',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for unsupported auth types', async () => {
|
||||||
|
mockGetAvailableModelsForAuthType.mockReturnValue([]);
|
||||||
|
|
||||||
const mockConfig = createMockConfig({
|
const mockConfig = createMockConfig({
|
||||||
model: 'test-model',
|
model: 'test-model',
|
||||||
authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType,
|
authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType,
|
||||||
@@ -117,8 +155,10 @@ describe('modelCommand', () => {
|
|||||||
const result = await modelCommand.action!(mockContext, '');
|
const result = await modelCommand.action!(mockContext, '');
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'dialog',
|
type: 'message',
|
||||||
dialog: 'model',
|
messageType: 'error',
|
||||||
|
content:
|
||||||
|
'No models available for the current authentication type (UNSUPPORTED_AUTH_TYPE).',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
MessageActionReturn,
|
MessageActionReturn,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
|
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const modelCommand: SlashCommand = {
|
export const modelCommand: SlashCommand = {
|
||||||
@@ -29,7 +30,7 @@ export const modelCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: t('Configuration not available.'),
|
content: 'Configuration not available.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +52,22 @@ export const modelCommand: SlashCommand = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const availableModels = getAvailableModelsForAuthType(authType);
|
||||||
|
|
||||||
|
if (availableModels.length === 0) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: t(
|
||||||
|
'No models available for the current authentication type ({{authType}}).',
|
||||||
|
{
|
||||||
|
authType,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger model selection dialog
|
||||||
return {
|
return {
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
dialog: 'model',
|
dialog: 'model',
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ export const summaryCommand: SlashCommand = {
|
|||||||
action: async (context): Promise<SlashCommandActionReturn> => {
|
action: async (context): Promise<SlashCommandActionReturn> => {
|
||||||
const { config } = context.services;
|
const { config } = context.services;
|
||||||
const { ui } = context;
|
const { ui } = context;
|
||||||
const executionMode = context.executionMode ?? 'interactive';
|
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
@@ -45,8 +43,8 @@ export const summaryCommand: SlashCommand = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already generating summary (interactive UI only)
|
// Check if already generating summary
|
||||||
if (executionMode === 'interactive' && ui.pendingItem) {
|
if (ui.pendingItem) {
|
||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: 'error' as const,
|
type: 'error' as const,
|
||||||
@@ -65,22 +63,29 @@ export const summaryCommand: SlashCommand = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const getChatHistory = () => {
|
try {
|
||||||
|
// Get the current chat history
|
||||||
const chat = geminiClient.getChat();
|
const chat = geminiClient.getChat();
|
||||||
return chat.getHistory();
|
const history = chat.getHistory();
|
||||||
};
|
|
||||||
|
|
||||||
const validateChatHistory = (
|
|
||||||
history: ReturnType<typeof getChatHistory>,
|
|
||||||
) => {
|
|
||||||
if (history.length <= 2) {
|
if (history.length <= 2) {
|
||||||
throw new Error(t('No conversation found to summarize.'));
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: t('No conversation found to summarize.'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const generateSummaryMarkdown = async (
|
// Show loading state
|
||||||
history: ReturnType<typeof getChatHistory>,
|
const pendingMessage: HistoryItemSummary = {
|
||||||
): Promise<string> => {
|
type: 'summary',
|
||||||
|
summary: {
|
||||||
|
isPending: true,
|
||||||
|
stage: 'generating',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ui.setPendingItem(pendingMessage);
|
||||||
|
|
||||||
// Build the conversation context for summary generation
|
// Build the conversation context for summary generation
|
||||||
const conversationContext = history.map((message) => ({
|
const conversationContext = history.map((message) => ({
|
||||||
role: message.role,
|
role: message.role,
|
||||||
@@ -116,21 +121,19 @@ export const summaryCommand: SlashCommand = {
|
|||||||
|
|
||||||
if (!markdownSummary) {
|
if (!markdownSummary) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t(
|
'Failed to generate summary - no text content received from LLM response',
|
||||||
'Failed to generate summary - no text content received from LLM response',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return markdownSummary;
|
// Update loading message to show saving progress
|
||||||
};
|
ui.setPendingItem({
|
||||||
|
type: 'summary',
|
||||||
|
summary: {
|
||||||
|
isPending: true,
|
||||||
|
stage: 'saving',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const saveSummaryToDisk = async (
|
|
||||||
markdownSummary: string,
|
|
||||||
): Promise<{
|
|
||||||
filePathForDisplay: string;
|
|
||||||
fullPath: string;
|
|
||||||
}> => {
|
|
||||||
// Ensure .qwen directory exists
|
// Ensure .qwen directory exists
|
||||||
const projectRoot = config.getProjectRoot();
|
const projectRoot = config.getProjectRoot();
|
||||||
const qwenDir = path.join(projectRoot, '.qwen');
|
const qwenDir = path.join(projectRoot, '.qwen');
|
||||||
@@ -152,163 +155,45 @@ export const summaryCommand: SlashCommand = {
|
|||||||
|
|
||||||
await fsPromises.writeFile(summaryPath, summaryContent, 'utf8');
|
await fsPromises.writeFile(summaryPath, summaryContent, 'utf8');
|
||||||
|
|
||||||
return {
|
// Clear pending item and show success message
|
||||||
filePathForDisplay: '.qwen/PROJECT_SUMMARY.md',
|
|
||||||
fullPath: summaryPath,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const emitInteractivePending = (stage: 'generating' | 'saving') => {
|
|
||||||
if (executionMode !== 'interactive') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pendingMessage: HistoryItemSummary = {
|
|
||||||
type: 'summary',
|
|
||||||
summary: {
|
|
||||||
isPending: true,
|
|
||||||
stage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
ui.setPendingItem(pendingMessage);
|
|
||||||
};
|
|
||||||
|
|
||||||
const completeInteractive = (filePathForDisplay: string) => {
|
|
||||||
if (executionMode !== 'interactive') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ui.setPendingItem(null);
|
ui.setPendingItem(null);
|
||||||
const completedSummaryItem: HistoryItemSummary = {
|
const completedSummaryItem: HistoryItemSummary = {
|
||||||
type: 'summary',
|
type: 'summary',
|
||||||
summary: {
|
summary: {
|
||||||
isPending: false,
|
isPending: false,
|
||||||
stage: 'completed',
|
stage: 'completed',
|
||||||
filePath: filePathForDisplay,
|
filePath: '.qwen/PROJECT_SUMMARY.md',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
ui.addItem(completedSummaryItem, Date.now());
|
ui.addItem(completedSummaryItem, Date.now());
|
||||||
};
|
|
||||||
|
|
||||||
const formatErrorMessage = (error: unknown): string =>
|
return {
|
||||||
t('Failed to generate project context summary: {{error}}', {
|
type: 'message',
|
||||||
error: error instanceof Error ? error.message : String(error),
|
messageType: 'info',
|
||||||
});
|
content: '', // Empty content since we show the message in UI component
|
||||||
|
};
|
||||||
const failInteractive = (error: unknown) => {
|
} catch (error) {
|
||||||
if (executionMode !== 'interactive') {
|
// Clear pending item on error
|
||||||
return;
|
|
||||||
}
|
|
||||||
ui.setPendingItem(null);
|
ui.setPendingItem(null);
|
||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: 'error' as const,
|
type: 'error' as const,
|
||||||
text: `❌ ${formatErrorMessage(error)}`,
|
text: `❌ ${t(
|
||||||
|
'Failed to generate project context summary: {{error}}',
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
)}`,
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const formatSuccessMessage = (filePathForDisplay: string): string =>
|
|
||||||
t('Saved project summary to {{filePathForDisplay}}.', {
|
|
||||||
filePathForDisplay,
|
|
||||||
});
|
|
||||||
|
|
||||||
const returnNoConversationMessage = (): SlashCommandActionReturn => {
|
|
||||||
const msg = t('No conversation found to summarize.');
|
|
||||||
if (executionMode === 'acp') {
|
|
||||||
const messages = async function* () {
|
|
||||||
yield {
|
|
||||||
messageType: 'info' as const,
|
|
||||||
content: msg,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
type: 'stream_messages',
|
|
||||||
messages: messages(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'info',
|
|
||||||
content: msg,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const executeSummaryGeneration = async (
|
|
||||||
history: ReturnType<typeof getChatHistory>,
|
|
||||||
): Promise<{
|
|
||||||
markdownSummary: string;
|
|
||||||
filePathForDisplay: string;
|
|
||||||
}> => {
|
|
||||||
emitInteractivePending('generating');
|
|
||||||
const markdownSummary = await generateSummaryMarkdown(history);
|
|
||||||
emitInteractivePending('saving');
|
|
||||||
const { filePathForDisplay } = await saveSummaryToDisk(markdownSummary);
|
|
||||||
completeInteractive(filePathForDisplay);
|
|
||||||
return { markdownSummary, filePathForDisplay };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate chat history once at the beginning
|
|
||||||
const history = getChatHistory();
|
|
||||||
try {
|
|
||||||
validateChatHistory(history);
|
|
||||||
} catch (_error) {
|
|
||||||
return returnNoConversationMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (executionMode === 'acp') {
|
|
||||||
const messages = async function* () {
|
|
||||||
try {
|
|
||||||
yield {
|
|
||||||
messageType: 'info' as const,
|
|
||||||
content: t('Generating project summary...'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const { filePathForDisplay } =
|
|
||||||
await executeSummaryGeneration(history);
|
|
||||||
|
|
||||||
yield {
|
|
||||||
messageType: 'info' as const,
|
|
||||||
content: formatSuccessMessage(filePathForDisplay),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
failInteractive(error);
|
|
||||||
yield {
|
|
||||||
messageType: 'error' as const,
|
|
||||||
content: formatErrorMessage(error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'stream_messages',
|
|
||||||
messages: messages(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { filePathForDisplay } = await executeSummaryGeneration(history);
|
|
||||||
|
|
||||||
if (executionMode === 'non_interactive') {
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'info',
|
|
||||||
content: formatSuccessMessage(filePathForDisplay),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interactive mode: UI components already display progress and completion.
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'info',
|
|
||||||
content: '',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
failInteractive(error);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: formatErrorMessage(error),
|
content: t('Failed to generate project context summary: {{error}}', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,14 +22,6 @@ import type {
|
|||||||
|
|
||||||
// Grouped dependencies for clarity and easier mocking
|
// Grouped dependencies for clarity and easier mocking
|
||||||
export interface CommandContext {
|
export interface CommandContext {
|
||||||
/**
|
|
||||||
* Execution mode for the current invocation.
|
|
||||||
*
|
|
||||||
* - interactive: React/Ink UI mode
|
|
||||||
* - non_interactive: non-interactive CLI mode (text/json)
|
|
||||||
* - acp: ACP/Zed integration mode
|
|
||||||
*/
|
|
||||||
executionMode?: 'interactive' | 'non_interactive' | 'acp';
|
|
||||||
// Invocation properties for when commands are called.
|
// Invocation properties for when commands are called.
|
||||||
invocation?: {
|
invocation?: {
|
||||||
/** The raw, untrimmed input string from the user. */
|
/** The raw, untrimmed input string from the user. */
|
||||||
@@ -116,19 +108,6 @@ export interface MessageActionReturn {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The return type for a command action that streams multiple messages.
|
|
||||||
* Used for long-running operations that need to send progress updates.
|
|
||||||
*/
|
|
||||||
export interface StreamMessagesActionReturn {
|
|
||||||
type: 'stream_messages';
|
|
||||||
messages: AsyncGenerator<
|
|
||||||
{ messageType: 'info' | 'error'; content: string },
|
|
||||||
void,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The return type for a command action that needs to open a dialog.
|
* The return type for a command action that needs to open a dialog.
|
||||||
*/
|
*/
|
||||||
@@ -195,7 +174,6 @@ export interface ConfirmActionReturn {
|
|||||||
export type SlashCommandActionReturn =
|
export type SlashCommandActionReturn =
|
||||||
| ToolActionReturn
|
| ToolActionReturn
|
||||||
| MessageActionReturn
|
| MessageActionReturn
|
||||||
| StreamMessagesActionReturn
|
|
||||||
| QuitActionReturn
|
| QuitActionReturn
|
||||||
| OpenDialogActionReturn
|
| OpenDialogActionReturn
|
||||||
| LoadHistoryActionReturn
|
| LoadHistoryActionReturn
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function ApprovalModeDialog({
|
|||||||
}: ApprovalModeDialogProps): React.JSX.Element {
|
}: ApprovalModeDialogProps): React.JSX.Element {
|
||||||
// Start with User scope by default
|
// Start with User scope by default
|
||||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||||
SettingScope.Workspace,
|
SettingScope.User,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track the currently highlighted approval mode
|
// Track the currently highlighted approval mode
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
|||||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||||
import { useConfig } from '../contexts/ConfigContext.js';
|
import { useConfig } from '../contexts/ConfigContext.js';
|
||||||
import { useSettings } from '../contexts/SettingsContext.js';
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
|
import { SettingScope } from '../../config/settings.js';
|
||||||
import { AuthState } from '../types.js';
|
import { AuthState } from '../types.js';
|
||||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
@@ -201,7 +202,7 @@ export const DialogManager = ({
|
|||||||
return (
|
return (
|
||||||
<OpenAIKeyPrompt
|
<OpenAIKeyPrompt
|
||||||
onSubmit={(apiKey, baseUrl, model) => {
|
onSubmit={(apiKey, baseUrl, model) => {
|
||||||
uiActions.handleAuthSelect(AuthType.USE_OPENAI, {
|
uiActions.handleAuthSelect(AuthType.USE_OPENAI, SettingScope.User, {
|
||||||
apiKey,
|
apiKey,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
model,
|
model,
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ import { ModelDialog } from './ModelDialog.js';
|
|||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
|
||||||
import type { LoadedSettings } from '../../config/settings.js';
|
|
||||||
import { SettingScope } from '../../config/settings.js';
|
|
||||||
import {
|
import {
|
||||||
AVAILABLE_MODELS_QWEN,
|
AVAILABLE_MODELS_QWEN,
|
||||||
MAINLINE_CODER,
|
MAINLINE_CODER,
|
||||||
@@ -40,29 +36,18 @@ const renderComponent = (
|
|||||||
};
|
};
|
||||||
const combinedProps = { ...defaultProps, ...props };
|
const combinedProps = { ...defaultProps, ...props };
|
||||||
|
|
||||||
const mockSettings = {
|
|
||||||
isTrusted: true,
|
|
||||||
user: { settings: {} },
|
|
||||||
workspace: { settings: {} },
|
|
||||||
setValue: vi.fn(),
|
|
||||||
} as unknown as LoadedSettings;
|
|
||||||
|
|
||||||
const mockConfig = contextValue
|
const mockConfig = contextValue
|
||||||
? ({
|
? ({
|
||||||
// --- Functions used by ModelDialog ---
|
// --- Functions used by ModelDialog ---
|
||||||
getModel: vi.fn(() => MAINLINE_CODER),
|
getModel: vi.fn(() => MAINLINE_CODER),
|
||||||
setModel: vi.fn().mockResolvedValue(undefined),
|
setModel: vi.fn(),
|
||||||
switchModel: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getAuthType: vi.fn(() => 'qwen-oauth'),
|
getAuthType: vi.fn(() => 'qwen-oauth'),
|
||||||
|
|
||||||
// --- Functions used by ClearcutLogger ---
|
// --- Functions used by ClearcutLogger ---
|
||||||
getUsageStatisticsEnabled: vi.fn(() => true),
|
getUsageStatisticsEnabled: vi.fn(() => true),
|
||||||
getSessionId: vi.fn(() => 'mock-session-id'),
|
getSessionId: vi.fn(() => 'mock-session-id'),
|
||||||
getDebugMode: vi.fn(() => false),
|
getDebugMode: vi.fn(() => false),
|
||||||
getContentGeneratorConfig: vi.fn(() => ({
|
getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })),
|
||||||
authType: AuthType.QWEN_OAUTH,
|
|
||||||
model: MAINLINE_CODER,
|
|
||||||
})),
|
|
||||||
getUseSmartEdit: vi.fn(() => false),
|
getUseSmartEdit: vi.fn(() => false),
|
||||||
getUseModelRouter: vi.fn(() => false),
|
getUseModelRouter: vi.fn(() => false),
|
||||||
getProxy: vi.fn(() => undefined),
|
getProxy: vi.fn(() => undefined),
|
||||||
@@ -73,27 +58,21 @@ const renderComponent = (
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const renderResult = render(
|
const renderResult = render(
|
||||||
<SettingsContext.Provider value={mockSettings}>
|
<ConfigContext.Provider value={mockConfig}>
|
||||||
<ConfigContext.Provider value={mockConfig}>
|
<ModelDialog {...combinedProps} />
|
||||||
<ModelDialog {...combinedProps} />
|
</ConfigContext.Provider>,
|
||||||
</ConfigContext.Provider>
|
|
||||||
</SettingsContext.Provider>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...renderResult,
|
...renderResult,
|
||||||
props: combinedProps,
|
props: combinedProps,
|
||||||
mockConfig,
|
mockConfig,
|
||||||
mockSettings,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('<ModelDialog />', () => {
|
describe('<ModelDialog />', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Ensure env-based fallback models don't leak into this suite from the developer environment.
|
|
||||||
delete process.env['OPENAI_MODEL'];
|
|
||||||
delete process.env['ANTHROPIC_MODEL'];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -112,12 +91,8 @@ describe('<ModelDialog />', () => {
|
|||||||
|
|
||||||
const props = mockedSelect.mock.calls[0][0];
|
const props = mockedSelect.mock.calls[0][0];
|
||||||
expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length);
|
expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length);
|
||||||
expect(props.items[0].value).toBe(
|
expect(props.items[0].value).toBe(MAINLINE_CODER);
|
||||||
`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`,
|
expect(props.items[1].value).toBe(MAINLINE_VLM);
|
||||||
);
|
|
||||||
expect(props.items[1].value).toBe(
|
|
||||||
`${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`,
|
|
||||||
);
|
|
||||||
expect(props.showNumbers).toBe(true);
|
expect(props.showNumbers).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,93 +139,16 @@ describe('<ModelDialog />', () => {
|
|||||||
expect(mockedSelect).toHaveBeenCalledTimes(1);
|
expect(mockedSelect).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => {
|
it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => {
|
||||||
const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue
|
const { props, mockConfig } = renderComponent({}, {}); // Pass empty object for contextValue
|
||||||
|
|
||||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||||
expect(childOnSelect).toBeDefined();
|
expect(childOnSelect).toBeDefined();
|
||||||
|
|
||||||
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
|
childOnSelect(MAINLINE_CODER);
|
||||||
|
|
||||||
expect(mockConfig?.switchModel).toHaveBeenCalledWith(
|
// Assert against the default mock provided by renderComponent
|
||||||
AuthType.QWEN_OAUTH,
|
expect(mockConfig?.setModel).toHaveBeenCalledWith(MAINLINE_CODER);
|
||||||
MAINLINE_CODER,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
reason: 'user_manual',
|
|
||||||
context: 'Model switched via /model dialog',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
|
||||||
SettingScope.User,
|
|
||||||
'model.name',
|
|
||||||
MAINLINE_CODER,
|
|
||||||
);
|
|
||||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
|
||||||
SettingScope.User,
|
|
||||||
'security.auth.selectedType',
|
|
||||||
AuthType.QWEN_OAUTH,
|
|
||||||
);
|
|
||||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls config.switchModel and persists authType+model when selecting a different authType', async () => {
|
|
||||||
const switchModel = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const getAuthType = vi.fn(() => AuthType.USE_OPENAI);
|
|
||||||
const getAvailableModelsForAuthType = vi.fn((t: AuthType) => {
|
|
||||||
if (t === AuthType.USE_OPENAI) {
|
|
||||||
return [{ id: 'gpt-4', label: 'GPT-4', authType: t }];
|
|
||||||
}
|
|
||||||
if (t === AuthType.QWEN_OAUTH) {
|
|
||||||
return AVAILABLE_MODELS_QWEN.map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
label: m.label,
|
|
||||||
authType: AuthType.QWEN_OAUTH,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockConfigWithSwitchAuthType = {
|
|
||||||
getAuthType,
|
|
||||||
getModel: vi.fn(() => 'gpt-4'),
|
|
||||||
getContentGeneratorConfig: vi.fn(() => ({
|
|
||||||
authType: AuthType.QWEN_OAUTH,
|
|
||||||
model: MAINLINE_CODER,
|
|
||||||
})),
|
|
||||||
// Add switchModel to the mock object (not the type)
|
|
||||||
switchModel,
|
|
||||||
getAvailableModelsForAuthType,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { props, mockSettings } = renderComponent(
|
|
||||||
{},
|
|
||||||
// Cast to Config to bypass type checking, matching the runtime behavior
|
|
||||||
mockConfigWithSwitchAuthType as unknown as Partial<Config>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
|
||||||
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
|
|
||||||
|
|
||||||
expect(switchModel).toHaveBeenCalledWith(
|
|
||||||
AuthType.QWEN_OAUTH,
|
|
||||||
MAINLINE_CODER,
|
|
||||||
{ requireCachedCredentials: true },
|
|
||||||
{
|
|
||||||
reason: 'user_manual',
|
|
||||||
context: 'AuthType+model switched via /model dialog',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
|
||||||
SettingScope.User,
|
|
||||||
'model.name',
|
|
||||||
MAINLINE_CODER,
|
|
||||||
);
|
|
||||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
|
||||||
SettingScope.User,
|
|
||||||
'security.auth.selectedType',
|
|
||||||
AuthType.QWEN_OAUTH,
|
|
||||||
);
|
|
||||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -295,25 +193,17 @@ describe('<ModelDialog />', () => {
|
|||||||
it('updates initialIndex when config context changes', () => {
|
it('updates initialIndex when config context changes', () => {
|
||||||
const mockGetModel = vi.fn(() => MAINLINE_CODER);
|
const mockGetModel = vi.fn(() => MAINLINE_CODER);
|
||||||
const mockGetAuthType = vi.fn(() => 'qwen-oauth');
|
const mockGetAuthType = vi.fn(() => 'qwen-oauth');
|
||||||
const mockSettings = {
|
|
||||||
isTrusted: true,
|
|
||||||
user: { settings: {} },
|
|
||||||
workspace: { settings: {} },
|
|
||||||
setValue: vi.fn(),
|
|
||||||
} as unknown as LoadedSettings;
|
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<SettingsContext.Provider value={mockSettings}>
|
<ConfigContext.Provider
|
||||||
<ConfigContext.Provider
|
value={
|
||||||
value={
|
{
|
||||||
{
|
getModel: mockGetModel,
|
||||||
getModel: mockGetModel,
|
getAuthType: mockGetAuthType,
|
||||||
getAuthType: mockGetAuthType,
|
} as unknown as Config
|
||||||
} as unknown as Config
|
}
|
||||||
}
|
>
|
||||||
>
|
<ModelDialog onClose={vi.fn()} />
|
||||||
<ModelDialog onClose={vi.fn()} />
|
</ConfigContext.Provider>,
|
||||||
</ConfigContext.Provider>
|
|
||||||
</SettingsContext.Provider>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
|
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
|
||||||
@@ -325,11 +215,9 @@ describe('<ModelDialog />', () => {
|
|||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<SettingsContext.Provider value={mockSettings}>
|
<ConfigContext.Provider value={newMockConfig}>
|
||||||
<ConfigContext.Provider value={newMockConfig}>
|
<ModelDialog onClose={vi.fn()} />
|
||||||
<ModelDialog onClose={vi.fn()} />
|
</ConfigContext.Provider>,
|
||||||
</ConfigContext.Provider>
|
|
||||||
</SettingsContext.Provider>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should be called at least twice: initial render + re-render after context change
|
// Should be called at least twice: initial render + re-render after context change
|
||||||
|
|||||||
@@ -5,210 +5,52 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
import { useCallback, useContext, useMemo } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import {
|
import {
|
||||||
AuthType,
|
AuthType,
|
||||||
ModelSlashCommandEvent,
|
ModelSlashCommandEvent,
|
||||||
logModelSlashCommand,
|
logModelSlashCommand,
|
||||||
type ContentGeneratorConfig,
|
|
||||||
type ContentGeneratorConfigSource,
|
|
||||||
type ContentGeneratorConfigSources,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
|
||||||
import { useSettings } from '../contexts/SettingsContext.js';
|
|
||||||
import {
|
import {
|
||||||
getAvailableModelsForAuthType,
|
getAvailableModelsForAuthType,
|
||||||
MAINLINE_CODER,
|
MAINLINE_CODER,
|
||||||
} from '../models/availableModels.js';
|
} from '../models/availableModels.js';
|
||||||
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface ModelDialogProps {
|
interface ModelDialogProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSourceBadge(
|
|
||||||
source: ContentGeneratorConfigSource | undefined,
|
|
||||||
): string | undefined {
|
|
||||||
if (!source) return undefined;
|
|
||||||
|
|
||||||
switch (source.kind) {
|
|
||||||
case 'cli':
|
|
||||||
return source.detail ? `CLI ${source.detail}` : 'CLI';
|
|
||||||
case 'env':
|
|
||||||
return source.envKey ? `ENV ${source.envKey}` : 'ENV';
|
|
||||||
case 'settings':
|
|
||||||
return source.settingsPath
|
|
||||||
? `Settings ${source.settingsPath}`
|
|
||||||
: 'Settings';
|
|
||||||
case 'modelProviders': {
|
|
||||||
const suffix =
|
|
||||||
source.authType && source.modelId
|
|
||||||
? `${source.authType}:${source.modelId}`
|
|
||||||
: source.authType
|
|
||||||
? `${source.authType}`
|
|
||||||
: source.modelId
|
|
||||||
? `${source.modelId}`
|
|
||||||
: '';
|
|
||||||
return suffix ? `ModelProviders ${suffix}` : 'ModelProviders';
|
|
||||||
}
|
|
||||||
case 'default':
|
|
||||||
return source.detail ? `Default ${source.detail}` : 'Default';
|
|
||||||
case 'computed':
|
|
||||||
return source.detail ? `Computed ${source.detail}` : 'Computed';
|
|
||||||
case 'programmatic':
|
|
||||||
return source.detail ? `Programmatic ${source.detail}` : 'Programmatic';
|
|
||||||
case 'unknown':
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSourcesFromConfig(config: unknown): ContentGeneratorConfigSources {
|
|
||||||
if (!config) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const maybe = config as {
|
|
||||||
getContentGeneratorConfigSources?: () => ContentGeneratorConfigSources;
|
|
||||||
};
|
|
||||||
return maybe.getContentGeneratorConfigSources?.() ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function maskApiKey(apiKey: string | undefined): string {
|
|
||||||
if (!apiKey) return '(not set)';
|
|
||||||
const trimmed = apiKey.trim();
|
|
||||||
if (trimmed.length === 0) return '(not set)';
|
|
||||||
if (trimmed.length <= 6) return '***';
|
|
||||||
const head = trimmed.slice(0, 3);
|
|
||||||
const tail = trimmed.slice(-4);
|
|
||||||
return `${head}…${tail}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistModelSelection(
|
|
||||||
settings: ReturnType<typeof useSettings>,
|
|
||||||
modelId: string,
|
|
||||||
): void {
|
|
||||||
const scope = getPersistScopeForModelSelection(settings);
|
|
||||||
settings.setValue(scope, 'model.name', modelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistAuthTypeSelection(
|
|
||||||
settings: ReturnType<typeof useSettings>,
|
|
||||||
authType: AuthType,
|
|
||||||
): void {
|
|
||||||
const scope = getPersistScopeForModelSelection(settings);
|
|
||||||
settings.setValue(scope, 'security.auth.selectedType', authType);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfigRow({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
badge,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: React.ReactNode;
|
|
||||||
badge?: string;
|
|
||||||
}): React.JSX.Element {
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column">
|
|
||||||
<Box>
|
|
||||||
<Box minWidth={12} flexShrink={0}>
|
|
||||||
<Text color={theme.text.secondary}>{label}:</Text>
|
|
||||||
</Box>
|
|
||||||
<Box flexGrow={1} flexDirection="row" flexWrap="wrap">
|
|
||||||
<Text>{value}</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
{badge ? (
|
|
||||||
<Box>
|
|
||||||
<Box minWidth={12} flexShrink={0}>
|
|
||||||
<Text> </Text>
|
|
||||||
</Box>
|
|
||||||
<Box flexGrow={1}>
|
|
||||||
<Text color={theme.text.secondary}>{badge}</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||||
const config = useContext(ConfigContext);
|
const config = useContext(ConfigContext);
|
||||||
const uiState = useContext(UIStateContext);
|
|
||||||
const settings = useSettings();
|
|
||||||
|
|
||||||
// Local error state for displaying errors within the dialog
|
// Get auth type from config, default to QWEN_OAUTH if not available
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const authType = config?.getAuthType() ?? AuthType.QWEN_OAUTH;
|
||||||
|
|
||||||
const authType = config?.getAuthType();
|
// Get available models based on auth type
|
||||||
const effectiveConfig =
|
const availableModels = useMemo(
|
||||||
(config?.getContentGeneratorConfig?.() as
|
() => getAvailableModelsForAuthType(authType),
|
||||||
| ContentGeneratorConfig
|
[authType],
|
||||||
| undefined) ?? undefined;
|
);
|
||||||
const sources = readSourcesFromConfig(config);
|
|
||||||
|
|
||||||
const availableModelEntries = useMemo(() => {
|
|
||||||
const allAuthTypes = Object.values(AuthType) as AuthType[];
|
|
||||||
const modelsByAuthType = allAuthTypes
|
|
||||||
.map((t) => ({
|
|
||||||
authType: t,
|
|
||||||
models: getAvailableModelsForAuthType(t, config ?? undefined),
|
|
||||||
}))
|
|
||||||
.filter((x) => x.models.length > 0);
|
|
||||||
|
|
||||||
// Fixed order: qwen-oauth first, then others in a stable order
|
|
||||||
const authTypeOrder: AuthType[] = [
|
|
||||||
AuthType.QWEN_OAUTH,
|
|
||||||
AuthType.USE_OPENAI,
|
|
||||||
AuthType.USE_ANTHROPIC,
|
|
||||||
AuthType.USE_GEMINI,
|
|
||||||
AuthType.USE_VERTEX_AI,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Filter to only include authTypes that have models
|
|
||||||
const availableAuthTypes = new Set(modelsByAuthType.map((x) => x.authType));
|
|
||||||
const orderedAuthTypes = authTypeOrder.filter((t) =>
|
|
||||||
availableAuthTypes.has(t),
|
|
||||||
);
|
|
||||||
|
|
||||||
return orderedAuthTypes.flatMap((t) => {
|
|
||||||
const models =
|
|
||||||
modelsByAuthType.find((x) => x.authType === t)?.models ?? [];
|
|
||||||
return models.map((m) => ({ authType: t, model: m }));
|
|
||||||
});
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const MODEL_OPTIONS = useMemo(
|
const MODEL_OPTIONS = useMemo(
|
||||||
() =>
|
() =>
|
||||||
availableModelEntries.map(({ authType: t2, model }) => {
|
availableModels.map((model) => ({
|
||||||
const value = `${t2}::${model.id}`;
|
value: model.id,
|
||||||
const title = (
|
title: model.label,
|
||||||
<Text>
|
description: model.description || '',
|
||||||
<Text bold color={theme.text.accent}>
|
key: model.id,
|
||||||
[{t2}]
|
})),
|
||||||
</Text>
|
[availableModels],
|
||||||
<Text>{` ${model.label}`}</Text>
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
const description = model.description || '';
|
|
||||||
return {
|
|
||||||
value,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
key: value,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
[availableModelEntries],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const preferredModelId = config?.getModel() || MAINLINE_CODER;
|
// Determine the Preferred Model (read once when the dialog opens).
|
||||||
const preferredKey = authType ? `${authType}::${preferredModelId}` : '';
|
const preferredModel = config?.getModel() || MAINLINE_CODER;
|
||||||
|
|
||||||
useKeypress(
|
useKeypress(
|
||||||
(key) => {
|
(key) => {
|
||||||
@@ -219,83 +61,25 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
|||||||
{ isActive: true },
|
{ isActive: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const initialIndex = useMemo(() => {
|
// Calculate the initial index based on the preferred model.
|
||||||
const index = MODEL_OPTIONS.findIndex(
|
const initialIndex = useMemo(
|
||||||
(option) => option.value === preferredKey,
|
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel),
|
||||||
);
|
[MODEL_OPTIONS, preferredModel],
|
||||||
return index === -1 ? 0 : index;
|
);
|
||||||
}, [MODEL_OPTIONS, preferredKey]);
|
|
||||||
|
|
||||||
|
// Handle selection internally (Autonomous Dialog).
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
async (selected: string) => {
|
(model: string) => {
|
||||||
// Clear any previous error
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
const sep = '::';
|
|
||||||
const idx = selected.indexOf(sep);
|
|
||||||
const selectedAuthType = (
|
|
||||||
idx >= 0 ? selected.slice(0, idx) : authType
|
|
||||||
) as AuthType;
|
|
||||||
const modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected;
|
|
||||||
|
|
||||||
if (config) {
|
if (config) {
|
||||||
try {
|
config.setModel(model);
|
||||||
await config.switchModel(
|
const event = new ModelSlashCommandEvent(model);
|
||||||
selectedAuthType,
|
|
||||||
modelId,
|
|
||||||
selectedAuthType !== authType &&
|
|
||||||
selectedAuthType === AuthType.QWEN_OAUTH
|
|
||||||
? { requireCachedCredentials: true }
|
|
||||||
: undefined,
|
|
||||||
{
|
|
||||||
reason: 'user_manual',
|
|
||||||
context:
|
|
||||||
selectedAuthType === authType
|
|
||||||
? 'Model switched via /model dialog'
|
|
||||||
: 'AuthType+model switched via /model dialog',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
const baseErrorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
setErrorMessage(
|
|
||||||
`Failed to switch model to '${modelId}'.\n\n${baseErrorMessage}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const event = new ModelSlashCommandEvent(modelId);
|
|
||||||
logModelSlashCommand(config, event);
|
logModelSlashCommand(config, event);
|
||||||
|
|
||||||
const after = config.getContentGeneratorConfig?.() as
|
|
||||||
| ContentGeneratorConfig
|
|
||||||
| undefined;
|
|
||||||
const effectiveAuthType =
|
|
||||||
after?.authType ?? selectedAuthType ?? authType;
|
|
||||||
const effectiveModelId = after?.model ?? modelId;
|
|
||||||
|
|
||||||
persistModelSelection(settings, effectiveModelId);
|
|
||||||
persistAuthTypeSelection(settings, effectiveAuthType);
|
|
||||||
|
|
||||||
const baseUrl = after?.baseUrl ?? '(default)';
|
|
||||||
const maskedKey = maskApiKey(after?.apiKey);
|
|
||||||
uiState?.historyManager.addItem(
|
|
||||||
{
|
|
||||||
type: 'info',
|
|
||||||
text:
|
|
||||||
`authType: ${effectiveAuthType}\n` +
|
|
||||||
`Using model: ${effectiveModelId}\n` +
|
|
||||||
`Base URL: ${baseUrl}\n` +
|
|
||||||
`API key: ${maskedKey}`,
|
|
||||||
},
|
|
||||||
Date.now(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
[authType, config, onClose, settings, uiState, setErrorMessage],
|
[config, onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasModels = MODEL_OPTIONS.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
@@ -305,73 +89,14 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
|||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
<Text bold>{t('Select Model')}</Text>
|
<Text bold>{t('Select Model')}</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
<Box marginTop={1} flexDirection="column">
|
<DescriptiveRadioButtonSelect
|
||||||
<Text color={theme.text.secondary}>
|
items={MODEL_OPTIONS}
|
||||||
{t('Current (effective) configuration')}
|
onSelect={handleSelect}
|
||||||
</Text>
|
initialIndex={initialIndex}
|
||||||
<Box flexDirection="column" marginTop={1}>
|
showNumbers={true}
|
||||||
<ConfigRow label="AuthType" value={authType} />
|
/>
|
||||||
<ConfigRow
|
|
||||||
label="Model"
|
|
||||||
value={effectiveConfig?.model ?? config?.getModel?.() ?? ''}
|
|
||||||
badge={formatSourceBadge(sources['model'])}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{authType !== AuthType.QWEN_OAUTH && (
|
|
||||||
<>
|
|
||||||
<ConfigRow
|
|
||||||
label="Base URL"
|
|
||||||
value={effectiveConfig?.baseUrl ?? ''}
|
|
||||||
badge={formatSourceBadge(sources['baseUrl'])}
|
|
||||||
/>
|
|
||||||
<ConfigRow
|
|
||||||
label="API Key"
|
|
||||||
value={effectiveConfig?.apiKey ? t('(set)') : t('(not set)')}
|
|
||||||
badge={formatSourceBadge(sources['apiKey'])}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!hasModels ? (
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
|
||||||
<Text color={theme.status.warning}>
|
|
||||||
{t(
|
|
||||||
'No models available for the current authentication type ({{authType}}).',
|
|
||||||
{
|
|
||||||
authType: authType ? String(authType) : t('(none)'),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={theme.text.secondary}>
|
|
||||||
{t(
|
|
||||||
'Please configure models in settings.modelProviders or use environment variables.',
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<DescriptiveRadioButtonSelect
|
|
||||||
items={MODEL_OPTIONS}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
initialIndex={initialIndex}
|
|
||||||
showNumbers={true}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{errorMessage && (
|
|
||||||
<Box marginTop={1} flexDirection="column" paddingX={1}>
|
|
||||||
<Text color={theme.status.error} wrap="wrap">
|
|
||||||
✕ {errorMessage}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text>
|
<Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -87,13 +87,7 @@ export async function showResumeSessionPicker(
|
|||||||
let selectedId: string | undefined;
|
let selectedId: string | undefined;
|
||||||
|
|
||||||
const { unmount, waitUntilExit } = render(
|
const { unmount, waitUntilExit } = render(
|
||||||
<KeypressProvider
|
<KeypressProvider kittyProtocolEnabled={false}>
|
||||||
kittyProtocolEnabled={false}
|
|
||||||
pasteWorkaround={
|
|
||||||
process.platform === 'win32' ||
|
|
||||||
parseInt(process.versions.node.split('.')[0], 10) < 20
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<StandalonePickerScreen
|
<StandalonePickerScreen
|
||||||
sessionService={sessionService}
|
sessionService={sessionService}
|
||||||
onSelect={(id) => {
|
onSelect={(id) => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { BaseSelectionList } from './BaseSelectionList.js';
|
|||||||
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||||
|
|
||||||
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
|
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
|
||||||
title: React.ReactNode;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface UIActions {
|
|||||||
) => void;
|
) => void;
|
||||||
handleAuthSelect: (
|
handleAuthSelect: (
|
||||||
authType: AuthType | undefined,
|
authType: AuthType | undefined,
|
||||||
|
scope: SettingScope,
|
||||||
credentials?: OpenAICredentials,
|
credentials?: OpenAICredentials,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
setAuthState: (state: AuthState) => void;
|
setAuthState: (state: AuthState) => void;
|
||||||
|
|||||||
@@ -520,13 +520,6 @@ export const useSlashCommandProcessor = (
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'stream_messages': {
|
|
||||||
// stream_messages is only used in ACP/Zed integration mode
|
|
||||||
// and should not be returned in interactive UI mode
|
|
||||||
throw new Error(
|
|
||||||
'stream_messages result type is not supported in interactive mode',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
default: {
|
default: {
|
||||||
const unhandled: never = result;
|
const unhandled: never = result;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface DialogCloseOptions {
|
|||||||
isAuthDialogOpen: boolean;
|
isAuthDialogOpen: boolean;
|
||||||
handleAuthSelect: (
|
handleAuthSelect: (
|
||||||
authType: AuthType | undefined,
|
authType: AuthType | undefined,
|
||||||
|
scope: SettingScope,
|
||||||
credentials?: OpenAICredentials,
|
credentials?: OpenAICredentials,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
pendingAuthType: AuthType | undefined;
|
pendingAuthType: AuthType | undefined;
|
||||||
|
|||||||
@@ -912,7 +912,7 @@ export const useGeminiStream = (
|
|||||||
// Reset quota error flag when starting a new query (not a continuation)
|
// Reset quota error flag when starting a new query (not a continuation)
|
||||||
if (!options?.isContinuation) {
|
if (!options?.isContinuation) {
|
||||||
setModelSwitchedFromQuotaError(false);
|
setModelSwitchedFromQuotaError(false);
|
||||||
// No quota-error / fallback routing mechanism currently; keep state minimal.
|
config.setQuotaErrorOccurred(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
abortControllerRef.current = new AbortController();
|
abortControllerRef.current = new AbortController();
|
||||||
|
|||||||
@@ -1,58 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useStdin } from 'ink';
|
import { useStdin } from 'ink';
|
||||||
import type { EditorType } from '@qwen-code/qwen-code-core';
|
import type { EditorType } from '@qwen-code/qwen-code-core';
|
||||||
import {
|
|
||||||
editorCommands,
|
|
||||||
commandExists as coreCommandExists,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import { spawnSync } from 'child_process';
|
import { spawnSync } from 'child_process';
|
||||||
import { useSettings } from '../contexts/SettingsContext.js';
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache for command existence checks to avoid repeated execSync calls.
|
|
||||||
*/
|
|
||||||
const commandExistsCache = new Map<string, boolean>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a command exists in the system with caching.
|
|
||||||
* Results are cached to improve performance in test environments.
|
|
||||||
*/
|
|
||||||
function commandExists(cmd: string): boolean {
|
|
||||||
if (commandExistsCache.has(cmd)) {
|
|
||||||
return commandExistsCache.get(cmd)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exists = coreCommandExists(cmd);
|
|
||||||
commandExistsCache.set(cmd, exists);
|
|
||||||
return exists;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get the actual executable command for an editor type.
|
|
||||||
*/
|
|
||||||
function getExecutableCommand(editorType: EditorType): string {
|
|
||||||
const commandConfig = editorCommands[editorType];
|
|
||||||
const commands =
|
|
||||||
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
|
|
||||||
|
|
||||||
const availableCommand = commands.find((cmd) => commandExists(cmd));
|
|
||||||
|
|
||||||
if (!availableCommand) {
|
|
||||||
throw new Error(
|
|
||||||
`No available editor command found for ${editorType}. ` +
|
|
||||||
`Tried: ${commands.join(', ')}. ` +
|
|
||||||
`Please install one of these editors or set a different preferredEditor in settings.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return availableCommand;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines the editor command to use based on user preferences and platform.
|
* Determines the editor command to use based on user preferences and platform.
|
||||||
*/
|
*/
|
||||||
function getEditorCommand(preferredEditor?: EditorType): string {
|
function getEditorCommand(preferredEditor?: EditorType): string {
|
||||||
if (preferredEditor) {
|
if (preferredEditor) {
|
||||||
return getExecutableCommand(preferredEditor);
|
return preferredEditor;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platform-specific defaults with UI preference for macOS
|
// Platform-specific defaults with UI preference for macOS
|
||||||
@@ -100,14 +63,8 @@ export function useLaunchEditor() {
|
|||||||
try {
|
try {
|
||||||
setRawMode?.(false);
|
setRawMode?.(false);
|
||||||
|
|
||||||
// On Windows, .cmd and .bat files need shell: true
|
|
||||||
const needsShell =
|
|
||||||
process.platform === 'win32' &&
|
|
||||||
(editorCommand.endsWith('.cmd') || editorCommand.endsWith('.bat'));
|
|
||||||
|
|
||||||
const { status, error } = spawnSync(editorCommand, editorArgs, {
|
const { status, error } = spawnSync(editorCommand, editorArgs, {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
shell: needsShell,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|||||||
@@ -8,22 +8,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||||||
import { renderHook, act } from '@testing-library/react';
|
import { renderHook, act } from '@testing-library/react';
|
||||||
import { useLoadingIndicator } from './useLoadingIndicator.js';
|
import { useLoadingIndicator } from './useLoadingIndicator.js';
|
||||||
import { StreamingState } from '../types.js';
|
import { StreamingState } from '../types.js';
|
||||||
import { PHRASE_CHANGE_INTERVAL_MS } from './usePhraseCycler.js';
|
import {
|
||||||
import * as i18n from '../../i18n/index.js';
|
WITTY_LOADING_PHRASES,
|
||||||
|
PHRASE_CHANGE_INTERVAL_MS,
|
||||||
const MOCK_WITTY_PHRASES = ['Phrase 1', 'Phrase 2', 'Phrase 3'];
|
} from './usePhraseCycler.js';
|
||||||
|
|
||||||
describe('useLoadingIndicator', () => {
|
describe('useLoadingIndicator', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.spyOn(i18n, 'ta').mockReturnValue(MOCK_WITTY_PHRASES);
|
|
||||||
vi.spyOn(i18n, 't').mockImplementation((key) => key);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers(); // Restore real timers after each test
|
vi.useRealTimers(); // Restore real timers after each test
|
||||||
act(() => vi.runOnlyPendingTimers);
|
act(() => vi.runOnlyPendingTimers);
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should initialize with default values when Idle', () => {
|
it('should initialize with default values when Idle', () => {
|
||||||
@@ -31,7 +28,9 @@ describe('useLoadingIndicator', () => {
|
|||||||
useLoadingIndicator(StreamingState.Idle),
|
useLoadingIndicator(StreamingState.Idle),
|
||||||
);
|
);
|
||||||
expect(result.current.elapsedTime).toBe(0);
|
expect(result.current.elapsedTime).toBe(0);
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
|
expect(WITTY_LOADING_PHRASES).toContain(
|
||||||
|
result.current.currentLoadingPhrase,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reflect values when Responding', async () => {
|
it('should reflect values when Responding', async () => {
|
||||||
@@ -41,14 +40,18 @@ describe('useLoadingIndicator', () => {
|
|||||||
|
|
||||||
// Initial state before timers advance
|
// Initial state before timers advance
|
||||||
expect(result.current.elapsedTime).toBe(0);
|
expect(result.current.elapsedTime).toBe(0);
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
|
expect(WITTY_LOADING_PHRASES).toContain(
|
||||||
|
result.current.currentLoadingPhrase,
|
||||||
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 1);
|
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Phrase should cycle if PHRASE_CHANGE_INTERVAL_MS has passed
|
// Phrase should cycle if PHRASE_CHANGE_INTERVAL_MS has passed
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
|
expect(WITTY_LOADING_PHRASES).toContain(
|
||||||
|
result.current.currentLoadingPhrase,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show waiting phrase and retain elapsedTime when WaitingForConfirmation', async () => {
|
it('should show waiting phrase and retain elapsedTime when WaitingForConfirmation', async () => {
|
||||||
@@ -101,7 +104,9 @@ describe('useLoadingIndicator', () => {
|
|||||||
rerender({ streamingState: StreamingState.Responding });
|
rerender({ streamingState: StreamingState.Responding });
|
||||||
});
|
});
|
||||||
expect(result.current.elapsedTime).toBe(0); // Should reset
|
expect(result.current.elapsedTime).toBe(0); // Should reset
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
|
expect(WITTY_LOADING_PHRASES).toContain(
|
||||||
|
result.current.currentLoadingPhrase,
|
||||||
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
@@ -125,7 +130,9 @@ describe('useLoadingIndicator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.elapsedTime).toBe(0);
|
expect(result.current.elapsedTime).toBe(0);
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
|
expect(WITTY_LOADING_PHRASES).toContain(
|
||||||
|
result.current.currentLoadingPhrase,
|
||||||
|
);
|
||||||
|
|
||||||
// Timer should not advance
|
// Timer should not advance
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -8,17 +8,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||||||
import { renderHook, act } from '@testing-library/react';
|
import { renderHook, act } from '@testing-library/react';
|
||||||
import {
|
import {
|
||||||
usePhraseCycler,
|
usePhraseCycler,
|
||||||
|
WITTY_LOADING_PHRASES,
|
||||||
PHRASE_CHANGE_INTERVAL_MS,
|
PHRASE_CHANGE_INTERVAL_MS,
|
||||||
} from './usePhraseCycler.js';
|
} from './usePhraseCycler.js';
|
||||||
import * as i18n from '../../i18n/index.js';
|
|
||||||
|
|
||||||
const MOCK_WITTY_PHRASES = ['Phrase 1', 'Phrase 2', 'Phrase 3'];
|
|
||||||
|
|
||||||
describe('usePhraseCycler', () => {
|
describe('usePhraseCycler', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.spyOn(i18n, 'ta').mockReturnValue(MOCK_WITTY_PHRASES);
|
|
||||||
vi.spyOn(i18n, 't').mockImplementation((key) => key);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -27,7 +23,7 @@ describe('usePhraseCycler', () => {
|
|||||||
|
|
||||||
it('should initialize with a witty phrase when not active and not waiting', () => {
|
it('should initialize with a witty phrase when not active and not waiting', () => {
|
||||||
const { result } = renderHook(() => usePhraseCycler(false, false));
|
const { result } = renderHook(() => usePhraseCycler(false, false));
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(result.current);
|
expect(WITTY_LOADING_PHRASES).toContain(result.current);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show "Waiting for user confirmation..." when isWaiting is true', () => {
|
it('should show "Waiting for user confirmation..." when isWaiting is true', () => {
|
||||||
@@ -51,30 +47,35 @@ describe('usePhraseCycler', () => {
|
|||||||
it('should cycle through witty phrases when isActive is true and not waiting', () => {
|
it('should cycle through witty phrases when isActive is true and not waiting', () => {
|
||||||
const { result } = renderHook(() => usePhraseCycler(true, false));
|
const { result } = renderHook(() => usePhraseCycler(true, false));
|
||||||
// Initial phrase should be one of the witty phrases
|
// Initial phrase should be one of the witty phrases
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(result.current);
|
expect(WITTY_LOADING_PHRASES).toContain(result.current);
|
||||||
const _initialPhrase = result.current;
|
const _initialPhrase = result.current;
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
|
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
|
||||||
});
|
});
|
||||||
// Phrase should change and be one of the witty phrases
|
// Phrase should change and be one of the witty phrases
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(result.current);
|
expect(WITTY_LOADING_PHRASES).toContain(result.current);
|
||||||
|
|
||||||
const _secondPhrase = result.current;
|
const _secondPhrase = result.current;
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
|
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
|
||||||
});
|
});
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(result.current);
|
expect(WITTY_LOADING_PHRASES).toContain(result.current);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reset to a witty phrase when isActive becomes true after being false (and not waiting)', () => {
|
it('should reset to a witty phrase when isActive becomes true after being false (and not waiting)', () => {
|
||||||
|
// Ensure there are at least two phrases for this test to be meaningful.
|
||||||
|
if (WITTY_LOADING_PHRASES.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Mock Math.random to make the test deterministic.
|
// Mock Math.random to make the test deterministic.
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
vi.spyOn(Math, 'random').mockImplementation(() => {
|
vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||||
// Cycle through 0, 1, 0, 1, ...
|
// Cycle through 0, 1, 0, 1, ...
|
||||||
const val = callCount % 2;
|
const val = callCount % 2;
|
||||||
callCount++;
|
callCount++;
|
||||||
return val / MOCK_WITTY_PHRASES.length;
|
return val / WITTY_LOADING_PHRASES.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
@@ -85,9 +86,9 @@ describe('usePhraseCycler', () => {
|
|||||||
// Activate
|
// Activate
|
||||||
rerender({ isActive: true, isWaiting: false });
|
rerender({ isActive: true, isWaiting: false });
|
||||||
const firstActivePhrase = result.current;
|
const firstActivePhrase = result.current;
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(firstActivePhrase);
|
expect(WITTY_LOADING_PHRASES).toContain(firstActivePhrase);
|
||||||
// With our mock, this should be the first phrase.
|
// With our mock, this should be the first phrase.
|
||||||
expect(firstActivePhrase).toBe(MOCK_WITTY_PHRASES[0]);
|
expect(firstActivePhrase).toBe(WITTY_LOADING_PHRASES[0]);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
|
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
|
||||||
@@ -95,18 +96,18 @@ describe('usePhraseCycler', () => {
|
|||||||
|
|
||||||
// Phrase should change to the second phrase.
|
// Phrase should change to the second phrase.
|
||||||
expect(result.current).not.toBe(firstActivePhrase);
|
expect(result.current).not.toBe(firstActivePhrase);
|
||||||
expect(result.current).toBe(MOCK_WITTY_PHRASES[1]);
|
expect(result.current).toBe(WITTY_LOADING_PHRASES[1]);
|
||||||
|
|
||||||
// Set to inactive - should reset to the default initial phrase
|
// Set to inactive - should reset to the default initial phrase
|
||||||
rerender({ isActive: false, isWaiting: false });
|
rerender({ isActive: false, isWaiting: false });
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(result.current);
|
expect(WITTY_LOADING_PHRASES).toContain(result.current);
|
||||||
|
|
||||||
// Set back to active - should pick a random witty phrase (which our mock controls)
|
// Set back to active - should pick a random witty phrase (which our mock controls)
|
||||||
act(() => {
|
act(() => {
|
||||||
rerender({ isActive: true, isWaiting: false });
|
rerender({ isActive: true, isWaiting: false });
|
||||||
});
|
});
|
||||||
// The random mock will now return 0, so it should be the first phrase again.
|
// The random mock will now return 0, so it should be the first phrase again.
|
||||||
expect(result.current).toBe(MOCK_WITTY_PHRASES[0]);
|
expect(result.current).toBe(WITTY_LOADING_PHRASES[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear phrase interval on unmount when active', () => {
|
it('should clear phrase interval on unmount when active', () => {
|
||||||
@@ -147,7 +148,7 @@ describe('usePhraseCycler', () => {
|
|||||||
|
|
||||||
rerender({ isActive: true, isWaiting: false, customPhrases: undefined });
|
rerender({ isActive: true, isWaiting: false, customPhrases: undefined });
|
||||||
|
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(result.current);
|
expect(WITTY_LOADING_PHRASES).toContain(result.current);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fall back to witty phrases if custom phrases are an empty array', () => {
|
it('should fall back to witty phrases if custom phrases are an empty array', () => {
|
||||||
@@ -163,7 +164,7 @@ describe('usePhraseCycler', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(result.current);
|
expect(WITTY_LOADING_PHRASES).toContain(result.current);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reset to a witty phrase when transitioning from waiting to active', () => {
|
it('should reset to a witty phrase when transitioning from waiting to active', () => {
|
||||||
@@ -173,13 +174,16 @@ describe('usePhraseCycler', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const _initialPhrase = result.current;
|
const _initialPhrase = result.current;
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(_initialPhrase);
|
expect(WITTY_LOADING_PHRASES).toContain(_initialPhrase);
|
||||||
|
|
||||||
// Cycle to a different phrase (potentially)
|
// Cycle to a different phrase (potentially)
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
|
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
|
||||||
});
|
});
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(result.current);
|
if (WITTY_LOADING_PHRASES.length > 1) {
|
||||||
|
// This check is probabilistic with random selection
|
||||||
|
}
|
||||||
|
expect(WITTY_LOADING_PHRASES).toContain(result.current);
|
||||||
|
|
||||||
// Go to waiting state
|
// Go to waiting state
|
||||||
rerender({ isActive: false, isWaiting: true });
|
rerender({ isActive: false, isWaiting: true });
|
||||||
@@ -187,6 +191,6 @@ describe('usePhraseCycler', () => {
|
|||||||
|
|
||||||
// Go back to active cycling - should pick a random witty phrase
|
// Go back to active cycling - should pick a random witty phrase
|
||||||
rerender({ isActive: true, isWaiting: false });
|
rerender({ isActive: true, isWaiting: false });
|
||||||
expect(MOCK_WITTY_PHRASES).toContain(result.current);
|
expect(WITTY_LOADING_PHRASES).toContain(result.current);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,139 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { t, ta } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const WITTY_LOADING_PHRASES: string[] = ["I'm Feeling Lucky"];
|
export const WITTY_LOADING_PHRASES = [
|
||||||
|
"I'm Feeling Lucky",
|
||||||
|
'Shipping awesomeness... ',
|
||||||
|
'Painting the serifs back on...',
|
||||||
|
'Navigating the slime mold...',
|
||||||
|
'Consulting the digital spirits...',
|
||||||
|
'Reticulating splines...',
|
||||||
|
'Warming up the AI hamsters...',
|
||||||
|
'Asking the magic conch shell...',
|
||||||
|
'Generating witty retort...',
|
||||||
|
'Polishing the algorithms...',
|
||||||
|
"Don't rush perfection (or my code)...",
|
||||||
|
'Brewing fresh bytes...',
|
||||||
|
'Counting electrons...',
|
||||||
|
'Engaging cognitive processors...',
|
||||||
|
'Checking for syntax errors in the universe...',
|
||||||
|
'One moment, optimizing humor...',
|
||||||
|
'Shuffling punchlines...',
|
||||||
|
'Untangling neural nets...',
|
||||||
|
'Compiling brilliance...',
|
||||||
|
'Loading wit.exe...',
|
||||||
|
'Summoning the cloud of wisdom...',
|
||||||
|
'Preparing a witty response...',
|
||||||
|
"Just a sec, I'm debugging reality...",
|
||||||
|
'Confuzzling the options...',
|
||||||
|
'Tuning the cosmic frequencies...',
|
||||||
|
'Crafting a response worthy of your patience...',
|
||||||
|
'Compiling the 1s and 0s...',
|
||||||
|
'Resolving dependencies... and existential crises...',
|
||||||
|
'Defragmenting memories... both RAM and personal...',
|
||||||
|
'Rebooting the humor module...',
|
||||||
|
'Caching the essentials (mostly cat memes)...',
|
||||||
|
'Optimizing for ludicrous speed',
|
||||||
|
"Swapping bits... don't tell the bytes...",
|
||||||
|
'Garbage collecting... be right back...',
|
||||||
|
'Assembling the interwebs...',
|
||||||
|
'Converting coffee into code...',
|
||||||
|
'Updating the syntax for reality...',
|
||||||
|
'Rewiring the synapses...',
|
||||||
|
'Looking for a misplaced semicolon...',
|
||||||
|
"Greasin' the cogs of the machine...",
|
||||||
|
'Pre-heating the servers...',
|
||||||
|
'Calibrating the flux capacitor...',
|
||||||
|
'Engaging the improbability drive...',
|
||||||
|
'Channeling the Force...',
|
||||||
|
'Aligning the stars for optimal response...',
|
||||||
|
'So say we all...',
|
||||||
|
'Loading the next great idea...',
|
||||||
|
"Just a moment, I'm in the zone...",
|
||||||
|
'Preparing to dazzle you with brilliance...',
|
||||||
|
"Just a tick, I'm polishing my wit...",
|
||||||
|
"Hold tight, I'm crafting a masterpiece...",
|
||||||
|
"Just a jiffy, I'm debugging the universe...",
|
||||||
|
"Just a moment, I'm aligning the pixels...",
|
||||||
|
"Just a sec, I'm optimizing the humor...",
|
||||||
|
"Just a moment, I'm tuning the algorithms...",
|
||||||
|
'Warp speed engaged...',
|
||||||
|
'Mining for more Dilithium crystals...',
|
||||||
|
"Don't panic...",
|
||||||
|
'Following the white rabbit...',
|
||||||
|
'The truth is in here... somewhere...',
|
||||||
|
'Blowing on the cartridge...',
|
||||||
|
'Loading... Do a barrel roll!',
|
||||||
|
'Waiting for the respawn...',
|
||||||
|
'Finishing the Kessel Run in less than 12 parsecs...',
|
||||||
|
"The cake is not a lie, it's just still loading...",
|
||||||
|
'Fiddling with the character creation screen...',
|
||||||
|
"Just a moment, I'm finding the right meme...",
|
||||||
|
"Pressing 'A' to continue...",
|
||||||
|
'Herding digital cats...',
|
||||||
|
'Polishing the pixels...',
|
||||||
|
'Finding a suitable loading screen pun...',
|
||||||
|
'Distracting you with this witty phrase...',
|
||||||
|
'Almost there... probably...',
|
||||||
|
'Our hamsters are working as fast as they can...',
|
||||||
|
'Giving Cloudy a pat on the head...',
|
||||||
|
'Petting the cat...',
|
||||||
|
'Rickrolling my boss...',
|
||||||
|
'Never gonna give you up, never gonna let you down...',
|
||||||
|
'Slapping the bass...',
|
||||||
|
'Tasting the snozberries...',
|
||||||
|
"I'm going the distance, I'm going for speed...",
|
||||||
|
'Is this the real life? Is this just fantasy?...',
|
||||||
|
"I've got a good feeling about this...",
|
||||||
|
'Poking the bear...',
|
||||||
|
'Doing research on the latest memes...',
|
||||||
|
'Figuring out how to make this more witty...',
|
||||||
|
'Hmmm... let me think...',
|
||||||
|
'What do you call a fish with no eyes? A fsh...',
|
||||||
|
'Why did the computer go to therapy? It had too many bytes...',
|
||||||
|
"Why don't programmers like nature? It has too many bugs...",
|
||||||
|
'Why do programmers prefer dark mode? Because light attracts bugs...',
|
||||||
|
'Why did the developer go broke? Because they used up all their cache...',
|
||||||
|
"What can you do with a broken pencil? Nothing, it's pointless...",
|
||||||
|
'Applying percussive maintenance...',
|
||||||
|
'Searching for the correct USB orientation...',
|
||||||
|
'Ensuring the magic smoke stays inside the wires...',
|
||||||
|
'Rewriting in Rust for no particular reason...',
|
||||||
|
'Trying to exit Vim...',
|
||||||
|
'Spinning up the hamster wheel...',
|
||||||
|
"That's not a bug, it's an undocumented feature...",
|
||||||
|
'Engage.',
|
||||||
|
"I'll be back... with an answer.",
|
||||||
|
'My other process is a TARDIS...',
|
||||||
|
'Communing with the machine spirit...',
|
||||||
|
'Letting the thoughts marinate...',
|
||||||
|
'Just remembered where I put my keys...',
|
||||||
|
'Pondering the orb...',
|
||||||
|
"I've seen things you people wouldn't believe... like a user who reads loading messages.",
|
||||||
|
'Initiating thoughtful gaze...',
|
||||||
|
"What's a computer's favorite snack? Microchips.",
|
||||||
|
"Why do Java developers wear glasses? Because they don't C#.",
|
||||||
|
'Charging the laser... pew pew!',
|
||||||
|
'Dividing by zero... just kidding!',
|
||||||
|
'Looking for an adult superviso... I mean, processing.',
|
||||||
|
'Making it go beep boop.',
|
||||||
|
'Buffering... because even AIs need a moment.',
|
||||||
|
'Entangling quantum particles for a faster response...',
|
||||||
|
'Polishing the chrome... on the algorithms.',
|
||||||
|
'Are you not entertained? (Working on it!)',
|
||||||
|
'Summoning the code gremlins... to help, of course.',
|
||||||
|
'Just waiting for the dial-up tone to finish...',
|
||||||
|
'Recalibrating the humor-o-meter.',
|
||||||
|
'My other loading screen is even funnier.',
|
||||||
|
"Pretty sure there's a cat walking on the keyboard somewhere...",
|
||||||
|
'Enhancing... Enhancing... Still loading.',
|
||||||
|
"It's not a bug, it's a feature... of this loading screen.",
|
||||||
|
'Have you tried turning it off and on again? (The loading screen, not me.)',
|
||||||
|
'Constructing additional pylons...',
|
||||||
|
'New line? That’s Ctrl+J.',
|
||||||
|
];
|
||||||
|
|
||||||
export const PHRASE_CHANGE_INTERVAL_MS = 15000;
|
export const PHRASE_CHANGE_INTERVAL_MS = 15000;
|
||||||
|
|
||||||
@@ -22,16 +152,14 @@ export const usePhraseCycler = (
|
|||||||
isWaiting: boolean,
|
isWaiting: boolean,
|
||||||
customPhrases?: string[],
|
customPhrases?: string[],
|
||||||
) => {
|
) => {
|
||||||
// Get phrases from translations if available
|
// Translate all phrases at once if using default phrases
|
||||||
const loadingPhrases = useMemo(() => {
|
const loadingPhrases = useMemo(
|
||||||
if (customPhrases && customPhrases.length > 0) {
|
() =>
|
||||||
return customPhrases;
|
customPhrases && customPhrases.length > 0
|
||||||
}
|
? customPhrases
|
||||||
const translatedPhrases = ta('WITTY_LOADING_PHRASES');
|
: WITTY_LOADING_PHRASES.map((phrase) => t(phrase)),
|
||||||
return translatedPhrases.length > 0
|
[customPhrases],
|
||||||
? translatedPhrases
|
);
|
||||||
: WITTY_LOADING_PHRASES;
|
|
||||||
}, [customPhrases]);
|
|
||||||
|
|
||||||
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(
|
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(
|
||||||
loadingPhrases[0],
|
loadingPhrases[0],
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const mockConfig = {
|
|||||||
getAllowedTools: vi.fn(() => []),
|
getAllowedTools: vi.fn(() => []),
|
||||||
getContentGeneratorConfig: () => ({
|
getContentGeneratorConfig: () => ({
|
||||||
model: 'test-model',
|
model: 'test-model',
|
||||||
authType: 'gemini',
|
authType: 'gemini-api-key',
|
||||||
}),
|
}),
|
||||||
getUseSmartEdit: () => false,
|
getUseSmartEdit: () => false,
|
||||||
getUseModelRouter: () => false,
|
getUseModelRouter: () => false,
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Qwen Team
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import {
|
|
||||||
getAvailableModelsForAuthType,
|
|
||||||
getFilteredQwenModels,
|
|
||||||
getOpenAIAvailableModelFromEnv,
|
|
||||||
isVisionModel,
|
|
||||||
getDefaultVisionModel,
|
|
||||||
AVAILABLE_MODELS_QWEN,
|
|
||||||
MAINLINE_VLM,
|
|
||||||
MAINLINE_CODER,
|
|
||||||
} from './availableModels.js';
|
|
||||||
import { AuthType, type Config } from '@qwen-code/qwen-code-core';
|
|
||||||
|
|
||||||
describe('availableModels', () => {
|
|
||||||
describe('AVAILABLE_MODELS_QWEN', () => {
|
|
||||||
it('should include coder model', () => {
|
|
||||||
const coderModel = AVAILABLE_MODELS_QWEN.find(
|
|
||||||
(m) => m.id === MAINLINE_CODER,
|
|
||||||
);
|
|
||||||
expect(coderModel).toBeDefined();
|
|
||||||
expect(coderModel?.isVision).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include vision model', () => {
|
|
||||||
const visionModel = AVAILABLE_MODELS_QWEN.find(
|
|
||||||
(m) => m.id === MAINLINE_VLM,
|
|
||||||
);
|
|
||||||
expect(visionModel).toBeDefined();
|
|
||||||
expect(visionModel?.isVision).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getFilteredQwenModels', () => {
|
|
||||||
it('should return all models when vision preview is enabled', () => {
|
|
||||||
const models = getFilteredQwenModels(true);
|
|
||||||
expect(models.length).toBe(AVAILABLE_MODELS_QWEN.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter out vision models when preview is disabled', () => {
|
|
||||||
const models = getFilteredQwenModels(false);
|
|
||||||
expect(models.every((m) => !m.isVision)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getOpenAIAvailableModelFromEnv', () => {
|
|
||||||
const originalEnv = process.env;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = originalEnv;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when OPENAI_MODEL is not set', () => {
|
|
||||||
delete process.env['OPENAI_MODEL'];
|
|
||||||
expect(getOpenAIAvailableModelFromEnv()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return model from OPENAI_MODEL env var', () => {
|
|
||||||
process.env['OPENAI_MODEL'] = 'gpt-4-turbo';
|
|
||||||
const model = getOpenAIAvailableModelFromEnv();
|
|
||||||
expect(model?.id).toBe('gpt-4-turbo');
|
|
||||||
expect(model?.label).toBe('gpt-4-turbo');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should trim whitespace from env var', () => {
|
|
||||||
process.env['OPENAI_MODEL'] = ' gpt-4 ';
|
|
||||||
const model = getOpenAIAvailableModelFromEnv();
|
|
||||||
expect(model?.id).toBe('gpt-4');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getAvailableModelsForAuthType', () => {
|
|
||||||
const originalEnv = process.env;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = originalEnv;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return hard-coded qwen models for qwen-oauth', () => {
|
|
||||||
const models = getAvailableModelsForAuthType(AuthType.QWEN_OAUTH);
|
|
||||||
expect(models).toEqual(AVAILABLE_MODELS_QWEN);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return hard-coded qwen models even when config is provided', () => {
|
|
||||||
const mockConfig = {
|
|
||||||
getAvailableModels: vi
|
|
||||||
.fn()
|
|
||||||
.mockReturnValue([
|
|
||||||
{ id: 'custom', label: 'Custom', authType: AuthType.QWEN_OAUTH },
|
|
||||||
]),
|
|
||||||
} as unknown as Config;
|
|
||||||
|
|
||||||
const models = getAvailableModelsForAuthType(
|
|
||||||
AuthType.QWEN_OAUTH,
|
|
||||||
mockConfig,
|
|
||||||
);
|
|
||||||
expect(models).toEqual(AVAILABLE_MODELS_QWEN);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use config.getAvailableModels for openai authType when available', () => {
|
|
||||||
const mockModels = [
|
|
||||||
{
|
|
||||||
id: 'gpt-4',
|
|
||||||
label: 'GPT-4',
|
|
||||||
description: 'Test',
|
|
||||||
authType: AuthType.USE_OPENAI,
|
|
||||||
isVision: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const getAvailableModelsForAuthType = vi.fn().mockReturnValue(mockModels);
|
|
||||||
const mockConfigWithMethod = {
|
|
||||||
// Prefer the newer API when available.
|
|
||||||
getAvailableModelsForAuthType,
|
|
||||||
};
|
|
||||||
|
|
||||||
const models = getAvailableModelsForAuthType(
|
|
||||||
AuthType.USE_OPENAI,
|
|
||||||
mockConfigWithMethod as unknown as Config,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(getAvailableModelsForAuthType).toHaveBeenCalled();
|
|
||||||
expect(models[0].id).toBe('gpt-4');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fallback to env var for openai when config returns empty', () => {
|
|
||||||
process.env['OPENAI_MODEL'] = 'fallback-model';
|
|
||||||
const mockConfig = {
|
|
||||||
getAvailableModelsForAuthType: vi.fn().mockReturnValue([]),
|
|
||||||
} as unknown as Config;
|
|
||||||
|
|
||||||
const models = getAvailableModelsForAuthType(
|
|
||||||
AuthType.USE_OPENAI,
|
|
||||||
mockConfig,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(models).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fallback to env var for openai when config throws', () => {
|
|
||||||
process.env['OPENAI_MODEL'] = 'fallback-model';
|
|
||||||
const mockConfig = {
|
|
||||||
getAvailableModelsForAuthType: vi.fn().mockImplementation(() => {
|
|
||||||
throw new Error('Registry not initialized');
|
|
||||||
}),
|
|
||||||
} as unknown as Config;
|
|
||||||
|
|
||||||
const models = getAvailableModelsForAuthType(
|
|
||||||
AuthType.USE_OPENAI,
|
|
||||||
mockConfig,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(models).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return env model for openai without config', () => {
|
|
||||||
process.env['OPENAI_MODEL'] = 'gpt-4-turbo';
|
|
||||||
const models = getAvailableModelsForAuthType(AuthType.USE_OPENAI);
|
|
||||||
expect(models[0].id).toBe('gpt-4-turbo');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array for openai without config or env', () => {
|
|
||||||
delete process.env['OPENAI_MODEL'];
|
|
||||||
const models = getAvailableModelsForAuthType(AuthType.USE_OPENAI);
|
|
||||||
expect(models).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array for other auth types', () => {
|
|
||||||
const models = getAvailableModelsForAuthType(AuthType.USE_GEMINI);
|
|
||||||
expect(models).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isVisionModel', () => {
|
|
||||||
it('should return true for vision model', () => {
|
|
||||||
expect(isVisionModel(MAINLINE_VLM)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for non-vision model', () => {
|
|
||||||
expect(isVisionModel(MAINLINE_CODER)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for unknown model', () => {
|
|
||||||
expect(isVisionModel('unknown-model')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getDefaultVisionModel', () => {
|
|
||||||
it('should return the vision model ID', () => {
|
|
||||||
expect(getDefaultVisionModel()).toBe(MAINLINE_VLM);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,12 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core';
|
||||||
AuthType,
|
|
||||||
DEFAULT_QWEN_MODEL,
|
|
||||||
type Config,
|
|
||||||
type AvailableModel as CoreAvailableModel,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export type AvailableModel = {
|
export type AvailableModel = {
|
||||||
@@ -62,78 +57,20 @@ export function getFilteredQwenModels(
|
|||||||
*/
|
*/
|
||||||
export function getOpenAIAvailableModelFromEnv(): AvailableModel | null {
|
export function getOpenAIAvailableModelFromEnv(): AvailableModel | null {
|
||||||
const id = process.env['OPENAI_MODEL']?.trim();
|
const id = process.env['OPENAI_MODEL']?.trim();
|
||||||
return id
|
return id ? { id, label: id } : null;
|
||||||
? {
|
|
||||||
id,
|
|
||||||
label: id,
|
|
||||||
get description() {
|
|
||||||
return t('Configured via OPENAI_MODEL environment variable');
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnthropicAvailableModelFromEnv(): AvailableModel | null {
|
export function getAnthropicAvailableModelFromEnv(): AvailableModel | null {
|
||||||
const id = process.env['ANTHROPIC_MODEL']?.trim();
|
const id = process.env['ANTHROPIC_MODEL']?.trim();
|
||||||
return id
|
return id ? { id, label: id } : null;
|
||||||
? {
|
|
||||||
id,
|
|
||||||
label: id,
|
|
||||||
get description() {
|
|
||||||
return t('Configured via ANTHROPIC_MODEL environment variable');
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert core AvailableModel to CLI AvailableModel format
|
|
||||||
*/
|
|
||||||
function convertCoreModelToCliModel(
|
|
||||||
coreModel: CoreAvailableModel,
|
|
||||||
): AvailableModel {
|
|
||||||
return {
|
|
||||||
id: coreModel.id,
|
|
||||||
label: coreModel.label,
|
|
||||||
description: coreModel.description,
|
|
||||||
isVision: coreModel.isVision ?? coreModel.capabilities?.vision ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get available models for the given authType.
|
|
||||||
*
|
|
||||||
* If a Config object is provided, uses config.getAvailableModelsForAuthType().
|
|
||||||
* For qwen-oauth, always returns the hard-coded models.
|
|
||||||
* Falls back to environment variables only when no config is provided.
|
|
||||||
*/
|
|
||||||
export function getAvailableModelsForAuthType(
|
export function getAvailableModelsForAuthType(
|
||||||
authType: AuthType,
|
authType: AuthType,
|
||||||
config?: Config,
|
|
||||||
): AvailableModel[] {
|
): AvailableModel[] {
|
||||||
// For qwen-oauth, always use hard-coded models, this aligns with the API gateway.
|
|
||||||
if (authType === AuthType.QWEN_OAUTH) {
|
|
||||||
return AVAILABLE_MODELS_QWEN;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use config's model registry when available
|
|
||||||
if (config) {
|
|
||||||
try {
|
|
||||||
const models = config.getAvailableModelsForAuthType(authType);
|
|
||||||
if (models.length > 0) {
|
|
||||||
return models.map(convertCoreModelToCliModel);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If config throws (e.g., not initialized), return empty array
|
|
||||||
}
|
|
||||||
// When a Config object is provided, we intentionally do NOT fall back to env-based
|
|
||||||
// "raw" models. These may reflect the currently effective config but should not be
|
|
||||||
// presented as selectable options in /model.
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to environment variables for specific auth types (no config provided)
|
|
||||||
switch (authType) {
|
switch (authType) {
|
||||||
|
case AuthType.QWEN_OAUTH:
|
||||||
|
return AVAILABLE_MODELS_QWEN;
|
||||||
case AuthType.USE_OPENAI: {
|
case AuthType.USE_OPENAI: {
|
||||||
const openAIModel = getOpenAIAvailableModelFromEnv();
|
const openAIModel = getOpenAIAvailableModelFromEnv();
|
||||||
return openAIModel ? [openAIModel] : [];
|
return openAIModel ? [openAIModel] : [];
|
||||||
@@ -143,10 +80,13 @@ export function getAvailableModelsForAuthType(
|
|||||||
return anthropicModel ? [anthropicModel] : [];
|
return anthropicModel ? [anthropicModel] : [];
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
// For other auth types, return empty array for now
|
||||||
|
// This can be expanded later according to the design doc
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
/**
|
||||||
* Hard code the default vision model as a string literal,
|
* Hard code the default vision model as a string literal,
|
||||||
* until our coding model supports multimodal.
|
* until our coding model supports multimodal.
|
||||||
|
|||||||
@@ -6,11 +6,7 @@
|
|||||||
|
|
||||||
import { vi, type Mock, type MockInstance } from 'vitest';
|
import { vi, type Mock, type MockInstance } from 'vitest';
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
|
||||||
OutputFormat,
|
|
||||||
FatalInputError,
|
|
||||||
ToolErrorType,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import {
|
import {
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
handleError,
|
handleError,
|
||||||
@@ -69,7 +65,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||||||
describe('errors', () => {
|
describe('errors', () => {
|
||||||
let mockConfig: Config;
|
let mockConfig: Config;
|
||||||
let processExitSpy: MockInstance;
|
let processExitSpy: MockInstance;
|
||||||
let processStderrWriteSpy: MockInstance;
|
|
||||||
let consoleErrorSpy: MockInstance;
|
let consoleErrorSpy: MockInstance;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -79,11 +74,6 @@ describe('errors', () => {
|
|||||||
// Mock console.error
|
// Mock console.error
|
||||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
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
|
// Mock process.exit to throw instead of actually exiting
|
||||||
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||||
throw new Error(`process.exit called with code: ${code}`);
|
throw new Error(`process.exit called with code: ${code}`);
|
||||||
@@ -94,13 +84,11 @@ describe('errors', () => {
|
|||||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
||||||
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
|
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
|
||||||
getDebugMode: vi.fn().mockReturnValue(true),
|
getDebugMode: vi.fn().mockReturnValue(true),
|
||||||
isInteractive: vi.fn().mockReturnValue(false),
|
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
consoleErrorSpy.mockRestore();
|
consoleErrorSpy.mockRestore();
|
||||||
processStderrWriteSpy.mockRestore();
|
|
||||||
processExitSpy.mockRestore();
|
processExitSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -444,87 +432,6 @@ describe('errors', () => {
|
|||||||
expect(processExitSpy).not.toHaveBeenCalled();
|
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', () => {
|
describe('handleCancellationError', () => {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
parseAndFormatApiError,
|
parseAndFormatApiError,
|
||||||
FatalTurnLimitedError,
|
FatalTurnLimitedError,
|
||||||
FatalCancellationError,
|
FatalCancellationError,
|
||||||
ToolErrorType,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
export function getErrorMessage(error: unknown): string {
|
export function getErrorMessage(error: unknown): string {
|
||||||
@@ -103,24 +102,10 @@ export function handleToolError(
|
|||||||
toolName: string,
|
toolName: string,
|
||||||
toolError: Error,
|
toolError: Error,
|
||||||
config: Config,
|
config: Config,
|
||||||
errorCode?: string | number,
|
_errorCode?: string | number,
|
||||||
resultDisplay?: string,
|
resultDisplay?: string,
|
||||||
): void {
|
): void {
|
||||||
// Check if this is a permission denied error in non-interactive mode
|
// Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere
|
||||||
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()) {
|
if (config.getDebugMode()) {
|
||||||
console.error(
|
console.error(
|
||||||
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,
|
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Qwen Team
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
AuthType,
|
|
||||||
type ContentGeneratorConfig,
|
|
||||||
type ContentGeneratorConfigSources,
|
|
||||||
resolveModelConfig,
|
|
||||||
type ModelConfigSourcesInput,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import type { Settings } from '../config/settings.js';
|
|
||||||
|
|
||||||
export interface CliGenerationConfigInputs {
|
|
||||||
argv: {
|
|
||||||
model?: string | undefined;
|
|
||||||
openaiApiKey?: string | undefined;
|
|
||||||
openaiBaseUrl?: string | undefined;
|
|
||||||
openaiLogging?: boolean | undefined;
|
|
||||||
openaiLoggingDir?: string | undefined;
|
|
||||||
};
|
|
||||||
settings: Settings;
|
|
||||||
selectedAuthType: AuthType | undefined;
|
|
||||||
/**
|
|
||||||
* Injectable env for testability. Defaults to process.env at callsites.
|
|
||||||
*/
|
|
||||||
env?: Record<string, string | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResolvedCliGenerationConfig {
|
|
||||||
/** The resolved model id (may be empty string if not resolvable at CLI layer) */
|
|
||||||
model: string;
|
|
||||||
/** API key for OpenAI-compatible auth */
|
|
||||||
apiKey: string;
|
|
||||||
/** Base URL for OpenAI-compatible auth */
|
|
||||||
baseUrl: string;
|
|
||||||
/** The full generation config to pass to core Config */
|
|
||||||
generationConfig: Partial<ContentGeneratorConfig>;
|
|
||||||
/** Source attribution for each resolved field */
|
|
||||||
sources: ContentGeneratorConfigSources;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAuthTypeFromEnv(): AuthType | undefined {
|
|
||||||
if (process.env['OPENAI_API_KEY']) {
|
|
||||||
return AuthType.USE_OPENAI;
|
|
||||||
}
|
|
||||||
if (process.env['QWEN_OAUTH']) {
|
|
||||||
return AuthType.QWEN_OAUTH;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env['GEMINI_API_KEY']) {
|
|
||||||
return AuthType.USE_GEMINI;
|
|
||||||
}
|
|
||||||
if (process.env['GOOGLE_API_KEY']) {
|
|
||||||
return AuthType.USE_VERTEX_AI;
|
|
||||||
}
|
|
||||||
if (process.env['ANTHROPIC_API_KEY']) {
|
|
||||||
return AuthType.USE_ANTHROPIC;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified resolver for CLI generation config.
|
|
||||||
*
|
|
||||||
* Precedence (for OpenAI auth):
|
|
||||||
* - model: argv.model > OPENAI_MODEL > QWEN_MODEL > settings.model.name
|
|
||||||
* - apiKey: argv.openaiApiKey > OPENAI_API_KEY > settings.security.auth.apiKey
|
|
||||||
* - baseUrl: argv.openaiBaseUrl > OPENAI_BASE_URL > settings.security.auth.baseUrl
|
|
||||||
*
|
|
||||||
* For non-OpenAI auth, only argv.model override is respected at CLI layer.
|
|
||||||
*/
|
|
||||||
export function resolveCliGenerationConfig(
|
|
||||||
inputs: CliGenerationConfigInputs,
|
|
||||||
): ResolvedCliGenerationConfig {
|
|
||||||
const { argv, settings, selectedAuthType } = inputs;
|
|
||||||
const env = inputs.env ?? (process.env as Record<string, string | undefined>);
|
|
||||||
|
|
||||||
const authType = selectedAuthType;
|
|
||||||
|
|
||||||
const configSources: ModelConfigSourcesInput = {
|
|
||||||
authType,
|
|
||||||
cli: {
|
|
||||||
model: argv.model,
|
|
||||||
apiKey: argv.openaiApiKey,
|
|
||||||
baseUrl: argv.openaiBaseUrl,
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
model: settings.model?.name,
|
|
||||||
apiKey: settings.security?.auth?.apiKey,
|
|
||||||
baseUrl: settings.security?.auth?.baseUrl,
|
|
||||||
generationConfig: settings.model?.generationConfig as
|
|
||||||
| Partial<ContentGeneratorConfig>
|
|
||||||
| undefined,
|
|
||||||
},
|
|
||||||
env,
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolved = resolveModelConfig(configSources);
|
|
||||||
|
|
||||||
// Log warnings if any
|
|
||||||
for (const warning of resolved.warnings) {
|
|
||||||
console.warn(`[modelProviderUtils] ${warning}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve OpenAI logging config (CLI-specific, not part of core resolver)
|
|
||||||
const enableOpenAILogging =
|
|
||||||
(typeof argv.openaiLogging === 'undefined'
|
|
||||||
? settings.model?.enableOpenAILogging
|
|
||||||
: argv.openaiLogging) ?? false;
|
|
||||||
|
|
||||||
const openAILoggingDir =
|
|
||||||
argv.openaiLoggingDir || settings.model?.openAILoggingDir;
|
|
||||||
|
|
||||||
// Build the full generation config
|
|
||||||
// Note: we merge the resolved config with logging settings
|
|
||||||
const generationConfig: Partial<ContentGeneratorConfig> = {
|
|
||||||
...resolved.config,
|
|
||||||
enableOpenAILogging,
|
|
||||||
openAILoggingDir,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
model: resolved.config.model || '',
|
|
||||||
apiKey: resolved.config.apiKey || '',
|
|
||||||
baseUrl: resolved.config.baseUrl || '',
|
|
||||||
generationConfig,
|
|
||||||
sources: resolved.sources,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -35,33 +35,22 @@ import {
|
|||||||
} from './nonInteractiveHelpers.js';
|
} from './nonInteractiveHelpers.js';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock('../nonInteractiveCliCommands.js', () => ({
|
vi.mock('../services/CommandService.js', () => ({
|
||||||
getAvailableCommands: vi
|
CommandService: {
|
||||||
.fn()
|
create: vi.fn().mockResolvedValue({
|
||||||
.mockImplementation(
|
getCommands: vi
|
||||||
async (
|
.fn()
|
||||||
_config: unknown,
|
.mockReturnValue([
|
||||||
_signal: AbortSignal,
|
{ name: 'help' },
|
||||||
allowedBuiltinCommandNames?: string[],
|
{ name: 'commit' },
|
||||||
) => {
|
{ name: 'memory' },
|
||||||
const allowedSet = new Set(allowedBuiltinCommandNames ?? []);
|
]),
|
||||||
const allCommands = [
|
}),
|
||||||
{ name: 'help', kind: 'built-in' },
|
},
|
||||||
{ name: 'commit', kind: 'file' },
|
}));
|
||||||
{ name: 'memory', kind: 'built-in' },
|
|
||||||
{ name: 'init', kind: 'built-in' },
|
|
||||||
{ name: 'summary', kind: 'built-in' },
|
|
||||||
{ name: 'compress', kind: 'built-in' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Filter commands: always include file commands, only include allowed built-in commands
|
vi.mock('../services/BuiltinCommandLoader.js', () => ({
|
||||||
return allCommands.filter(
|
BuiltinCommandLoader: vi.fn().mockImplementation(() => ({})),
|
||||||
(cmd) =>
|
|
||||||
cmd.kind === 'file' ||
|
|
||||||
(cmd.kind === 'built-in' && allowedSet.has(cmd.name)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../ui/utils/computeStats.js', () => ({
|
vi.mock('../ui/utils/computeStats.js', () => ({
|
||||||
@@ -522,12 +511,10 @@ describe('buildSystemMessage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should build system message with all fields', async () => {
|
it('should build system message with all fields', async () => {
|
||||||
const allowedBuiltinCommands = ['init', 'summary', 'compress'];
|
|
||||||
const result = await buildSystemMessage(
|
const result = await buildSystemMessage(
|
||||||
mockConfig,
|
mockConfig,
|
||||||
'test-session-id',
|
'test-session-id',
|
||||||
'auto' as PermissionMode,
|
'auto' as PermissionMode,
|
||||||
allowedBuiltinCommands,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
@@ -543,7 +530,7 @@ describe('buildSystemMessage', () => {
|
|||||||
],
|
],
|
||||||
model: 'test-model',
|
model: 'test-model',
|
||||||
permission_mode: 'auto',
|
permission_mode: 'auto',
|
||||||
slash_commands: ['commit', 'compress', 'init', 'summary'],
|
slash_commands: ['commit', 'help', 'memory'],
|
||||||
qwen_code_version: '1.0.0',
|
qwen_code_version: '1.0.0',
|
||||||
agents: [],
|
agents: [],
|
||||||
});
|
});
|
||||||
@@ -559,7 +546,6 @@ describe('buildSystemMessage', () => {
|
|||||||
config,
|
config,
|
||||||
'test-session-id',
|
'test-session-id',
|
||||||
'auto' as PermissionMode,
|
'auto' as PermissionMode,
|
||||||
['init', 'summary'],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.tools).toEqual([]);
|
expect(result.tools).toEqual([]);
|
||||||
@@ -575,7 +561,6 @@ describe('buildSystemMessage', () => {
|
|||||||
config,
|
config,
|
||||||
'test-session-id',
|
'test-session-id',
|
||||||
'auto' as PermissionMode,
|
'auto' as PermissionMode,
|
||||||
['init', 'summary'],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.mcp_servers).toEqual([]);
|
expect(result.mcp_servers).toEqual([]);
|
||||||
@@ -591,37 +576,10 @@ describe('buildSystemMessage', () => {
|
|||||||
config,
|
config,
|
||||||
'test-session-id',
|
'test-session-id',
|
||||||
'auto' as PermissionMode,
|
'auto' as PermissionMode,
|
||||||
['init', 'summary'],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.qwen_code_version).toBe('unknown');
|
expect(result.qwen_code_version).toBe('unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should only include allowed built-in commands and all file commands', async () => {
|
|
||||||
const allowedBuiltinCommands = ['init', 'summary'];
|
|
||||||
const result = await buildSystemMessage(
|
|
||||||
mockConfig,
|
|
||||||
'test-session-id',
|
|
||||||
'auto' as PermissionMode,
|
|
||||||
allowedBuiltinCommands,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should include: 'commit' (FILE), 'init' (BUILT_IN, allowed), 'summary' (BUILT_IN, allowed)
|
|
||||||
// Should NOT include: 'help', 'memory', 'compress' (BUILT_IN but not in allowed set)
|
|
||||||
expect(result.slash_commands).toEqual(['commit', 'init', 'summary']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include only file commands when no built-in commands are allowed', async () => {
|
|
||||||
const result = await buildSystemMessage(
|
|
||||||
mockConfig,
|
|
||||||
'test-session-id',
|
|
||||||
'auto' as PermissionMode,
|
|
||||||
[], // Empty array - no built-in commands allowed
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should only include 'commit' (FILE command)
|
|
||||||
expect(result.slash_commands).toEqual(['commit']);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createTaskToolProgressHandler', () => {
|
describe('createTaskToolProgressHandler', () => {
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ import type {
|
|||||||
PermissionMode,
|
PermissionMode,
|
||||||
CLISystemMessage,
|
CLISystemMessage,
|
||||||
} from '../nonInteractive/types.js';
|
} from '../nonInteractive/types.js';
|
||||||
|
import { CommandService } from '../services/CommandService.js';
|
||||||
|
import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js';
|
||||||
import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js';
|
import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js';
|
||||||
import { computeSessionStats } from '../ui/utils/computeStats.js';
|
import { computeSessionStats } from '../ui/utils/computeStats.js';
|
||||||
import { getAvailableCommands } from '../nonInteractiveCliCommands.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes various part list formats into a consistent Part[] array.
|
* Normalizes various part list formats into a consistent Part[] array.
|
||||||
@@ -186,27 +187,24 @@ export function computeUsageFromMetrics(metrics: SessionMetrics): Usage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load slash command names using getAvailableCommands
|
* Load slash command names using CommandService
|
||||||
*
|
*
|
||||||
* @param config - Config instance
|
* @param config - Config instance
|
||||||
* @param allowedBuiltinCommandNames - Optional array of allowed built-in command names.
|
|
||||||
* If not provided, uses the default from getAvailableCommands.
|
|
||||||
* @returns Promise resolving to array of slash command names
|
* @returns Promise resolving to array of slash command names
|
||||||
*/
|
*/
|
||||||
async function loadSlashCommandNames(
|
async function loadSlashCommandNames(config: Config): Promise<string[]> {
|
||||||
config: Config,
|
|
||||||
allowedBuiltinCommandNames?: string[],
|
|
||||||
): Promise<string[]> {
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
try {
|
try {
|
||||||
const commands = await getAvailableCommands(
|
const service = await CommandService.create(
|
||||||
config,
|
[new BuiltinCommandLoader(config)],
|
||||||
controller.signal,
|
controller.signal,
|
||||||
allowedBuiltinCommandNames,
|
|
||||||
);
|
);
|
||||||
|
const names = new Set<string>();
|
||||||
// Extract command names and sort
|
const commands = service.getCommands();
|
||||||
return commands.map((cmd) => cmd.name).sort();
|
for (const command of commands) {
|
||||||
|
names.add(command.name);
|
||||||
|
}
|
||||||
|
return Array.from(names).sort();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (config.getDebugMode()) {
|
if (config.getDebugMode()) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -235,15 +233,12 @@ async function loadSlashCommandNames(
|
|||||||
* @param config - Config instance
|
* @param config - Config instance
|
||||||
* @param sessionId - Session identifier
|
* @param sessionId - Session identifier
|
||||||
* @param permissionMode - Current permission/approval mode
|
* @param permissionMode - Current permission/approval mode
|
||||||
* @param allowedBuiltinCommandNames - Optional array of allowed built-in command names.
|
|
||||||
* If not provided, defaults to empty array (only file commands will be included).
|
|
||||||
* @returns Promise resolving to CLISystemMessage
|
* @returns Promise resolving to CLISystemMessage
|
||||||
*/
|
*/
|
||||||
export async function buildSystemMessage(
|
export async function buildSystemMessage(
|
||||||
config: Config,
|
config: Config,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
permissionMode: PermissionMode,
|
permissionMode: PermissionMode,
|
||||||
allowedBuiltinCommandNames?: string[],
|
|
||||||
): Promise<CLISystemMessage> {
|
): Promise<CLISystemMessage> {
|
||||||
const toolRegistry = config.getToolRegistry();
|
const toolRegistry = config.getToolRegistry();
|
||||||
const tools = toolRegistry ? toolRegistry.getAllToolNames() : [];
|
const tools = toolRegistry ? toolRegistry.getAllToolNames() : [];
|
||||||
@@ -256,11 +251,8 @@ export async function buildSystemMessage(
|
|||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Load slash commands with filtering based on allowed built-in commands
|
// Load slash commands
|
||||||
const slashCommands = await loadSlashCommandNames(
|
const slashCommands = await loadSlashCommandNames(config);
|
||||||
config,
|
|
||||||
allowedBuiltinCommandNames,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load subagent names from config
|
// Load subagent names from config
|
||||||
let agentNames: string[] = [];
|
let agentNames: string[] = [];
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ describe('systemInfo', () => {
|
|||||||
getModel: vi.fn().mockReturnValue('test-model'),
|
getModel: vi.fn().mockReturnValue('test-model'),
|
||||||
getIdeMode: vi.fn().mockReturnValue(true),
|
getIdeMode: vi.fn().mockReturnValue(true),
|
||||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||||
getAuthType: vi.fn().mockReturnValue('test-auth'),
|
|
||||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||||
baseUrl: 'https://api.openai.com',
|
baseUrl: 'https://api.openai.com',
|
||||||
}),
|
}),
|
||||||
@@ -274,9 +273,6 @@ describe('systemInfo', () => {
|
|||||||
// Update the mock context to use OpenAI auth
|
// Update the mock context to use OpenAI auth
|
||||||
mockContext.services.settings.merged.security!.auth!.selectedType =
|
mockContext.services.settings.merged.security!.auth!.selectedType =
|
||||||
AuthType.USE_OPENAI;
|
AuthType.USE_OPENAI;
|
||||||
vi.mocked(mockContext.services.config!.getAuthType).mockReturnValue(
|
|
||||||
AuthType.USE_OPENAI,
|
|
||||||
);
|
|
||||||
|
|
||||||
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ export async function getSystemInfo(
|
|||||||
const sandboxEnv = getSandboxEnv();
|
const sandboxEnv = getSandboxEnv();
|
||||||
const modelVersion = context.services.config?.getModel() || 'Unknown';
|
const modelVersion = context.services.config?.getModel() || 'Unknown';
|
||||||
const cliVersion = await getCliVersion();
|
const cliVersion = await getCliVersion();
|
||||||
const selectedAuthType = context.services.config?.getAuthType() || '';
|
const selectedAuthType =
|
||||||
|
context.services.settings.merged.security?.auth?.selectedType || '';
|
||||||
const ideClient = await getIdeClientName(context);
|
const ideClient = await getIdeClientName(context);
|
||||||
const sessionId = context.services.config?.getSessionId() || 'unknown';
|
const sessionId = context.services.config?.getSessionId() || 'unknown';
|
||||||
|
|
||||||
|
|||||||
@@ -14,20 +14,6 @@ import * as JsonOutputAdapterModule from './nonInteractive/io/JsonOutputAdapter.
|
|||||||
import * as StreamJsonOutputAdapterModule from './nonInteractive/io/StreamJsonOutputAdapter.js';
|
import * as StreamJsonOutputAdapterModule from './nonInteractive/io/StreamJsonOutputAdapter.js';
|
||||||
import * as cleanupModule from './utils/cleanup.js';
|
import * as cleanupModule from './utils/cleanup.js';
|
||||||
|
|
||||||
// Helper to create a mock Config with modelsConfig
|
|
||||||
function createMockConfig(overrides?: Partial<Config>): Config {
|
|
||||||
return {
|
|
||||||
refreshAuth: vi.fn().mockResolvedValue('refreshed'),
|
|
||||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
|
||||||
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: undefined }),
|
|
||||||
modelsConfig: {
|
|
||||||
getModel: vi.fn().mockReturnValue('default-model'),
|
|
||||||
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
} as unknown as Config;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('validateNonInterActiveAuth', () => {
|
describe('validateNonInterActiveAuth', () => {
|
||||||
let originalEnvGeminiApiKey: string | undefined;
|
let originalEnvGeminiApiKey: string | undefined;
|
||||||
let originalEnvVertexAi: string | undefined;
|
let originalEnvVertexAi: string | undefined;
|
||||||
@@ -121,20 +107,17 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exits if validateAuthMethod fails for default auth type', async () => {
|
it('exits if no auth type is configured or env vars set', async () => {
|
||||||
// Mock validateAuthMethod to return error (e.g., missing API key)
|
const nonInteractiveConfig = {
|
||||||
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue(
|
|
||||||
'Missing API key for authentication',
|
|
||||||
);
|
|
||||||
const nonInteractiveConfig = createMockConfig({
|
|
||||||
refreshAuth: refreshAuthMock,
|
refreshAuth: refreshAuthMock,
|
||||||
modelsConfig: {
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
||||||
getModel: vi.fn().mockReturnValue('default-model'),
|
getContentGeneratorConfig: vi
|
||||||
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
|
.fn()
|
||||||
},
|
.mockReturnValue({ authType: undefined }),
|
||||||
});
|
} as unknown as Config;
|
||||||
try {
|
try {
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
@@ -144,21 +127,22 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
expect((e as Error).message).toContain('process.exit(1) called');
|
expect((e as Error).message).toContain('process.exit(1) called');
|
||||||
}
|
}
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('Missing API key'),
|
expect.stringContaining('Please set an Auth method'),
|
||||||
);
|
);
|
||||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses USE_OPENAI if OPENAI_API_KEY is set', async () => {
|
it('uses USE_OPENAI if OPENAI_API_KEY is set', async () => {
|
||||||
process.env['OPENAI_API_KEY'] = 'fake-openai-key';
|
process.env['OPENAI_API_KEY'] = 'fake-openai-key';
|
||||||
const nonInteractiveConfig = createMockConfig({
|
const nonInteractiveConfig = {
|
||||||
refreshAuth: refreshAuthMock,
|
refreshAuth: refreshAuthMock,
|
||||||
modelsConfig: {
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
||||||
getModel: vi.fn().mockReturnValue('default-model'),
|
getContentGeneratorConfig: vi
|
||||||
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
|
.fn()
|
||||||
},
|
.mockReturnValue({ authType: undefined }),
|
||||||
});
|
} as unknown as Config;
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
@@ -167,14 +151,15 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses configured QWEN_OAUTH if provided', async () => {
|
it('uses configured QWEN_OAUTH if provided', async () => {
|
||||||
const nonInteractiveConfig = createMockConfig({
|
const nonInteractiveConfig = {
|
||||||
refreshAuth: refreshAuthMock,
|
refreshAuth: refreshAuthMock,
|
||||||
modelsConfig: {
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
||||||
getModel: vi.fn().mockReturnValue('default-model'),
|
getContentGeneratorConfig: vi
|
||||||
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
|
.fn()
|
||||||
},
|
.mockReturnValue({ authType: undefined }),
|
||||||
});
|
} as unknown as Config;
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
|
AuthType.QWEN_OAUTH,
|
||||||
undefined,
|
undefined,
|
||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
@@ -185,11 +170,16 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
it('exits if validateAuthMethod returns error', async () => {
|
it('exits if validateAuthMethod returns error', async () => {
|
||||||
// Mock validateAuthMethod to return error
|
// Mock validateAuthMethod to return error
|
||||||
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
|
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
|
||||||
const nonInteractiveConfig = createMockConfig({
|
const nonInteractiveConfig = {
|
||||||
refreshAuth: refreshAuthMock,
|
refreshAuth: refreshAuthMock,
|
||||||
});
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
||||||
|
getContentGeneratorConfig: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue({ authType: undefined }),
|
||||||
|
} as unknown as Config;
|
||||||
try {
|
try {
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
|
AuthType.USE_GEMINI,
|
||||||
undefined,
|
undefined,
|
||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
@@ -207,13 +197,14 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
const validateAuthMethodSpy = vi
|
const validateAuthMethodSpy = vi
|
||||||
.spyOn(auth, 'validateAuthMethod')
|
.spyOn(auth, 'validateAuthMethod')
|
||||||
.mockReturnValue('Auth error!');
|
.mockReturnValue('Auth error!');
|
||||||
const nonInteractiveConfig = createMockConfig({
|
const nonInteractiveConfig = {
|
||||||
refreshAuth: refreshAuthMock,
|
refreshAuth: refreshAuthMock,
|
||||||
});
|
} as unknown as Config;
|
||||||
|
|
||||||
// Even with validation errors, it should not exit
|
// Even with an invalid auth type, it should not exit
|
||||||
// because validation is skipped when useExternalAuth is true.
|
// because validation is skipped.
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
|
'invalid-auth-type' as AuthType,
|
||||||
true, // useExternalAuth = true
|
true, // useExternalAuth = true
|
||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
@@ -222,8 +213,8 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
expect(validateAuthMethodSpy).not.toHaveBeenCalled();
|
expect(validateAuthMethodSpy).not.toHaveBeenCalled();
|
||||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
expect(processExitSpy).not.toHaveBeenCalled();
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
// refreshAuth is called with the authType from config.modelsConfig.getCurrentAuthType()
|
// We still expect refreshAuth to be called with the (invalid) type
|
||||||
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.QWEN_OAUTH);
|
expect(refreshAuthMock).toHaveBeenCalledWith('invalid-auth-type');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses enforcedAuthType if provided', async () => {
|
it('uses enforcedAuthType if provided', async () => {
|
||||||
@@ -231,14 +222,11 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
mockSettings.merged.security!.auth!.selectedType = AuthType.USE_OPENAI;
|
mockSettings.merged.security!.auth!.selectedType = AuthType.USE_OPENAI;
|
||||||
// Set required env var for USE_OPENAI to ensure enforcedAuthType takes precedence
|
// Set required env var for USE_OPENAI to ensure enforcedAuthType takes precedence
|
||||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||||
const nonInteractiveConfig = createMockConfig({
|
const nonInteractiveConfig = {
|
||||||
refreshAuth: refreshAuthMock,
|
refreshAuth: refreshAuthMock,
|
||||||
modelsConfig: {
|
} as unknown as Config;
|
||||||
getModel: vi.fn().mockReturnValue('default-model'),
|
|
||||||
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
|
AuthType.USE_OPENAI,
|
||||||
undefined,
|
undefined,
|
||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
@@ -249,15 +237,16 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
it('exits if currentAuthType does not match enforcedAuthType', async () => {
|
it('exits if currentAuthType does not match enforcedAuthType', async () => {
|
||||||
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
|
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
|
||||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||||
const nonInteractiveConfig = createMockConfig({
|
const nonInteractiveConfig = {
|
||||||
refreshAuth: refreshAuthMock,
|
refreshAuth: refreshAuthMock,
|
||||||
modelsConfig: {
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
||||||
getModel: vi.fn().mockReturnValue('default-model'),
|
getContentGeneratorConfig: vi
|
||||||
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
|
.fn()
|
||||||
},
|
.mockReturnValue({ authType: undefined }),
|
||||||
});
|
} as unknown as Config;
|
||||||
try {
|
try {
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
|
AuthType.USE_OPENAI,
|
||||||
undefined,
|
undefined,
|
||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
@@ -290,21 +279,18 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits error result and exits when validateAuthMethod fails', async () => {
|
it('emits error result and exits when no auth is configured', async () => {
|
||||||
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue(
|
const nonInteractiveConfig = {
|
||||||
'Missing API key for authentication',
|
|
||||||
);
|
|
||||||
const nonInteractiveConfig = createMockConfig({
|
|
||||||
refreshAuth: refreshAuthMock,
|
refreshAuth: refreshAuthMock,
|
||||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
|
||||||
modelsConfig: {
|
getContentGeneratorConfig: vi
|
||||||
getModel: vi.fn().mockReturnValue('default-model'),
|
.fn()
|
||||||
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
|
.mockReturnValue({ authType: undefined }),
|
||||||
},
|
} as unknown as Config;
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
@@ -316,7 +302,9 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
|
|
||||||
expect(emitResultMock).toHaveBeenCalledWith({
|
expect(emitResultMock).toHaveBeenCalledWith({
|
||||||
isError: true,
|
isError: true,
|
||||||
errorMessage: expect.stringContaining('Missing API key'),
|
errorMessage: expect.stringContaining(
|
||||||
|
'Please set an Auth method in your',
|
||||||
|
),
|
||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
apiDurationMs: 0,
|
apiDurationMs: 0,
|
||||||
numTurns: 0,
|
numTurns: 0,
|
||||||
@@ -331,17 +319,17 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
|
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
|
||||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||||
|
|
||||||
const nonInteractiveConfig = createMockConfig({
|
const nonInteractiveConfig = {
|
||||||
refreshAuth: refreshAuthMock,
|
refreshAuth: refreshAuthMock,
|
||||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
|
||||||
modelsConfig: {
|
getContentGeneratorConfig: vi
|
||||||
getModel: vi.fn().mockReturnValue('default-model'),
|
.fn()
|
||||||
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
|
.mockReturnValue({ authType: undefined }),
|
||||||
},
|
} as unknown as Config;
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
@@ -366,21 +354,21 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits error result and exits when API key validation fails', async () => {
|
it('emits error result and exits when validateAuthMethod fails', async () => {
|
||||||
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
|
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
|
||||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||||
|
|
||||||
const nonInteractiveConfig = createMockConfig({
|
const nonInteractiveConfig = {
|
||||||
refreshAuth: refreshAuthMock,
|
refreshAuth: refreshAuthMock,
|
||||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
|
||||||
modelsConfig: {
|
getContentGeneratorConfig: vi
|
||||||
getModel: vi.fn().mockReturnValue('default-model'),
|
.fn()
|
||||||
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
|
.mockReturnValue({ authType: undefined }),
|
||||||
},
|
} as unknown as Config;
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
|
AuthType.USE_OPENAI,
|
||||||
undefined,
|
undefined,
|
||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
@@ -425,22 +413,19 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits error result and exits when validateAuthMethod fails', async () => {
|
it('emits error result and exits when no auth is configured', async () => {
|
||||||
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue(
|
const nonInteractiveConfig = {
|
||||||
'Missing API key for authentication',
|
|
||||||
);
|
|
||||||
const nonInteractiveConfig = createMockConfig({
|
|
||||||
refreshAuth: refreshAuthMock,
|
refreshAuth: refreshAuthMock,
|
||||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
|
||||||
getIncludePartialMessages: vi.fn().mockReturnValue(false),
|
getIncludePartialMessages: vi.fn().mockReturnValue(false),
|
||||||
modelsConfig: {
|
getContentGeneratorConfig: vi
|
||||||
getModel: vi.fn().mockReturnValue('default-model'),
|
.fn()
|
||||||
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
|
.mockReturnValue({ authType: undefined }),
|
||||||
},
|
} as unknown as Config;
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
@@ -452,7 +437,9 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
|
|
||||||
expect(emitResultMock).toHaveBeenCalledWith({
|
expect(emitResultMock).toHaveBeenCalledWith({
|
||||||
isError: true,
|
isError: true,
|
||||||
errorMessage: expect.stringContaining('Missing API key'),
|
errorMessage: expect.stringContaining(
|
||||||
|
'Please set an Auth method in your',
|
||||||
|
),
|
||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
apiDurationMs: 0,
|
apiDurationMs: 0,
|
||||||
numTurns: 0,
|
numTurns: 0,
|
||||||
@@ -467,18 +454,18 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
|
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
|
||||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||||
|
|
||||||
const nonInteractiveConfig = createMockConfig({
|
const nonInteractiveConfig = {
|
||||||
refreshAuth: refreshAuthMock,
|
refreshAuth: refreshAuthMock,
|
||||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
|
||||||
getIncludePartialMessages: vi.fn().mockReturnValue(false),
|
getIncludePartialMessages: vi.fn().mockReturnValue(false),
|
||||||
modelsConfig: {
|
getContentGeneratorConfig: vi
|
||||||
getModel: vi.fn().mockReturnValue('default-model'),
|
.fn()
|
||||||
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
|
.mockReturnValue({ authType: undefined }),
|
||||||
},
|
} as unknown as Config;
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
@@ -503,22 +490,22 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits error result and exits when API key validation fails', async () => {
|
it('emits error result and exits when validateAuthMethod fails', async () => {
|
||||||
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
|
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
|
||||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||||
|
|
||||||
const nonInteractiveConfig = createMockConfig({
|
const nonInteractiveConfig = {
|
||||||
refreshAuth: refreshAuthMock,
|
refreshAuth: refreshAuthMock,
|
||||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
|
||||||
getIncludePartialMessages: vi.fn().mockReturnValue(false),
|
getIncludePartialMessages: vi.fn().mockReturnValue(false),
|
||||||
modelsConfig: {
|
getContentGeneratorConfig: vi
|
||||||
getModel: vi.fn().mockReturnValue('default-model'),
|
.fn()
|
||||||
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
|
.mockReturnValue({ authType: undefined }),
|
||||||
},
|
} as unknown as Config;
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
|
AuthType.USE_OPENAI,
|
||||||
undefined,
|
undefined,
|
||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
|
|||||||
@@ -5,42 +5,69 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import { OutputFormat } from '@qwen-code/qwen-code-core';
|
import { AuthType, OutputFormat } from '@qwen-code/qwen-code-core';
|
||||||
|
import { USER_SETTINGS_PATH } from './config/settings.js';
|
||||||
import { validateAuthMethod } from './config/auth.js';
|
import { validateAuthMethod } from './config/auth.js';
|
||||||
import { type LoadedSettings } from './config/settings.js';
|
import { type LoadedSettings } from './config/settings.js';
|
||||||
import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
|
import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
|
||||||
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
|
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
|
||||||
import { runExitCleanup } from './utils/cleanup.js';
|
import { runExitCleanup } from './utils/cleanup.js';
|
||||||
|
|
||||||
|
function getAuthTypeFromEnv(): AuthType | undefined {
|
||||||
|
if (process.env['OPENAI_API_KEY']) {
|
||||||
|
return AuthType.USE_OPENAI;
|
||||||
|
}
|
||||||
|
if (process.env['QWEN_OAUTH']) {
|
||||||
|
return AuthType.QWEN_OAUTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env['GEMINI_API_KEY']) {
|
||||||
|
return AuthType.USE_GEMINI;
|
||||||
|
}
|
||||||
|
if (process.env['GOOGLE_API_KEY']) {
|
||||||
|
return AuthType.USE_VERTEX_AI;
|
||||||
|
}
|
||||||
|
if (process.env['ANTHROPIC_API_KEY']) {
|
||||||
|
return AuthType.USE_ANTHROPIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export async function validateNonInteractiveAuth(
|
export async function validateNonInteractiveAuth(
|
||||||
|
configuredAuthType: AuthType | undefined,
|
||||||
useExternalAuth: boolean | undefined,
|
useExternalAuth: boolean | undefined,
|
||||||
nonInteractiveConfig: Config,
|
nonInteractiveConfig: Config,
|
||||||
settings: LoadedSettings,
|
settings: LoadedSettings,
|
||||||
): Promise<Config> {
|
): Promise<Config> {
|
||||||
try {
|
try {
|
||||||
// Get the actual authType from config which has already resolved CLI args, env vars, and settings
|
|
||||||
const authType = nonInteractiveConfig.modelsConfig.getCurrentAuthType();
|
|
||||||
if (!authType) {
|
|
||||||
throw new Error(
|
|
||||||
'No auth type is selected. Please configure an auth type (e.g. via settings or `--auth-type`) before running in non-interactive mode.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const resolvedAuthType: NonNullable<typeof authType> = authType;
|
|
||||||
|
|
||||||
const enforcedType = settings.merged.security?.auth?.enforcedType;
|
const enforcedType = settings.merged.security?.auth?.enforcedType;
|
||||||
if (enforcedType && enforcedType !== resolvedAuthType) {
|
if (enforcedType) {
|
||||||
const message = `The configured auth type is ${enforcedType}, but the current auth type is ${resolvedAuthType}. Please re-authenticate with the correct type.`;
|
const currentAuthType = getAuthTypeFromEnv();
|
||||||
|
if (currentAuthType !== enforcedType) {
|
||||||
|
const message = `The configured auth type is ${enforcedType}, but the current auth type is ${currentAuthType}. Please re-authenticate with the correct type.`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveAuthType =
|
||||||
|
enforcedType || configuredAuthType || getAuthTypeFromEnv();
|
||||||
|
|
||||||
|
if (!effectiveAuthType) {
|
||||||
|
const message = `Please set an Auth method in your ${USER_SETTINGS_PATH} or specify one of the following environment variables before running: QWEN_OAUTH, OPENAI_API_KEY`;
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authType: AuthType = effectiveAuthType as AuthType;
|
||||||
|
|
||||||
if (!useExternalAuth) {
|
if (!useExternalAuth) {
|
||||||
const err = validateAuthMethod(resolvedAuthType, nonInteractiveConfig);
|
const err = validateAuthMethod(String(authType));
|
||||||
if (err != null) {
|
if (err != null) {
|
||||||
throw new Error(err);
|
throw new Error(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await nonInteractiveConfig.refreshAuth(resolvedAuthType);
|
await nonInteractiveConfig.refreshAuth(authType);
|
||||||
return nonInteractiveConfig;
|
return nonInteractiveConfig;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const outputFormat = nonInteractiveConfig.getOutputFormat();
|
const outputFormat = nonInteractiveConfig.getOutputFormat();
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ export * from './src/index.js';
|
|||||||
export { Storage } from './src/config/storage.js';
|
export { Storage } from './src/config/storage.js';
|
||||||
export {
|
export {
|
||||||
DEFAULT_QWEN_MODEL,
|
DEFAULT_QWEN_MODEL,
|
||||||
DEFAULT_QWEN_FLASH_MODEL,
|
|
||||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||||
|
DEFAULT_GEMINI_MODEL,
|
||||||
|
DEFAULT_GEMINI_MODEL_AUTO,
|
||||||
|
DEFAULT_GEMINI_FLASH_MODEL,
|
||||||
|
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||||
|
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||||
} from './src/config/models.js';
|
} from './src/config/models.js';
|
||||||
export {
|
export {
|
||||||
serializeTerminalToObject,
|
serializeTerminalToObject,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code-core",
|
"name": "@qwen-code/qwen-code-core",
|
||||||
"version": "0.7.0",
|
"version": "0.6.0",
|
||||||
"description": "Qwen Code Core",
|
"description": "Qwen Code Core",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -15,16 +15,10 @@ import {
|
|||||||
DEFAULT_OTLP_ENDPOINT,
|
DEFAULT_OTLP_ENDPOINT,
|
||||||
QwenLogger,
|
QwenLogger,
|
||||||
} from '../telemetry/index.js';
|
} from '../telemetry/index.js';
|
||||||
import type {
|
import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
|
||||||
ContentGenerator,
|
|
||||||
ContentGeneratorConfig,
|
|
||||||
} from '../core/contentGenerator.js';
|
|
||||||
import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js';
|
|
||||||
import {
|
import {
|
||||||
AuthType,
|
AuthType,
|
||||||
createContentGenerator,
|
|
||||||
createContentGeneratorConfig,
|
createContentGeneratorConfig,
|
||||||
resolveContentGeneratorConfigWithSources,
|
|
||||||
} from '../core/contentGenerator.js';
|
} from '../core/contentGenerator.js';
|
||||||
import { GeminiClient } from '../core/client.js';
|
import { GeminiClient } from '../core/client.js';
|
||||||
import { GitService } from '../services/gitService.js';
|
import { GitService } from '../services/gitService.js';
|
||||||
@@ -214,19 +208,6 @@ describe('Server Config (config.ts)', () => {
|
|||||||
vi.spyOn(QwenLogger.prototype, 'logStartSessionEvent').mockImplementation(
|
vi.spyOn(QwenLogger.prototype, 'logStartSessionEvent').mockImplementation(
|
||||||
async () => undefined,
|
async () => undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Setup default mock for resolveContentGeneratorConfigWithSources
|
|
||||||
vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation(
|
|
||||||
(_config, authType, generationConfig) => ({
|
|
||||||
config: {
|
|
||||||
...generationConfig,
|
|
||||||
authType,
|
|
||||||
model: generationConfig?.model || MODEL,
|
|
||||||
apiKey: 'test-key',
|
|
||||||
} as ContentGeneratorConfig,
|
|
||||||
sources: {},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('initialize', () => {
|
describe('initialize', () => {
|
||||||
@@ -274,28 +255,31 @@ describe('Server Config (config.ts)', () => {
|
|||||||
const mockContentConfig = {
|
const mockContentConfig = {
|
||||||
apiKey: 'test-key',
|
apiKey: 'test-key',
|
||||||
model: 'qwen3-coder-plus',
|
model: 'qwen3-coder-plus',
|
||||||
authType,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({
|
vi.mocked(createContentGeneratorConfig).mockReturnValue(
|
||||||
config: mockContentConfig as ContentGeneratorConfig,
|
mockContentConfig,
|
||||||
sources: {},
|
);
|
||||||
});
|
|
||||||
|
// Set fallback mode to true to ensure it gets reset
|
||||||
|
config.setFallbackMode(true);
|
||||||
|
expect(config.isInFallbackMode()).toBe(true);
|
||||||
|
|
||||||
await config.refreshAuth(authType);
|
await config.refreshAuth(authType);
|
||||||
|
|
||||||
expect(resolveContentGeneratorConfigWithSources).toHaveBeenCalledWith(
|
expect(createContentGeneratorConfig).toHaveBeenCalledWith(
|
||||||
config,
|
config,
|
||||||
authType,
|
authType,
|
||||||
expect.objectContaining({
|
{
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
}),
|
baseUrl: undefined,
|
||||||
expect.anything(),
|
},
|
||||||
expect.anything(),
|
|
||||||
);
|
);
|
||||||
// Verify that contentGeneratorConfig is updated
|
// Verify that contentGeneratorConfig is updated
|
||||||
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
|
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
|
||||||
expect(GeminiClient).toHaveBeenCalledWith(config);
|
expect(GeminiClient).toHaveBeenCalledWith(config);
|
||||||
|
// Verify that fallback mode is reset
|
||||||
|
expect(config.isInFallbackMode()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not strip thoughts when switching from Vertex to GenAI', async () => {
|
it('should not strip thoughts when switching from Vertex to GenAI', async () => {
|
||||||
@@ -316,129 +300,6 @@ describe('Server Config (config.ts)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('model switching optimization (QWEN_OAUTH)', () => {
|
|
||||||
it('should switch qwen-oauth model in-place without refreshing auth when safe', async () => {
|
|
||||||
const config = new Config(baseParams);
|
|
||||||
|
|
||||||
const mockContentConfig: ContentGeneratorConfig = {
|
|
||||||
authType: AuthType.QWEN_OAUTH,
|
|
||||||
model: 'coder-model',
|
|
||||||
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
|
|
||||||
baseUrl: DEFAULT_DASHSCOPE_BASE_URL,
|
|
||||||
timeout: 60000,
|
|
||||||
maxRetries: 3,
|
|
||||||
} as ContentGeneratorConfig;
|
|
||||||
|
|
||||||
vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation(
|
|
||||||
(_config, authType, generationConfig) => ({
|
|
||||||
config: {
|
|
||||||
...mockContentConfig,
|
|
||||||
authType,
|
|
||||||
model: generationConfig?.model ?? mockContentConfig.model,
|
|
||||||
} as ContentGeneratorConfig,
|
|
||||||
sources: {},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.mocked(createContentGenerator).mockResolvedValue({
|
|
||||||
generateContent: vi.fn(),
|
|
||||||
generateContentStream: vi.fn(),
|
|
||||||
countTokens: vi.fn(),
|
|
||||||
embedContent: vi.fn(),
|
|
||||||
} as unknown as ContentGenerator);
|
|
||||||
|
|
||||||
// Establish initial qwen-oauth content generator config/content generator.
|
|
||||||
await config.refreshAuth(AuthType.QWEN_OAUTH);
|
|
||||||
|
|
||||||
// Spy after initial refresh to ensure model switch does not re-trigger refreshAuth.
|
|
||||||
const refreshSpy = vi.spyOn(config, 'refreshAuth');
|
|
||||||
|
|
||||||
await config.switchModel(AuthType.QWEN_OAUTH, 'vision-model');
|
|
||||||
|
|
||||||
expect(config.getModel()).toBe('vision-model');
|
|
||||||
expect(refreshSpy).not.toHaveBeenCalled();
|
|
||||||
// Called once during initial refreshAuth + once during handleModelChange diffing.
|
|
||||||
expect(
|
|
||||||
vi.mocked(resolveContentGeneratorConfigWithSources),
|
|
||||||
).toHaveBeenCalledTimes(2);
|
|
||||||
expect(vi.mocked(createContentGenerator)).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('model switching with different credentials (OpenAI)', () => {
|
|
||||||
it('should refresh auth when switching to model with different envKey', async () => {
|
|
||||||
// This test verifies the fix for switching between modelProvider models
|
|
||||||
// with different envKeys (e.g., deepseek-chat with DEEPSEEK_API_KEY)
|
|
||||||
const configWithModelProviders = new Config({
|
|
||||||
...baseParams,
|
|
||||||
authType: AuthType.USE_OPENAI,
|
|
||||||
modelProvidersConfig: {
|
|
||||||
openai: [
|
|
||||||
{
|
|
||||||
id: 'model-a',
|
|
||||||
name: 'Model A',
|
|
||||||
baseUrl: 'https://api.example.com/v1',
|
|
||||||
envKey: 'API_KEY_A',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'model-b',
|
|
||||||
name: 'Model B',
|
|
||||||
baseUrl: 'https://api.example.com/v1',
|
|
||||||
envKey: 'API_KEY_B',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockContentConfigA: ContentGeneratorConfig = {
|
|
||||||
authType: AuthType.USE_OPENAI,
|
|
||||||
model: 'model-a',
|
|
||||||
apiKey: 'key-a',
|
|
||||||
baseUrl: 'https://api.example.com/v1',
|
|
||||||
} as ContentGeneratorConfig;
|
|
||||||
|
|
||||||
const mockContentConfigB: ContentGeneratorConfig = {
|
|
||||||
authType: AuthType.USE_OPENAI,
|
|
||||||
model: 'model-b',
|
|
||||||
apiKey: 'key-b',
|
|
||||||
baseUrl: 'https://api.example.com/v1',
|
|
||||||
} as ContentGeneratorConfig;
|
|
||||||
|
|
||||||
vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation(
|
|
||||||
(_config, _authType, generationConfig) => {
|
|
||||||
const model = generationConfig?.model;
|
|
||||||
return {
|
|
||||||
config:
|
|
||||||
model === 'model-b' ? mockContentConfigB : mockContentConfigA,
|
|
||||||
sources: {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mocked(createContentGenerator).mockResolvedValue({
|
|
||||||
generateContent: vi.fn(),
|
|
||||||
generateContentStream: vi.fn(),
|
|
||||||
countTokens: vi.fn(),
|
|
||||||
embedContent: vi.fn(),
|
|
||||||
} as unknown as ContentGenerator);
|
|
||||||
|
|
||||||
// Initialize with model-a
|
|
||||||
await configWithModelProviders.refreshAuth(AuthType.USE_OPENAI);
|
|
||||||
|
|
||||||
// Spy on refreshAuth to verify it's called when switching to model-b
|
|
||||||
const refreshSpy = vi.spyOn(configWithModelProviders, 'refreshAuth');
|
|
||||||
|
|
||||||
// Switch to model-b (different envKey)
|
|
||||||
await configWithModelProviders.switchModel(
|
|
||||||
AuthType.USE_OPENAI,
|
|
||||||
'model-b',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should trigger full refresh because envKey changed
|
|
||||||
expect(refreshSpy).toHaveBeenCalledWith(AuthType.USE_OPENAI);
|
|
||||||
expect(configWithModelProviders.getModel()).toBe('model-b');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Config constructor should store userMemory correctly', () => {
|
it('Config constructor should store userMemory correctly', () => {
|
||||||
const config = new Config(baseParams);
|
const config = new Config(baseParams);
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import { ProxyAgent, setGlobalDispatcher } from 'undici';
|
|||||||
import type {
|
import type {
|
||||||
ContentGenerator,
|
ContentGenerator,
|
||||||
ContentGeneratorConfig,
|
ContentGeneratorConfig,
|
||||||
|
AuthType,
|
||||||
} from '../core/contentGenerator.js';
|
} from '../core/contentGenerator.js';
|
||||||
import type { ContentGeneratorConfigSources } from '../core/contentGenerator.js';
|
import type { FallbackModelHandler } from '../fallback/types.js';
|
||||||
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
||||||
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
||||||
import type { AnyToolInvocation } from '../tools/tools.js';
|
import type { AnyToolInvocation } from '../tools/tools.js';
|
||||||
@@ -26,9 +27,8 @@ import type { AnyToolInvocation } from '../tools/tools.js';
|
|||||||
import { BaseLlmClient } from '../core/baseLlmClient.js';
|
import { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||||
import { GeminiClient } from '../core/client.js';
|
import { GeminiClient } from '../core/client.js';
|
||||||
import {
|
import {
|
||||||
AuthType,
|
|
||||||
createContentGenerator,
|
createContentGenerator,
|
||||||
resolveContentGeneratorConfigWithSources,
|
createContentGeneratorConfig,
|
||||||
} from '../core/contentGenerator.js';
|
} from '../core/contentGenerator.js';
|
||||||
import { tokenLimit } from '../core/tokenLimits.js';
|
import { tokenLimit } from '../core/tokenLimits.js';
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ import {
|
|||||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import { DEFAULT_QWEN_EMBEDDING_MODEL } from './models.js';
|
import { DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_MODEL } from './models.js';
|
||||||
import { Storage } from './storage.js';
|
import { Storage } from './storage.js';
|
||||||
import { ChatRecordingService } from '../services/chatRecordingService.js';
|
import { ChatRecordingService } from '../services/chatRecordingService.js';
|
||||||
import {
|
import {
|
||||||
@@ -103,12 +103,6 @@ import {
|
|||||||
} from '../services/sessionService.js';
|
} from '../services/sessionService.js';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
|
||||||
ModelsConfig,
|
|
||||||
type ModelProvidersConfig,
|
|
||||||
type AvailableModel,
|
|
||||||
} from '../models/index.js';
|
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig };
|
export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig };
|
||||||
export {
|
export {
|
||||||
@@ -324,11 +318,6 @@ export interface ConfigParameters {
|
|||||||
ideMode?: boolean;
|
ideMode?: boolean;
|
||||||
authType?: AuthType;
|
authType?: AuthType;
|
||||||
generationConfig?: Partial<ContentGeneratorConfig>;
|
generationConfig?: Partial<ContentGeneratorConfig>;
|
||||||
/**
|
|
||||||
* Optional source map for generationConfig fields (e.g. CLI/env/settings attribution).
|
|
||||||
* This is used to produce per-field source badges in the UI.
|
|
||||||
*/
|
|
||||||
generationConfigSources?: ContentGeneratorConfigSources;
|
|
||||||
cliVersion?: string;
|
cliVersion?: string;
|
||||||
loadMemoryFromIncludeDirectories?: boolean;
|
loadMemoryFromIncludeDirectories?: boolean;
|
||||||
chatRecording?: boolean;
|
chatRecording?: boolean;
|
||||||
@@ -364,8 +353,6 @@ export interface ConfigParameters {
|
|||||||
sdkMode?: boolean;
|
sdkMode?: boolean;
|
||||||
sessionSubagents?: SubagentConfig[];
|
sessionSubagents?: SubagentConfig[];
|
||||||
channel?: string;
|
channel?: string;
|
||||||
/** Model providers configuration grouped by authType */
|
|
||||||
modelProvidersConfig?: ModelProvidersConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeConfigOutputFormat(
|
function normalizeConfigOutputFormat(
|
||||||
@@ -407,12 +394,9 @@ export class Config {
|
|||||||
private skillManager!: SkillManager;
|
private skillManager!: SkillManager;
|
||||||
private fileSystemService: FileSystemService;
|
private fileSystemService: FileSystemService;
|
||||||
private contentGeneratorConfig!: ContentGeneratorConfig;
|
private contentGeneratorConfig!: ContentGeneratorConfig;
|
||||||
private contentGeneratorConfigSources: ContentGeneratorConfigSources = {};
|
|
||||||
private contentGenerator!: ContentGenerator;
|
private contentGenerator!: ContentGenerator;
|
||||||
|
private _generationConfig: Partial<ContentGeneratorConfig>;
|
||||||
private readonly embeddingModel: string;
|
private readonly embeddingModel: string;
|
||||||
|
|
||||||
private _modelsConfig!: ModelsConfig;
|
|
||||||
private readonly modelProvidersConfig?: ModelProvidersConfig;
|
|
||||||
private readonly sandbox: SandboxConfig | undefined;
|
private readonly sandbox: SandboxConfig | undefined;
|
||||||
private readonly targetDir: string;
|
private readonly targetDir: string;
|
||||||
private workspaceContext: WorkspaceContext;
|
private workspaceContext: WorkspaceContext;
|
||||||
@@ -461,6 +445,7 @@ export class Config {
|
|||||||
private readonly folderTrust: boolean;
|
private readonly folderTrust: boolean;
|
||||||
private ideMode: boolean;
|
private ideMode: boolean;
|
||||||
|
|
||||||
|
private inFallbackMode = false;
|
||||||
private readonly maxSessionTurns: number;
|
private readonly maxSessionTurns: number;
|
||||||
private readonly sessionTokenLimit: number;
|
private readonly sessionTokenLimit: number;
|
||||||
private readonly listExtensions: boolean;
|
private readonly listExtensions: boolean;
|
||||||
@@ -469,6 +454,8 @@ export class Config {
|
|||||||
name: string;
|
name: string;
|
||||||
extensionName: string;
|
extensionName: string;
|
||||||
}>;
|
}>;
|
||||||
|
fallbackModelHandler?: FallbackModelHandler;
|
||||||
|
private quotaErrorOccurred: boolean = false;
|
||||||
private readonly summarizeToolOutput:
|
private readonly summarizeToolOutput:
|
||||||
| Record<string, SummarizeToolOutputSettings>
|
| Record<string, SummarizeToolOutputSettings>
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -583,7 +570,13 @@ export class Config {
|
|||||||
this.folderTrustFeature = params.folderTrustFeature ?? false;
|
this.folderTrustFeature = params.folderTrustFeature ?? false;
|
||||||
this.folderTrust = params.folderTrust ?? false;
|
this.folderTrust = params.folderTrust ?? false;
|
||||||
this.ideMode = params.ideMode ?? false;
|
this.ideMode = params.ideMode ?? false;
|
||||||
this.modelProvidersConfig = params.modelProvidersConfig;
|
this._generationConfig = {
|
||||||
|
model: params.model,
|
||||||
|
...(params.generationConfig || {}),
|
||||||
|
baseUrl: params.generationConfig?.baseUrl,
|
||||||
|
};
|
||||||
|
this.contentGeneratorConfig = this
|
||||||
|
._generationConfig as ContentGeneratorConfig;
|
||||||
this.cliVersion = params.cliVersion;
|
this.cliVersion = params.cliVersion;
|
||||||
|
|
||||||
this.chatRecordingEnabled = params.chatRecording ?? true;
|
this.chatRecordingEnabled = params.chatRecording ?? true;
|
||||||
@@ -626,22 +619,6 @@ export class Config {
|
|||||||
setGeminiMdFilename(params.contextFileName);
|
setGeminiMdFilename(params.contextFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ModelsConfig for centralized model management
|
|
||||||
// Prefer params.authType over generationConfig.authType because:
|
|
||||||
// - params.authType preserves undefined (user hasn't selected yet)
|
|
||||||
// - generationConfig.authType may have a default value from resolvers
|
|
||||||
this._modelsConfig = new ModelsConfig({
|
|
||||||
initialAuthType: params.authType ?? params.generationConfig?.authType,
|
|
||||||
modelProvidersConfig: this.modelProvidersConfig,
|
|
||||||
generationConfig: {
|
|
||||||
model: params.model,
|
|
||||||
...(params.generationConfig || {}),
|
|
||||||
baseUrl: params.generationConfig?.baseUrl,
|
|
||||||
},
|
|
||||||
generationConfigSources: params.generationConfigSources,
|
|
||||||
onModelChange: this.handleModelChange.bind(this),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.telemetrySettings.enabled) {
|
if (this.telemetrySettings.enabled) {
|
||||||
initializeTelemetry(this);
|
initializeTelemetry(this);
|
||||||
}
|
}
|
||||||
@@ -692,61 +669,45 @@ export class Config {
|
|||||||
return this.contentGenerator;
|
return this.contentGenerator;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the ModelsConfig instance for model-related operations.
|
|
||||||
* External code (e.g., CLI) can use this to access model configuration.
|
|
||||||
*/
|
|
||||||
get modelsConfig(): ModelsConfig {
|
|
||||||
return this._modelsConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the credentials in the generation config.
|
* Updates the credentials in the generation config.
|
||||||
* Exclusive for `OpenAIKeyPrompt` to update credentials via `/auth`
|
* This is needed when credentials are set after Config construction.
|
||||||
* Delegates to ModelsConfig.
|
|
||||||
*/
|
*/
|
||||||
updateCredentials(credentials: {
|
updateCredentials(credentials: {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
}): void {
|
}): void {
|
||||||
this._modelsConfig.updateCredentials(credentials);
|
if (credentials.apiKey) {
|
||||||
|
this._generationConfig.apiKey = credentials.apiKey;
|
||||||
|
}
|
||||||
|
if (credentials.baseUrl) {
|
||||||
|
this._generationConfig.baseUrl = credentials.baseUrl;
|
||||||
|
}
|
||||||
|
if (credentials.model) {
|
||||||
|
this._generationConfig.model = credentials.model;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh authentication and rebuild ContentGenerator.
|
|
||||||
*/
|
|
||||||
async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) {
|
async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) {
|
||||||
// Sync modelsConfig state for this auth refresh
|
const newContentGeneratorConfig = createContentGeneratorConfig(
|
||||||
const modelId = this._modelsConfig.getModel();
|
|
||||||
this._modelsConfig.syncAfterAuthRefresh(authMethod, modelId);
|
|
||||||
|
|
||||||
// Check and consume cached credentials flag
|
|
||||||
const requireCached =
|
|
||||||
this._modelsConfig.consumeRequireCachedCredentialsFlag();
|
|
||||||
|
|
||||||
const { config, sources } = resolveContentGeneratorConfigWithSources(
|
|
||||||
this,
|
this,
|
||||||
authMethod,
|
authMethod,
|
||||||
this._modelsConfig.getGenerationConfig(),
|
this._generationConfig,
|
||||||
this._modelsConfig.getGenerationConfigSources(),
|
|
||||||
{
|
|
||||||
strictModelProvider:
|
|
||||||
this._modelsConfig.isStrictModelProviderSelection(),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const newContentGeneratorConfig = config;
|
|
||||||
this.contentGenerator = await createContentGenerator(
|
this.contentGenerator = await createContentGenerator(
|
||||||
newContentGeneratorConfig,
|
newContentGeneratorConfig,
|
||||||
this,
|
this,
|
||||||
requireCached ? true : isInitialAuth,
|
isInitialAuth,
|
||||||
);
|
);
|
||||||
// Only assign to instance properties after successful initialization
|
// Only assign to instance properties after successful initialization
|
||||||
this.contentGeneratorConfig = newContentGeneratorConfig;
|
this.contentGeneratorConfig = newContentGeneratorConfig;
|
||||||
this.contentGeneratorConfigSources = sources;
|
|
||||||
|
|
||||||
// Initialize BaseLlmClient now that the ContentGenerator is available
|
// Initialize BaseLlmClient now that the ContentGenerator is available
|
||||||
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
|
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
|
||||||
|
|
||||||
|
// Reset the session flag since we're explicitly changing auth and using default model
|
||||||
|
this.inFallbackMode = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -806,125 +767,31 @@ export class Config {
|
|||||||
return this.contentGeneratorConfig;
|
return this.contentGeneratorConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
getContentGeneratorConfigSources(): ContentGeneratorConfigSources {
|
|
||||||
// If contentGeneratorConfigSources is empty (before initializeAuth),
|
|
||||||
// get sources from ModelsConfig
|
|
||||||
if (
|
|
||||||
Object.keys(this.contentGeneratorConfigSources).length === 0 &&
|
|
||||||
this._modelsConfig
|
|
||||||
) {
|
|
||||||
return this._modelsConfig.getGenerationConfigSources();
|
|
||||||
}
|
|
||||||
return this.contentGeneratorConfigSources;
|
|
||||||
}
|
|
||||||
|
|
||||||
getModel(): string {
|
getModel(): string {
|
||||||
return this.contentGeneratorConfig?.model || this._modelsConfig.getModel();
|
return this.contentGeneratorConfig?.model || DEFAULT_QWEN_MODEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set model programmatically (e.g., VLM auto-switch, fallback).
|
|
||||||
* Delegates to ModelsConfig.
|
|
||||||
*/
|
|
||||||
async setModel(
|
async setModel(
|
||||||
newModel: string,
|
newModel: string,
|
||||||
metadata?: { reason?: string; context?: string },
|
_metadata?: { reason?: string; context?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this._modelsConfig.setModel(newModel, metadata);
|
|
||||||
// Also update contentGeneratorConfig for hot-update compatibility
|
|
||||||
if (this.contentGeneratorConfig) {
|
if (this.contentGeneratorConfig) {
|
||||||
this.contentGeneratorConfig.model = newModel;
|
this.contentGeneratorConfig.model = newModel;
|
||||||
}
|
}
|
||||||
|
// TODO: Log _metadata for telemetry if needed
|
||||||
|
// This _metadata can be used for tracking model switches (reason, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
isInFallbackMode(): boolean {
|
||||||
* Handle model change from ModelsConfig.
|
return this.inFallbackMode;
|
||||||
* This updates the content generator config with the new model settings.
|
|
||||||
*/
|
|
||||||
private async handleModelChange(
|
|
||||||
authType: AuthType,
|
|
||||||
requiresRefresh: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.contentGeneratorConfig) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hot update path: only supported for qwen-oauth.
|
|
||||||
// For other auth types we always refresh to recreate the ContentGenerator.
|
|
||||||
//
|
|
||||||
// Rationale:
|
|
||||||
// - Non-qwen providers may need to re-validate credentials / baseUrl / envKey.
|
|
||||||
// - ModelsConfig.applyResolvedModelDefaults can clear or change credentials sources.
|
|
||||||
// - Refresh keeps runtime behavior consistent and centralized.
|
|
||||||
if (authType === AuthType.QWEN_OAUTH && !requiresRefresh) {
|
|
||||||
const { config, sources } = resolveContentGeneratorConfigWithSources(
|
|
||||||
this,
|
|
||||||
authType,
|
|
||||||
this._modelsConfig.getGenerationConfig(),
|
|
||||||
this._modelsConfig.getGenerationConfigSources(),
|
|
||||||
{
|
|
||||||
strictModelProvider:
|
|
||||||
this._modelsConfig.isStrictModelProviderSelection(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Hot-update fields (qwen-oauth models share the same auth + client).
|
|
||||||
this.contentGeneratorConfig.model = config.model;
|
|
||||||
this.contentGeneratorConfig.samplingParams = config.samplingParams;
|
|
||||||
this.contentGeneratorConfig.disableCacheControl =
|
|
||||||
config.disableCacheControl;
|
|
||||||
|
|
||||||
if ('model' in sources) {
|
|
||||||
this.contentGeneratorConfigSources['model'] = sources['model'];
|
|
||||||
}
|
|
||||||
if ('samplingParams' in sources) {
|
|
||||||
this.contentGeneratorConfigSources['samplingParams'] =
|
|
||||||
sources['samplingParams'];
|
|
||||||
}
|
|
||||||
if ('disableCacheControl' in sources) {
|
|
||||||
this.contentGeneratorConfigSources['disableCacheControl'] =
|
|
||||||
sources['disableCacheControl'];
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full refresh path
|
|
||||||
await this.refreshAuth(authType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
setFallbackMode(active: boolean): void {
|
||||||
* Get available models for the current authType.
|
this.inFallbackMode = active;
|
||||||
* Delegates to ModelsConfig.
|
|
||||||
*/
|
|
||||||
getAvailableModels(): AvailableModel[] {
|
|
||||||
return this._modelsConfig.getAvailableModels();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
setFallbackModelHandler(handler: FallbackModelHandler): void {
|
||||||
* Get available models for a specific authType.
|
this.fallbackModelHandler = handler;
|
||||||
* Delegates to ModelsConfig.
|
|
||||||
*/
|
|
||||||
getAvailableModelsForAuthType(authType: AuthType): AvailableModel[] {
|
|
||||||
return this._modelsConfig.getAvailableModelsForAuthType(authType);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switch authType+model via registry-backed selection.
|
|
||||||
* This triggers a refresh of the ContentGenerator when required (always on authType changes).
|
|
||||||
* For qwen-oauth model switches that are hot-update safe, this may update in place.
|
|
||||||
*
|
|
||||||
* @param authType - Target authentication type
|
|
||||||
* @param modelId - Target model ID
|
|
||||||
* @param options - Additional options like requireCachedCredentials
|
|
||||||
* @param metadata - Metadata for logging/tracking
|
|
||||||
*/
|
|
||||||
async switchModel(
|
|
||||||
authType: AuthType,
|
|
||||||
modelId: string,
|
|
||||||
options?: { requireCachedCredentials?: boolean },
|
|
||||||
metadata?: { reason?: string; context?: string },
|
|
||||||
): Promise<void> {
|
|
||||||
await this._modelsConfig.switchModel(authType, modelId, options, metadata);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getMaxSessionTurns(): number {
|
getMaxSessionTurns(): number {
|
||||||
@@ -935,6 +802,14 @@ export class Config {
|
|||||||
return this.sessionTokenLimit;
|
return this.sessionTokenLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setQuotaErrorOccurred(value: boolean): void {
|
||||||
|
this.quotaErrorOccurred = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
getQuotaErrorOccurred(): boolean {
|
||||||
|
return this.quotaErrorOccurred;
|
||||||
|
}
|
||||||
|
|
||||||
getEmbeddingModel(): string {
|
getEmbeddingModel(): string {
|
||||||
return this.embeddingModel;
|
return this.embeddingModel;
|
||||||
}
|
}
|
||||||
@@ -1276,7 +1151,7 @@ export class Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getAuthType(): AuthType | undefined {
|
getAuthType(): AuthType | undefined {
|
||||||
return this.contentGeneratorConfig?.authType;
|
return this.contentGeneratorConfig.authType;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCliVersion(): string | undefined {
|
getCliVersion(): string | undefined {
|
||||||
|
|||||||
99
packages/core/src/config/flashFallback.test.ts
Normal file
99
packages/core/src/config/flashFallback.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { Config } from './config.js';
|
||||||
|
import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
vi.mock('node:fs');
|
||||||
|
|
||||||
|
describe('Flash Model Fallback Configuration', () => {
|
||||||
|
let config: Config;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.statSync).mockReturnValue({
|
||||||
|
isDirectory: () => true,
|
||||||
|
} as fs.Stats);
|
||||||
|
config = new Config({
|
||||||
|
targetDir: '/test',
|
||||||
|
debugMode: false,
|
||||||
|
cwd: '/test',
|
||||||
|
model: DEFAULT_GEMINI_MODEL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize contentGeneratorConfig for testing
|
||||||
|
(
|
||||||
|
config as unknown as { contentGeneratorConfig: unknown }
|
||||||
|
).contentGeneratorConfig = {
|
||||||
|
model: DEFAULT_GEMINI_MODEL,
|
||||||
|
authType: 'gemini-api-key',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// These tests do not actually test fallback. isInFallbackMode() only returns true,
|
||||||
|
// when setFallbackMode is marked as true. This is to decouple setting a model
|
||||||
|
// with the fallback mechanism. This will be necessary we introduce more
|
||||||
|
// intelligent model routing.
|
||||||
|
describe('setModel', () => {
|
||||||
|
it('should only mark as switched if contentGeneratorConfig exists', async () => {
|
||||||
|
// Create config without initializing contentGeneratorConfig
|
||||||
|
const newConfig = new Config({
|
||||||
|
targetDir: '/test',
|
||||||
|
debugMode: false,
|
||||||
|
cwd: '/test',
|
||||||
|
model: DEFAULT_GEMINI_MODEL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not crash when contentGeneratorConfig is undefined
|
||||||
|
await newConfig.setModel(DEFAULT_GEMINI_FLASH_MODEL);
|
||||||
|
expect(newConfig.isInFallbackMode()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getModel', () => {
|
||||||
|
it('should return contentGeneratorConfig model if available', async () => {
|
||||||
|
// Simulate initialized content generator config
|
||||||
|
await config.setModel(DEFAULT_GEMINI_FLASH_MODEL);
|
||||||
|
expect(config.getModel()).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to initial model if contentGeneratorConfig is not available', () => {
|
||||||
|
// Test with fresh config where contentGeneratorConfig might not be set
|
||||||
|
const newConfig = new Config({
|
||||||
|
targetDir: '/test',
|
||||||
|
debugMode: false,
|
||||||
|
cwd: '/test',
|
||||||
|
model: 'custom-model',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(newConfig.getModel()).toBe('custom-model');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isInFallbackMode', () => {
|
||||||
|
it('should start as false for new session', () => {
|
||||||
|
expect(config.isInFallbackMode()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remain false if no model switch occurs', () => {
|
||||||
|
// Perform other operations that don't involve model switching
|
||||||
|
expect(config.isInFallbackMode()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist switched state throughout session', async () => {
|
||||||
|
await config.setModel(DEFAULT_GEMINI_FLASH_MODEL);
|
||||||
|
// Setting state for fallback mode as is expected of clients
|
||||||
|
config.setFallbackMode(true);
|
||||||
|
expect(config.isInFallbackMode()).toBe(true);
|
||||||
|
|
||||||
|
// Should remain true even after getting model
|
||||||
|
config.getModel();
|
||||||
|
expect(config.isInFallbackMode()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
83
packages/core/src/config/models.test.ts
Normal file
83
packages/core/src/config/models.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
getEffectiveModel,
|
||||||
|
DEFAULT_GEMINI_MODEL,
|
||||||
|
DEFAULT_GEMINI_FLASH_MODEL,
|
||||||
|
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||||
|
} from './models.js';
|
||||||
|
|
||||||
|
describe('getEffectiveModel', () => {
|
||||||
|
describe('When NOT in fallback mode', () => {
|
||||||
|
const isInFallbackMode = false;
|
||||||
|
|
||||||
|
it('should return the Pro model when Pro is requested', () => {
|
||||||
|
const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL);
|
||||||
|
expect(model).toBe(DEFAULT_GEMINI_MODEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the Flash model when Flash is requested', () => {
|
||||||
|
const model = getEffectiveModel(
|
||||||
|
isInFallbackMode,
|
||||||
|
DEFAULT_GEMINI_FLASH_MODEL,
|
||||||
|
);
|
||||||
|
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the Lite model when Lite is requested', () => {
|
||||||
|
const model = getEffectiveModel(
|
||||||
|
isInFallbackMode,
|
||||||
|
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||||
|
);
|
||||||
|
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a custom model name when requested', () => {
|
||||||
|
const customModel = 'custom-model-v1';
|
||||||
|
const model = getEffectiveModel(isInFallbackMode, customModel);
|
||||||
|
expect(model).toBe(customModel);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('When IN fallback mode', () => {
|
||||||
|
const isInFallbackMode = true;
|
||||||
|
|
||||||
|
it('should downgrade the Pro model to the Flash model', () => {
|
||||||
|
const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL);
|
||||||
|
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the Flash model when Flash is requested', () => {
|
||||||
|
const model = getEffectiveModel(
|
||||||
|
isInFallbackMode,
|
||||||
|
DEFAULT_GEMINI_FLASH_MODEL,
|
||||||
|
);
|
||||||
|
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should HONOR the Lite model when Lite is requested', () => {
|
||||||
|
const model = getEffectiveModel(
|
||||||
|
isInFallbackMode,
|
||||||
|
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||||
|
);
|
||||||
|
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should HONOR any model with "lite" in its name', () => {
|
||||||
|
const customLiteModel = 'gemini-2.5-custom-lite-vNext';
|
||||||
|
const model = getEffectiveModel(isInFallbackMode, customLiteModel);
|
||||||
|
expect(model).toBe(customLiteModel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should downgrade any other custom model to the Flash model', () => {
|
||||||
|
const customModel = 'custom-model-v1-unlisted';
|
||||||
|
const model = getEffectiveModel(isInFallbackMode, customModel);
|
||||||
|
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,3 +7,46 @@
|
|||||||
export const DEFAULT_QWEN_MODEL = 'coder-model';
|
export const DEFAULT_QWEN_MODEL = 'coder-model';
|
||||||
export const DEFAULT_QWEN_FLASH_MODEL = 'coder-model';
|
export const DEFAULT_QWEN_FLASH_MODEL = 'coder-model';
|
||||||
export const DEFAULT_QWEN_EMBEDDING_MODEL = 'text-embedding-v4';
|
export const DEFAULT_QWEN_EMBEDDING_MODEL = 'text-embedding-v4';
|
||||||
|
|
||||||
|
export const DEFAULT_GEMINI_MODEL = 'coder-model';
|
||||||
|
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
|
||||||
|
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
|
||||||
|
|
||||||
|
export const DEFAULT_GEMINI_MODEL_AUTO = 'auto';
|
||||||
|
|
||||||
|
export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
|
||||||
|
|
||||||
|
// Some thinking models do not default to dynamic thinking which is done by a value of -1
|
||||||
|
export const DEFAULT_THINKING_MODE = -1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the effective model to use, applying fallback logic if necessary.
|
||||||
|
*
|
||||||
|
* When fallback mode is active, this function enforces the use of the standard
|
||||||
|
* fallback model. However, it makes an exception for "lite" models (any model
|
||||||
|
* with "lite" in its name), allowing them to be used to preserve cost savings.
|
||||||
|
* This ensures that "pro" models are always downgraded, while "lite" model
|
||||||
|
* requests are honored.
|
||||||
|
*
|
||||||
|
* @param isInFallbackMode Whether the application is in fallback mode.
|
||||||
|
* @param requestedModel The model that was originally requested.
|
||||||
|
* @returns The effective model name.
|
||||||
|
*/
|
||||||
|
export function getEffectiveModel(
|
||||||
|
isInFallbackMode: boolean,
|
||||||
|
requestedModel: string,
|
||||||
|
): string {
|
||||||
|
// If we are not in fallback mode, simply use the requested model.
|
||||||
|
if (!isInFallbackMode) {
|
||||||
|
return requestedModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a "lite" model is requested, honor it. This allows for variations of
|
||||||
|
// lite models without needing to list them all as constants.
|
||||||
|
if (requestedModel.includes('lite')) {
|
||||||
|
return requestedModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback for Gemini CLI.
|
||||||
|
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
type ChatCompressionInfo,
|
type ChatCompressionInfo,
|
||||||
} from './turn.js';
|
} from './turn.js';
|
||||||
import { getCoreSystemPrompt } from './prompts.js';
|
import { getCoreSystemPrompt } from './prompts.js';
|
||||||
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
|
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||||
import { setSimulate429 } from '../utils/testUtils.js';
|
import { setSimulate429 } from '../utils/testUtils.js';
|
||||||
import { tokenLimit } from './tokenLimits.js';
|
import { tokenLimit } from './tokenLimits.js';
|
||||||
@@ -302,6 +302,8 @@ describe('Gemini Client (client.ts)', () => {
|
|||||||
getFileService: vi.fn().mockReturnValue(fileService),
|
getFileService: vi.fn().mockReturnValue(fileService),
|
||||||
getMaxSessionTurns: vi.fn().mockReturnValue(0),
|
getMaxSessionTurns: vi.fn().mockReturnValue(0),
|
||||||
getSessionTokenLimit: vi.fn().mockReturnValue(32000),
|
getSessionTokenLimit: vi.fn().mockReturnValue(32000),
|
||||||
|
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
||||||
|
setQuotaErrorOccurred: vi.fn(),
|
||||||
getNoBrowser: vi.fn().mockReturnValue(false),
|
getNoBrowser: vi.fn().mockReturnValue(false),
|
||||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
|
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
|
||||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||||
@@ -315,6 +317,8 @@ describe('Gemini Client (client.ts)', () => {
|
|||||||
getModelRouterService: vi.fn().mockReturnValue({
|
getModelRouterService: vi.fn().mockReturnValue({
|
||||||
route: vi.fn().mockResolvedValue({ model: 'default-routed-model' }),
|
route: vi.fn().mockResolvedValue({ model: 'default-routed-model' }),
|
||||||
}),
|
}),
|
||||||
|
isInFallbackMode: vi.fn().mockReturnValue(false),
|
||||||
|
setFallbackMode: vi.fn(),
|
||||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||||
getChatCompression: vi.fn().mockReturnValue(undefined),
|
getChatCompression: vi.fn().mockReturnValue(undefined),
|
||||||
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
|
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
|
||||||
@@ -1058,18 +1062,26 @@ describe('Gemini Client (client.ts)', () => {
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(ideContextStore.get).toHaveBeenCalled();
|
expect(ideContextStore.get).toHaveBeenCalled();
|
||||||
const expectedContext = `Here is the user's editor context. This is for your information only.
|
const expectedContext = `
|
||||||
Active file:
|
Here is the user's editor context as a JSON object. This is for your information only.
|
||||||
Path: /path/to/active/file.ts
|
\`\`\`json
|
||||||
Cursor: line 5, character 10
|
${JSON.stringify(
|
||||||
Selected text:
|
{
|
||||||
|
activeFile: {
|
||||||
|
path: '/path/to/active/file.ts',
|
||||||
|
cursor: {
|
||||||
|
line: 5,
|
||||||
|
character: 10,
|
||||||
|
},
|
||||||
|
selectedText: 'hello',
|
||||||
|
},
|
||||||
|
otherOpenFiles: ['/path/to/recent/file1.ts', '/path/to/recent/file2.ts'],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
hello
|
`.trim();
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Other open files:
|
|
||||||
- /path/to/recent/file1.ts
|
|
||||||
- /path/to/recent/file2.ts`;
|
|
||||||
const expectedRequest = [{ text: expectedContext }];
|
const expectedRequest = [{ text: expectedContext }];
|
||||||
expect(mockChat.addHistory).toHaveBeenCalledWith({
|
expect(mockChat.addHistory).toHaveBeenCalledWith({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -1169,14 +1181,25 @@ Other open files:
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(ideContextStore.get).toHaveBeenCalled();
|
expect(ideContextStore.get).toHaveBeenCalled();
|
||||||
const expectedContext = `Here is the user's editor context. This is for your information only.
|
const expectedContext = `
|
||||||
Active file:
|
Here is the user's editor context as a JSON object. This is for your information only.
|
||||||
Path: /path/to/active/file.ts
|
\`\`\`json
|
||||||
Cursor: line 5, character 10
|
${JSON.stringify(
|
||||||
Selected text:
|
{
|
||||||
|
activeFile: {
|
||||||
|
path: '/path/to/active/file.ts',
|
||||||
|
cursor: {
|
||||||
|
line: 5,
|
||||||
|
character: 10,
|
||||||
|
},
|
||||||
|
selectedText: 'hello',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
hello
|
`.trim();
|
||||||
\`\`\``;
|
|
||||||
const expectedRequest = [{ text: expectedContext }];
|
const expectedRequest = [{ text: expectedContext }];
|
||||||
expect(mockChat.addHistory).toHaveBeenCalledWith({
|
expect(mockChat.addHistory).toHaveBeenCalledWith({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -1235,10 +1258,18 @@ hello
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(ideContextStore.get).toHaveBeenCalled();
|
expect(ideContextStore.get).toHaveBeenCalled();
|
||||||
const expectedContext = `Here is the user's editor context. This is for your information only.
|
const expectedContext = `
|
||||||
Other open files:
|
Here is the user's editor context as a JSON object. This is for your information only.
|
||||||
- /path/to/recent/file1.ts
|
\`\`\`json
|
||||||
- /path/to/recent/file2.ts`;
|
${JSON.stringify(
|
||||||
|
{
|
||||||
|
otherOpenFiles: ['/path/to/recent/file1.ts', '/path/to/recent/file2.ts'],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}
|
||||||
|
\`\`\`
|
||||||
|
`.trim();
|
||||||
const expectedRequest = [{ text: expectedContext }];
|
const expectedRequest = [{ text: expectedContext }];
|
||||||
expect(mockChat.addHistory).toHaveBeenCalledWith({
|
expect(mockChat.addHistory).toHaveBeenCalledWith({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -1755,9 +1786,11 @@ Other open files:
|
|||||||
// Also verify it's the full context, not a delta.
|
// Also verify it's the full context, not a delta.
|
||||||
const call = mockChat.addHistory.mock.calls[0][0];
|
const call = mockChat.addHistory.mock.calls[0][0];
|
||||||
const contextText = call.parts[0].text;
|
const contextText = call.parts[0].text;
|
||||||
// Verify it contains the active file information in plain text format
|
const contextJson = JSON.parse(
|
||||||
expect(contextText).toContain('Active file:');
|
contextText.match(/```json\n(.*)\n```/s)![1],
|
||||||
expect(contextText).toContain('Path: /path/to/active/file.ts');
|
);
|
||||||
|
expect(contextJson).toHaveProperty('activeFile');
|
||||||
|
expect(contextJson.activeFile.path).toBe('/path/to/active/file.ts');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1960,7 +1993,7 @@ Other open files:
|
|||||||
);
|
);
|
||||||
expect(contextCall).toBeDefined();
|
expect(contextCall).toBeDefined();
|
||||||
expect(JSON.stringify(contextCall![0])).toContain(
|
expect(JSON.stringify(contextCall![0])).toContain(
|
||||||
"Here is the user's editor context.",
|
"Here is the user's editor context as a JSON object",
|
||||||
);
|
);
|
||||||
// Check that the sent context is the new one (fileB.ts)
|
// Check that the sent context is the new one (fileB.ts)
|
||||||
expect(JSON.stringify(contextCall![0])).toContain('fileB.ts');
|
expect(JSON.stringify(contextCall![0])).toContain('fileB.ts');
|
||||||
@@ -1996,7 +2029,9 @@ Other open files:
|
|||||||
|
|
||||||
// Assert: Full context for fileA.ts was sent and stored.
|
// Assert: Full context for fileA.ts was sent and stored.
|
||||||
const initialCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0];
|
const initialCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0];
|
||||||
expect(JSON.stringify(initialCall)).toContain("user's editor context.");
|
expect(JSON.stringify(initialCall)).toContain(
|
||||||
|
"user's editor context as a JSON object",
|
||||||
|
);
|
||||||
expect(JSON.stringify(initialCall)).toContain('fileA.ts');
|
expect(JSON.stringify(initialCall)).toContain('fileA.ts');
|
||||||
// This implicitly tests that `lastSentIdeContext` is now set internally by the client.
|
// This implicitly tests that `lastSentIdeContext` is now set internally by the client.
|
||||||
vi.mocked(mockChat.addHistory!).mockClear();
|
vi.mocked(mockChat.addHistory!).mockClear();
|
||||||
@@ -2094,9 +2129,9 @@ Other open files:
|
|||||||
const finalCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0];
|
const finalCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0];
|
||||||
expect(JSON.stringify(finalCall)).toContain('summary of changes');
|
expect(JSON.stringify(finalCall)).toContain('summary of changes');
|
||||||
// The delta should reflect fileA being closed and fileC being opened.
|
// The delta should reflect fileA being closed and fileC being opened.
|
||||||
expect(JSON.stringify(finalCall)).toContain('Files closed');
|
expect(JSON.stringify(finalCall)).toContain('filesClosed');
|
||||||
expect(JSON.stringify(finalCall)).toContain('fileA.ts');
|
expect(JSON.stringify(finalCall)).toContain('fileA.ts');
|
||||||
expect(JSON.stringify(finalCall)).toContain('Active file changed');
|
expect(JSON.stringify(finalCall)).toContain('activeFileChanged');
|
||||||
expect(JSON.stringify(finalCall)).toContain('fileC.ts');
|
expect(JSON.stringify(finalCall)).toContain('fileC.ts');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2227,12 +2262,12 @@ Other open files:
|
|||||||
contents,
|
contents,
|
||||||
generationConfig,
|
generationConfig,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
DEFAULT_QWEN_FLASH_MODEL,
|
DEFAULT_GEMINI_FLASH_MODEL,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
|
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
model: DEFAULT_QWEN_FLASH_MODEL,
|
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||||
config: expect.objectContaining({
|
config: expect.objectContaining({
|
||||||
abortSignal,
|
abortSignal,
|
||||||
systemInstruction: getCoreSystemPrompt(''),
|
systemInstruction: getCoreSystemPrompt(''),
|
||||||
@@ -2255,7 +2290,7 @@ Other open files:
|
|||||||
contents,
|
contents,
|
||||||
{},
|
{},
|
||||||
new AbortController().signal,
|
new AbortController().signal,
|
||||||
DEFAULT_QWEN_FLASH_MODEL,
|
DEFAULT_GEMINI_FLASH_MODEL,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockContentGenerator.generateContent).not.toHaveBeenCalledWith({
|
expect(mockContentGenerator.generateContent).not.toHaveBeenCalledWith({
|
||||||
@@ -2265,7 +2300,7 @@ Other open files:
|
|||||||
});
|
});
|
||||||
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
|
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
model: DEFAULT_QWEN_FLASH_MODEL,
|
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||||
config: expect.any(Object),
|
config: expect.any(Object),
|
||||||
contents,
|
contents,
|
||||||
},
|
},
|
||||||
@@ -2273,7 +2308,28 @@ Other open files:
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: there is currently no "fallback mode" model routing; the model used
|
it('should use the Flash model when fallback mode is active', async () => {
|
||||||
// is always the one explicitly requested by the caller.
|
const contents = [{ role: 'user', parts: [{ text: 'hello' }] }];
|
||||||
|
const generationConfig = { temperature: 0.5 };
|
||||||
|
const abortSignal = new AbortController().signal;
|
||||||
|
const requestedModel = 'gemini-2.5-pro'; // A non-flash model
|
||||||
|
|
||||||
|
// Mock config to be in fallback mode
|
||||||
|
vi.spyOn(client['config'], 'isInFallbackMode').mockReturnValue(true);
|
||||||
|
|
||||||
|
await client.generateContent(
|
||||||
|
contents,
|
||||||
|
generationConfig,
|
||||||
|
abortSignal,
|
||||||
|
requestedModel,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockGenerateContentFn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||||
|
}),
|
||||||
|
'test-session-id',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
|
|
||||||
// Config
|
// Config
|
||||||
import { ApprovalMode, type Config } from '../config/config.js';
|
import { ApprovalMode, type Config } from '../config/config.js';
|
||||||
|
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||||
|
|
||||||
// Core modules
|
// Core modules
|
||||||
import type { ContentGenerator } from './contentGenerator.js';
|
import type { ContentGenerator } from './contentGenerator.js';
|
||||||
@@ -218,48 +219,42 @@ export class GeminiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (forceFullContext || !this.lastSentIdeContext) {
|
if (forceFullContext || !this.lastSentIdeContext) {
|
||||||
// Send full context as plain text
|
// Send full context as JSON
|
||||||
const openFiles = currentIdeContext.workspaceState?.openFiles || [];
|
const openFiles = currentIdeContext.workspaceState?.openFiles || [];
|
||||||
const activeFile = openFiles.find((f) => f.isActive);
|
const activeFile = openFiles.find((f) => f.isActive);
|
||||||
const otherOpenFiles = openFiles
|
const otherOpenFiles = openFiles
|
||||||
.filter((f) => !f.isActive)
|
.filter((f) => !f.isActive)
|
||||||
.map((f) => f.path);
|
.map((f) => f.path);
|
||||||
|
|
||||||
const contextLines: string[] = [];
|
const contextData: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (activeFile) {
|
if (activeFile) {
|
||||||
contextLines.push('Active file:');
|
contextData['activeFile'] = {
|
||||||
contextLines.push(` Path: ${activeFile.path}`);
|
path: activeFile.path,
|
||||||
if (activeFile.cursor) {
|
cursor: activeFile.cursor
|
||||||
contextLines.push(
|
? {
|
||||||
` Cursor: line ${activeFile.cursor.line}, character ${activeFile.cursor.character}`,
|
line: activeFile.cursor.line,
|
||||||
);
|
character: activeFile.cursor.character,
|
||||||
}
|
}
|
||||||
if (activeFile.selectedText) {
|
: undefined,
|
||||||
contextLines.push(' Selected text:');
|
selectedText: activeFile.selectedText || undefined,
|
||||||
contextLines.push('```');
|
};
|
||||||
contextLines.push(activeFile.selectedText);
|
|
||||||
contextLines.push('```');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (otherOpenFiles.length > 0) {
|
if (otherOpenFiles.length > 0) {
|
||||||
if (contextLines.length > 0) {
|
contextData['otherOpenFiles'] = otherOpenFiles;
|
||||||
contextLines.push('');
|
|
||||||
}
|
|
||||||
contextLines.push('Other open files:');
|
|
||||||
for (const filePath of otherOpenFiles) {
|
|
||||||
contextLines.push(` - ${filePath}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contextLines.length === 0) {
|
if (Object.keys(contextData).length === 0) {
|
||||||
return { contextParts: [], newIdeContext: currentIdeContext };
|
return { contextParts: [], newIdeContext: currentIdeContext };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(contextData, null, 2);
|
||||||
const contextParts = [
|
const contextParts = [
|
||||||
"Here is the user's editor context. This is for your information only.",
|
"Here is the user's editor context as a JSON object. This is for your information only.",
|
||||||
contextLines.join('\n'),
|
'```json',
|
||||||
|
jsonString,
|
||||||
|
'```',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (this.config.getDebugMode()) {
|
if (this.config.getDebugMode()) {
|
||||||
@@ -270,8 +265,9 @@ export class GeminiClient {
|
|||||||
newIdeContext: currentIdeContext,
|
newIdeContext: currentIdeContext,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Calculate and send delta as plain text
|
// Calculate and send delta as JSON
|
||||||
const changeLines: string[] = [];
|
const delta: Record<string, unknown> = {};
|
||||||
|
const changes: Record<string, unknown> = {};
|
||||||
|
|
||||||
const lastFiles = new Map(
|
const lastFiles = new Map(
|
||||||
(this.lastSentIdeContext.workspaceState?.openFiles || []).map(
|
(this.lastSentIdeContext.workspaceState?.openFiles || []).map(
|
||||||
@@ -292,10 +288,7 @@ export class GeminiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (openedFiles.length > 0) {
|
if (openedFiles.length > 0) {
|
||||||
changeLines.push('Files opened:');
|
changes['filesOpened'] = openedFiles;
|
||||||
for (const filePath of openedFiles) {
|
|
||||||
changeLines.push(` - ${filePath}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const closedFiles: string[] = [];
|
const closedFiles: string[] = [];
|
||||||
@@ -305,13 +298,7 @@ export class GeminiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (closedFiles.length > 0) {
|
if (closedFiles.length > 0) {
|
||||||
if (changeLines.length > 0) {
|
changes['filesClosed'] = closedFiles;
|
||||||
changeLines.push('');
|
|
||||||
}
|
|
||||||
changeLines.push('Files closed:');
|
|
||||||
for (const filePath of closedFiles) {
|
|
||||||
changeLines.push(` - ${filePath}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastActiveFile = (
|
const lastActiveFile = (
|
||||||
@@ -323,22 +310,16 @@ export class GeminiClient {
|
|||||||
|
|
||||||
if (currentActiveFile) {
|
if (currentActiveFile) {
|
||||||
if (!lastActiveFile || lastActiveFile.path !== currentActiveFile.path) {
|
if (!lastActiveFile || lastActiveFile.path !== currentActiveFile.path) {
|
||||||
if (changeLines.length > 0) {
|
changes['activeFileChanged'] = {
|
||||||
changeLines.push('');
|
path: currentActiveFile.path,
|
||||||
}
|
cursor: currentActiveFile.cursor
|
||||||
changeLines.push('Active file changed:');
|
? {
|
||||||
changeLines.push(` Path: ${currentActiveFile.path}`);
|
line: currentActiveFile.cursor.line,
|
||||||
if (currentActiveFile.cursor) {
|
character: currentActiveFile.cursor.character,
|
||||||
changeLines.push(
|
}
|
||||||
` Cursor: line ${currentActiveFile.cursor.line}, character ${currentActiveFile.cursor.character}`,
|
: undefined,
|
||||||
);
|
selectedText: currentActiveFile.selectedText || undefined,
|
||||||
}
|
};
|
||||||
if (currentActiveFile.selectedText) {
|
|
||||||
changeLines.push(' Selected text:');
|
|
||||||
changeLines.push('```');
|
|
||||||
changeLines.push(currentActiveFile.selectedText);
|
|
||||||
changeLines.push('```');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const lastCursor = lastActiveFile.cursor;
|
const lastCursor = lastActiveFile.cursor;
|
||||||
const currentCursor = currentActiveFile.cursor;
|
const currentCursor = currentActiveFile.cursor;
|
||||||
@@ -348,50 +329,42 @@ export class GeminiClient {
|
|||||||
lastCursor.line !== currentCursor.line ||
|
lastCursor.line !== currentCursor.line ||
|
||||||
lastCursor.character !== currentCursor.character)
|
lastCursor.character !== currentCursor.character)
|
||||||
) {
|
) {
|
||||||
if (changeLines.length > 0) {
|
changes['cursorMoved'] = {
|
||||||
changeLines.push('');
|
path: currentActiveFile.path,
|
||||||
}
|
cursor: {
|
||||||
changeLines.push('Cursor moved:');
|
line: currentCursor.line,
|
||||||
changeLines.push(` Path: ${currentActiveFile.path}`);
|
character: currentCursor.character,
|
||||||
changeLines.push(
|
},
|
||||||
` New position: line ${currentCursor.line}, character ${currentCursor.character}`,
|
};
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastSelectedText = lastActiveFile.selectedText || '';
|
const lastSelectedText = lastActiveFile.selectedText || '';
|
||||||
const currentSelectedText = currentActiveFile.selectedText || '';
|
const currentSelectedText = currentActiveFile.selectedText || '';
|
||||||
if (lastSelectedText !== currentSelectedText) {
|
if (lastSelectedText !== currentSelectedText) {
|
||||||
if (changeLines.length > 0) {
|
changes['selectionChanged'] = {
|
||||||
changeLines.push('');
|
path: currentActiveFile.path,
|
||||||
}
|
selectedText: currentSelectedText,
|
||||||
changeLines.push('Selection changed:');
|
};
|
||||||
changeLines.push(` Path: ${currentActiveFile.path}`);
|
|
||||||
if (currentSelectedText) {
|
|
||||||
changeLines.push(' Selected text:');
|
|
||||||
changeLines.push('```');
|
|
||||||
changeLines.push(currentSelectedText);
|
|
||||||
changeLines.push('```');
|
|
||||||
} else {
|
|
||||||
changeLines.push(' Selected text: (none)');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (lastActiveFile) {
|
} else if (lastActiveFile) {
|
||||||
if (changeLines.length > 0) {
|
changes['activeFileChanged'] = {
|
||||||
changeLines.push('');
|
path: null,
|
||||||
}
|
previousPath: lastActiveFile.path,
|
||||||
changeLines.push('Active file changed:');
|
};
|
||||||
changeLines.push(' No active file');
|
|
||||||
changeLines.push(` Previous path: ${lastActiveFile.path}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changeLines.length === 0) {
|
if (Object.keys(changes).length === 0) {
|
||||||
return { contextParts: [], newIdeContext: currentIdeContext };
|
return { contextParts: [], newIdeContext: currentIdeContext };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delta['changes'] = changes;
|
||||||
|
const jsonString = JSON.stringify(delta, null, 2);
|
||||||
const contextParts = [
|
const contextParts = [
|
||||||
"Here is a summary of changes in the user's editor context. This is for your information only.",
|
"Here is a summary of changes in the user's editor context, in JSON format. This is for your information only.",
|
||||||
changeLines.join('\n'),
|
'```json',
|
||||||
|
jsonString,
|
||||||
|
'```',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (this.config.getDebugMode()) {
|
if (this.config.getDebugMode()) {
|
||||||
@@ -569,6 +542,11 @@ export class GeminiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!turn.pendingToolCalls.length && signal && !signal.aborted) {
|
if (!turn.pendingToolCalls.length && signal && !signal.aborted) {
|
||||||
|
// Check if next speaker check is needed
|
||||||
|
if (this.config.getQuotaErrorOccurred()) {
|
||||||
|
return turn;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.config.getSkipNextSpeakerCheck()) {
|
if (this.config.getSkipNextSpeakerCheck()) {
|
||||||
return turn;
|
return turn;
|
||||||
}
|
}
|
||||||
@@ -624,11 +602,14 @@ export class GeminiClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const apiCall = () => {
|
const apiCall = () => {
|
||||||
currentAttemptModel = model;
|
const modelToUse = this.config.isInFallbackMode()
|
||||||
|
? DEFAULT_GEMINI_FLASH_MODEL
|
||||||
|
: model;
|
||||||
|
currentAttemptModel = modelToUse;
|
||||||
|
|
||||||
return this.getContentGeneratorOrFail().generateContent(
|
return this.getContentGeneratorOrFail().generateContent(
|
||||||
{
|
{
|
||||||
model,
|
model: modelToUse,
|
||||||
config: requestConfig,
|
config: requestConfig,
|
||||||
contents,
|
contents,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,11 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import {
|
import { createContentGenerator, AuthType } from './contentGenerator.js';
|
||||||
createContentGenerator,
|
|
||||||
createContentGeneratorConfig,
|
|
||||||
AuthType,
|
|
||||||
} from './contentGenerator.js';
|
|
||||||
import { GoogleGenAI } from '@google/genai';
|
import { GoogleGenAI } from '@google/genai';
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
import { LoggingContentGenerator } from './loggingContentGenerator/index.js';
|
import { LoggingContentGenerator } from './loggingContentGenerator/index.js';
|
||||||
@@ -82,32 +78,3 @@ describe('createContentGenerator', () => {
|
|||||||
expect(generator).toBeInstanceOf(LoggingContentGenerator);
|
expect(generator).toBeInstanceOf(LoggingContentGenerator);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createContentGeneratorConfig', () => {
|
|
||||||
const mockConfig = {
|
|
||||||
getProxy: () => undefined,
|
|
||||||
} as unknown as Config;
|
|
||||||
|
|
||||||
it('should preserve provided fields and set authType for QWEN_OAUTH', () => {
|
|
||||||
const cfg = createContentGeneratorConfig(mockConfig, AuthType.QWEN_OAUTH, {
|
|
||||||
model: 'vision-model',
|
|
||||||
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
|
|
||||||
});
|
|
||||||
expect(cfg.authType).toBe(AuthType.QWEN_OAUTH);
|
|
||||||
expect(cfg.model).toBe('vision-model');
|
|
||||||
expect(cfg.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not warn or fallback for QWEN_OAUTH (resolution handled by ModelConfigResolver)', () => {
|
|
||||||
const warnSpy = vi
|
|
||||||
.spyOn(console, 'warn')
|
|
||||||
.mockImplementation(() => undefined);
|
|
||||||
const cfg = createContentGeneratorConfig(mockConfig, AuthType.QWEN_OAUTH, {
|
|
||||||
model: 'some-random-model',
|
|
||||||
});
|
|
||||||
expect(cfg.model).toBe('some-random-model');
|
|
||||||
expect(cfg.apiKey).toBeUndefined();
|
|
||||||
expect(warnSpy).not.toHaveBeenCalled();
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -12,24 +12,9 @@ import type {
|
|||||||
GenerateContentParameters,
|
GenerateContentParameters,
|
||||||
GenerateContentResponse,
|
GenerateContentResponse,
|
||||||
} from '@google/genai';
|
} from '@google/genai';
|
||||||
|
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
import { LoggingContentGenerator } from './loggingContentGenerator/index.js';
|
import { LoggingContentGenerator } from './loggingContentGenerator/index.js';
|
||||||
import type {
|
|
||||||
ConfigSource,
|
|
||||||
ConfigSourceKind,
|
|
||||||
ConfigSources,
|
|
||||||
} from '../utils/configResolver.js';
|
|
||||||
import {
|
|
||||||
getDefaultApiKeyEnvVar,
|
|
||||||
getDefaultModelEnvVar,
|
|
||||||
MissingAnthropicBaseUrlEnvError,
|
|
||||||
MissingApiKeyError,
|
|
||||||
MissingBaseUrlError,
|
|
||||||
MissingModelError,
|
|
||||||
StrictMissingCredentialsError,
|
|
||||||
StrictMissingModelIdError,
|
|
||||||
} from '../models/modelConfigErrors.js';
|
|
||||||
import { PROVIDER_SOURCED_FIELDS } from '../models/modelsConfig.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface abstracting the core functionalities for generating content and counting tokens.
|
* Interface abstracting the core functionalities for generating content and counting tokens.
|
||||||
@@ -63,7 +48,6 @@ export enum AuthType {
|
|||||||
export type ContentGeneratorConfig = {
|
export type ContentGeneratorConfig = {
|
||||||
model: string;
|
model: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
apiKeyEnvKey?: string;
|
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
vertexai?: boolean;
|
vertexai?: boolean;
|
||||||
authType?: AuthType | undefined;
|
authType?: AuthType | undefined;
|
||||||
@@ -93,178 +77,102 @@ export type ContentGeneratorConfig = {
|
|||||||
schemaCompliance?: 'auto' | 'openapi_30';
|
schemaCompliance?: 'auto' | 'openapi_30';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep the public ContentGeneratorConfigSources API, but reuse the generic
|
|
||||||
// source-tracking types from utils/configResolver to avoid duplication.
|
|
||||||
export type ContentGeneratorConfigSourceKind = ConfigSourceKind;
|
|
||||||
export type ContentGeneratorConfigSource = ConfigSource;
|
|
||||||
export type ContentGeneratorConfigSources = ConfigSources;
|
|
||||||
|
|
||||||
export type ResolvedContentGeneratorConfig = {
|
|
||||||
config: ContentGeneratorConfig;
|
|
||||||
sources: ContentGeneratorConfigSources;
|
|
||||||
};
|
|
||||||
|
|
||||||
function setSource(
|
|
||||||
sources: ContentGeneratorConfigSources,
|
|
||||||
path: string,
|
|
||||||
source: ContentGeneratorConfigSource,
|
|
||||||
): void {
|
|
||||||
sources[path] = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSeedSource(
|
|
||||||
seed: ContentGeneratorConfigSources | undefined,
|
|
||||||
path: string,
|
|
||||||
): ContentGeneratorConfigSource | undefined {
|
|
||||||
return seed?.[path];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve ContentGeneratorConfig while tracking the source of each effective field.
|
|
||||||
*
|
|
||||||
* This function now primarily validates and finalizes the configuration that has
|
|
||||||
* already been resolved by ModelConfigResolver. The env fallback logic has been
|
|
||||||
* moved to the unified resolver to eliminate duplication.
|
|
||||||
*
|
|
||||||
* Note: The generationConfig passed here should already be fully resolved with
|
|
||||||
* proper source tracking from the caller (CLI/SDK layer).
|
|
||||||
*/
|
|
||||||
export function resolveContentGeneratorConfigWithSources(
|
|
||||||
config: Config,
|
|
||||||
authType: AuthType | undefined,
|
|
||||||
generationConfig?: Partial<ContentGeneratorConfig>,
|
|
||||||
seedSources?: ContentGeneratorConfigSources,
|
|
||||||
options?: { strictModelProvider?: boolean },
|
|
||||||
): ResolvedContentGeneratorConfig {
|
|
||||||
const sources: ContentGeneratorConfigSources = { ...(seedSources || {}) };
|
|
||||||
const strictModelProvider = options?.strictModelProvider === true;
|
|
||||||
|
|
||||||
// Build config with computed fields
|
|
||||||
const newContentGeneratorConfig: Partial<ContentGeneratorConfig> = {
|
|
||||||
...(generationConfig || {}),
|
|
||||||
authType,
|
|
||||||
proxy: config?.getProxy(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set sources for computed fields
|
|
||||||
setSource(sources, 'authType', {
|
|
||||||
kind: 'computed',
|
|
||||||
detail: 'provided by caller',
|
|
||||||
});
|
|
||||||
if (config?.getProxy()) {
|
|
||||||
setSource(sources, 'proxy', {
|
|
||||||
kind: 'computed',
|
|
||||||
detail: 'Config.getProxy()',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve seed sources for fields that were passed in
|
|
||||||
const seedOrUnknown = (path: string): ContentGeneratorConfigSource =>
|
|
||||||
getSeedSource(seedSources, path) ?? { kind: 'unknown' };
|
|
||||||
|
|
||||||
for (const field of PROVIDER_SOURCED_FIELDS) {
|
|
||||||
if (generationConfig && field in generationConfig && !sources[field]) {
|
|
||||||
setSource(sources, field, seedOrUnknown(field));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate required fields based on authType. This does not perform any
|
|
||||||
// fallback resolution (resolution is handled by ModelConfigResolver).
|
|
||||||
const validation = validateModelConfig(
|
|
||||||
newContentGeneratorConfig as ContentGeneratorConfig,
|
|
||||||
strictModelProvider,
|
|
||||||
);
|
|
||||||
if (!validation.valid) {
|
|
||||||
throw new Error(validation.errors.map((e) => e.message).join('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
config: newContentGeneratorConfig as ContentGeneratorConfig,
|
|
||||||
sources,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModelConfigValidationResult {
|
|
||||||
valid: boolean;
|
|
||||||
errors: Error[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a resolved model configuration.
|
|
||||||
* This is the single validation entry point used across Core.
|
|
||||||
*/
|
|
||||||
export function validateModelConfig(
|
|
||||||
config: ContentGeneratorConfig,
|
|
||||||
isStrictModelProvider: boolean = false,
|
|
||||||
): ModelConfigValidationResult {
|
|
||||||
const errors: Error[] = [];
|
|
||||||
|
|
||||||
// Qwen OAuth doesn't need validation - it uses dynamic tokens
|
|
||||||
if (config.authType === AuthType.QWEN_OAUTH) {
|
|
||||||
return { valid: true, errors: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// API key is required for all other auth types
|
|
||||||
if (!config.apiKey) {
|
|
||||||
if (isStrictModelProvider) {
|
|
||||||
errors.push(
|
|
||||||
new StrictMissingCredentialsError(
|
|
||||||
config.authType,
|
|
||||||
config.model,
|
|
||||||
config.apiKeyEnvKey,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const envKey =
|
|
||||||
config.apiKeyEnvKey || getDefaultApiKeyEnvVar(config.authType);
|
|
||||||
errors.push(
|
|
||||||
new MissingApiKeyError({
|
|
||||||
authType: config.authType,
|
|
||||||
model: config.model,
|
|
||||||
baseUrl: config.baseUrl,
|
|
||||||
envKey,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model is required
|
|
||||||
if (!config.model) {
|
|
||||||
if (isStrictModelProvider) {
|
|
||||||
errors.push(new StrictMissingModelIdError(config.authType));
|
|
||||||
} else {
|
|
||||||
const envKey = getDefaultModelEnvVar(config.authType);
|
|
||||||
errors.push(new MissingModelError({ authType: config.authType, envKey }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicit baseUrl is required for Anthropic; Migrated from existing code.
|
|
||||||
if (config.authType === AuthType.USE_ANTHROPIC && !config.baseUrl) {
|
|
||||||
if (isStrictModelProvider) {
|
|
||||||
errors.push(
|
|
||||||
new MissingBaseUrlError({
|
|
||||||
authType: config.authType,
|
|
||||||
model: config.model,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else if (config.authType === AuthType.USE_ANTHROPIC) {
|
|
||||||
errors.push(new MissingAnthropicBaseUrlEnvError());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createContentGeneratorConfig(
|
export function createContentGeneratorConfig(
|
||||||
config: Config,
|
config: Config,
|
||||||
authType: AuthType | undefined,
|
authType: AuthType | undefined,
|
||||||
generationConfig?: Partial<ContentGeneratorConfig>,
|
generationConfig?: Partial<ContentGeneratorConfig>,
|
||||||
): ContentGeneratorConfig {
|
): ContentGeneratorConfig {
|
||||||
return resolveContentGeneratorConfigWithSources(
|
let newContentGeneratorConfig: Partial<ContentGeneratorConfig> = {
|
||||||
config,
|
...(generationConfig || {}),
|
||||||
authType,
|
authType,
|
||||||
generationConfig,
|
proxy: config?.getProxy(),
|
||||||
).config;
|
};
|
||||||
|
|
||||||
|
if (authType === AuthType.QWEN_OAUTH) {
|
||||||
|
// For Qwen OAuth, we'll handle the API key dynamically in createContentGenerator
|
||||||
|
// Set a special marker to indicate this is Qwen OAuth
|
||||||
|
return {
|
||||||
|
...newContentGeneratorConfig,
|
||||||
|
model: DEFAULT_QWEN_MODEL,
|
||||||
|
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
|
||||||
|
} as ContentGeneratorConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === AuthType.USE_OPENAI) {
|
||||||
|
newContentGeneratorConfig = {
|
||||||
|
...newContentGeneratorConfig,
|
||||||
|
apiKey: newContentGeneratorConfig.apiKey || process.env['OPENAI_API_KEY'],
|
||||||
|
baseUrl:
|
||||||
|
newContentGeneratorConfig.baseUrl || process.env['OPENAI_BASE_URL'],
|
||||||
|
model: newContentGeneratorConfig.model || process.env['OPENAI_MODEL'],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!newContentGeneratorConfig.apiKey) {
|
||||||
|
throw new Error('OPENAI_API_KEY environment variable not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...newContentGeneratorConfig,
|
||||||
|
model: newContentGeneratorConfig?.model || 'qwen3-coder-plus',
|
||||||
|
} as ContentGeneratorConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === AuthType.USE_ANTHROPIC) {
|
||||||
|
newContentGeneratorConfig = {
|
||||||
|
...newContentGeneratorConfig,
|
||||||
|
apiKey:
|
||||||
|
newContentGeneratorConfig.apiKey || process.env['ANTHROPIC_API_KEY'],
|
||||||
|
baseUrl:
|
||||||
|
newContentGeneratorConfig.baseUrl || process.env['ANTHROPIC_BASE_URL'],
|
||||||
|
model: newContentGeneratorConfig.model || process.env['ANTHROPIC_MODEL'],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!newContentGeneratorConfig.apiKey) {
|
||||||
|
throw new Error('ANTHROPIC_API_KEY environment variable not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newContentGeneratorConfig.baseUrl) {
|
||||||
|
throw new Error('ANTHROPIC_BASE_URL environment variable not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newContentGeneratorConfig.model) {
|
||||||
|
throw new Error('ANTHROPIC_MODEL environment variable not found.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === AuthType.USE_GEMINI) {
|
||||||
|
newContentGeneratorConfig = {
|
||||||
|
...newContentGeneratorConfig,
|
||||||
|
apiKey: newContentGeneratorConfig.apiKey || process.env['GEMINI_API_KEY'],
|
||||||
|
model: newContentGeneratorConfig.model || process.env['GEMINI_MODEL'],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!newContentGeneratorConfig.apiKey) {
|
||||||
|
throw new Error('GEMINI_API_KEY environment variable not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newContentGeneratorConfig.model) {
|
||||||
|
throw new Error('GEMINI_MODEL environment variable not found.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === AuthType.USE_VERTEX_AI) {
|
||||||
|
newContentGeneratorConfig = {
|
||||||
|
...newContentGeneratorConfig,
|
||||||
|
apiKey: newContentGeneratorConfig.apiKey || process.env['GOOGLE_API_KEY'],
|
||||||
|
model: newContentGeneratorConfig.model || process.env['GOOGLE_MODEL'],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!newContentGeneratorConfig.apiKey) {
|
||||||
|
throw new Error('GOOGLE_API_KEY environment variable not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newContentGeneratorConfig.model) {
|
||||||
|
throw new Error('GOOGLE_MODEL environment variable not found.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newContentGeneratorConfig as ContentGeneratorConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createContentGenerator(
|
export async function createContentGenerator(
|
||||||
@@ -272,12 +180,11 @@ export async function createContentGenerator(
|
|||||||
gcConfig: Config,
|
gcConfig: Config,
|
||||||
isInitialAuth?: boolean,
|
isInitialAuth?: boolean,
|
||||||
): Promise<ContentGenerator> {
|
): Promise<ContentGenerator> {
|
||||||
const validation = validateModelConfig(config, false);
|
|
||||||
if (!validation.valid) {
|
|
||||||
throw new Error(validation.errors.map((e) => e.message).join('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.authType === AuthType.USE_OPENAI) {
|
if (config.authType === AuthType.USE_OPENAI) {
|
||||||
|
if (!config.apiKey) {
|
||||||
|
throw new Error('OPENAI_API_KEY environment variable not found.');
|
||||||
|
}
|
||||||
|
|
||||||
// Import OpenAIContentGenerator dynamically to avoid circular dependencies
|
// Import OpenAIContentGenerator dynamically to avoid circular dependencies
|
||||||
const { createOpenAIContentGenerator } = await import(
|
const { createOpenAIContentGenerator } = await import(
|
||||||
'./openaiContentGenerator/index.js'
|
'./openaiContentGenerator/index.js'
|
||||||
@@ -316,6 +223,10 @@ export async function createContentGenerator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (config.authType === AuthType.USE_ANTHROPIC) {
|
if (config.authType === AuthType.USE_ANTHROPIC) {
|
||||||
|
if (!config.apiKey) {
|
||||||
|
throw new Error('ANTHROPIC_API_KEY environment variable not found.');
|
||||||
|
}
|
||||||
|
|
||||||
const { createAnthropicContentGenerator } = await import(
|
const { createAnthropicContentGenerator } = await import(
|
||||||
'./anthropicContentGenerator/index.js'
|
'./anthropicContentGenerator/index.js'
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user