mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-24 18:49:13 +00:00
Compare commits
1 Commits
chore-pome
...
fix/auth-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4249a8a7d9 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -47,5 +47,3 @@ packages/vscode-ide-companion/*.vsix
|
||||
logs/
|
||||
# GHA credentials
|
||||
gha-creds-*.json
|
||||
|
||||
QWEN.md
|
||||
193
QWEN.md
Normal file
193
QWEN.md
Normal file
@@ -0,0 +1,193 @@
|
||||
## Building and running
|
||||
|
||||
Before submitting any changes, it is crucial to validate them by running the full preflight check. This command will build the repository, run all tests, check for type errors, and lint the code.
|
||||
|
||||
To run the full suite of checks, execute the following command:
|
||||
|
||||
```bash
|
||||
npm run preflight
|
||||
```
|
||||
|
||||
This single command ensures that your changes meet all the quality gates of the project. While you can run the individual steps (`build`, `test`, `typecheck`, `lint`) separately, it is highly recommended to use `npm run preflight` to ensure a comprehensive validation.
|
||||
|
||||
## Writing Tests
|
||||
|
||||
This project uses **Vitest** as its primary testing framework. When writing tests, aim to follow existing patterns. Key conventions include:
|
||||
|
||||
### Test Structure and Framework
|
||||
|
||||
- **Framework**: All tests are written using Vitest (`describe`, `it`, `expect`, `vi`).
|
||||
- **File Location**: Test files (`*.test.ts` for logic, `*.test.tsx` for React components) are co-located with the source files they test.
|
||||
- **Configuration**: Test environments are defined in `vitest.config.ts` files.
|
||||
- **Setup/Teardown**: Use `beforeEach` and `afterEach`. Commonly, `vi.resetAllMocks()` is called in `beforeEach` and `vi.restoreAllMocks()` in `afterEach`.
|
||||
|
||||
### Mocking (`vi` from Vitest)
|
||||
|
||||
- **ES Modules**: Mock with `vi.mock('module-name', async (importOriginal) => { ... })`. Use `importOriginal` for selective mocking.
|
||||
- _Example_: `vi.mock('os', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: vi.fn() }; });`
|
||||
- **Mocking Order**: For critical dependencies (e.g., `os`, `fs`) that affect module-level constants, place `vi.mock` at the _very top_ of the test file, before other imports.
|
||||
- **Hoisting**: Use `const myMock = vi.hoisted(() => vi.fn());` if a mock function needs to be defined before its use in a `vi.mock` factory.
|
||||
- **Mock Functions**: Create with `vi.fn()`. Define behavior with `mockImplementation()`, `mockResolvedValue()`, or `mockRejectedValue()`.
|
||||
- **Spying**: Use `vi.spyOn(object, 'methodName')`. Restore spies with `mockRestore()` in `afterEach`.
|
||||
|
||||
### Commonly Mocked Modules
|
||||
|
||||
- **Node.js built-ins**: `fs`, `fs/promises`, `os` (especially `os.homedir()`), `path`, `child_process` (`execSync`, `spawn`).
|
||||
- **External SDKs**: `@google/genai`, `@modelcontextprotocol/sdk`.
|
||||
- **Internal Project Modules**: Dependencies from other project packages are often mocked.
|
||||
|
||||
### React Component Testing (CLI UI - Ink)
|
||||
|
||||
- Use `render()` from `ink-testing-library`.
|
||||
- Assert output with `lastFrame()`.
|
||||
- Wrap components in necessary `Context.Provider`s.
|
||||
- Mock custom React hooks and complex child components using `vi.mock()`.
|
||||
|
||||
### Asynchronous Testing
|
||||
|
||||
- Use `async/await`.
|
||||
- For timers, use `vi.useFakeTimers()`, `vi.advanceTimersByTimeAsync()`, `vi.runAllTimersAsync()`.
|
||||
- Test promise rejections with `await expect(promise).rejects.toThrow(...)`.
|
||||
|
||||
### General Guidance
|
||||
|
||||
- When adding tests, first examine existing tests to understand and conform to established conventions.
|
||||
- Pay close attention to the mocks at the top of existing test files; they reveal critical dependencies and how they are managed in a test environment.
|
||||
|
||||
## Git Repo
|
||||
|
||||
The main branch for this project is called "main"
|
||||
|
||||
## JavaScript/TypeScript
|
||||
|
||||
When contributing to this React, Node, and TypeScript codebase, please prioritize the use of plain JavaScript objects with accompanying TypeScript interface or type declarations over JavaScript class syntax. This approach offers significant advantages, especially concerning interoperability with React and overall code maintainability.
|
||||
|
||||
### Preferring Plain Objects over Classes
|
||||
|
||||
JavaScript classes, by their nature, are designed to encapsulate internal state and behavior. While this can be useful in some object-oriented paradigms, it often introduces unnecessary complexity and friction when working with React's component-based architecture. Here's why plain objects are preferred:
|
||||
|
||||
- Seamless React Integration: React components thrive on explicit props and state management. Classes' tendency to store internal state directly within instances can make prop and state propagation harder to reason about and maintain. Plain objects, on the other hand, are inherently immutable (when used thoughtfully) and can be easily passed as props, simplifying data flow and reducing unexpected side effects.
|
||||
|
||||
- Reduced Boilerplate and Increased Conciseness: Classes often promote the use of constructors, this binding, getters, setters, and other boilerplate that can unnecessarily bloat code. TypeScript interface and type declarations provide powerful static type checking without the runtime overhead or verbosity of class definitions. This allows for more succinct and readable code, aligning with JavaScript's strengths in functional programming.
|
||||
|
||||
- Enhanced Readability and Predictability: Plain objects, especially when their structure is clearly defined by TypeScript interfaces, are often easier to read and understand. Their properties are directly accessible, and there's no hidden internal state or complex inheritance chains to navigate. This predictability leads to fewer bugs and a more maintainable codebase.
|
||||
|
||||
- Simplified Immutability: While not strictly enforced, plain objects encourage an immutable approach to data. When you need to modify an object, you typically create a new one with the desired changes, rather than mutating the original. This pattern aligns perfectly with React's reconciliation process and helps prevent subtle bugs related to shared mutable state.
|
||||
|
||||
- Better Serialization and Deserialization: Plain JavaScript objects are naturally easy to serialize to JSON and deserialize back, which is a common requirement in web development (e.g., for API communication or local storage). Classes, with their methods and prototypes, can complicate this process.
|
||||
|
||||
### Embracing ES Module Syntax for Encapsulation
|
||||
|
||||
Rather than relying on Java-esque private or public class members, which can be verbose and sometimes limit flexibility, we strongly prefer leveraging ES module syntax (`import`/`export`) for encapsulating private and public APIs.
|
||||
|
||||
- Clearer Public API Definition: With ES modules, anything that is exported is part of the public API of that module, while anything not exported is inherently private to that module. This provides a very clear and explicit way to define what parts of your code are meant to be consumed by other modules.
|
||||
|
||||
- Enhanced Testability (Without Exposing Internals): By default, unexported functions or variables are not accessible from outside the module. This encourages you to test the public API of your modules, rather than their internal implementation details. If you find yourself needing to spy on or stub an unexported function for testing purposes, it's often a "code smell" indicating that the function might be a good candidate for extraction into its own separate, testable module with a well-defined public API. This promotes a more robust and maintainable testing strategy.
|
||||
|
||||
- Reduced Coupling: Explicitly defined module boundaries through import/export help reduce coupling between different parts of your codebase. This makes it easier to refactor, debug, and understand individual components in isolation.
|
||||
|
||||
### Avoiding `any` Types and Type Assertions; Preferring `unknown`
|
||||
|
||||
TypeScript's power lies in its ability to provide static type checking, catching potential errors before your code runs. To fully leverage this, it's crucial to avoid the `any` type and be judicious with type assertions.
|
||||
|
||||
- **The Dangers of `any`**: Using any effectively opts out of TypeScript's type checking for that particular variable or expression. While it might seem convenient in the short term, it introduces significant risks:
|
||||
- **Loss of Type Safety**: You lose all the benefits of type checking, making it easy to introduce runtime errors that TypeScript would otherwise have caught.
|
||||
- **Reduced Readability and Maintainability**: Code with `any` types is harder to understand and maintain, as the expected type of data is no longer explicitly defined.
|
||||
- **Masking Underlying Issues**: Often, the need for any indicates a deeper problem in the design of your code or the way you're interacting with external libraries. It's a sign that you might need to refine your types or refactor your code.
|
||||
|
||||
- **Preferring `unknown` over `any`**: When you absolutely cannot determine the type of a value at compile time, and you're tempted to reach for any, consider using unknown instead. unknown is a type-safe counterpart to any. While a variable of type unknown can hold any value, you must perform type narrowing (e.g., using typeof or instanceof checks, or a type assertion) before you can perform any operations on it. This forces you to handle the unknown type explicitly, preventing accidental runtime errors.
|
||||
|
||||
```ts
|
||||
function processValue(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
// value is now safely a string
|
||||
console.log(value.toUpperCase());
|
||||
} else if (typeof value === 'number') {
|
||||
// value is now safely a number
|
||||
console.log(value * 2);
|
||||
}
|
||||
// Without narrowing, you cannot access properties or methods on 'value'
|
||||
// console.log(value.someProperty); // Error: Object is of type 'unknown'.
|
||||
}
|
||||
```
|
||||
|
||||
- **Type Assertions (`as Type`) - Use with Caution**: Type assertions tell the TypeScript compiler, "Trust me, I know what I'm doing; this is definitely of this type." While there are legitimate use cases (e.g., when dealing with external libraries that don't have perfect type definitions, or when you have more information than the compiler), they should be used sparingly and with extreme caution.
|
||||
- **Bypassing Type Checking**: Like `any`, type assertions bypass TypeScript's safety checks. If your assertion is incorrect, you introduce a runtime error that TypeScript would not have warned you about.
|
||||
- **Code Smell in Testing**: A common scenario where `any` or type assertions might be tempting is when trying to test "private" implementation details (e.g., spying on or stubbing an unexported function within a module). This is a strong indication of a "code smell" in your testing strategy and potentially your code structure. Instead of trying to force access to private internals, consider whether those internal details should be refactored into a separate module with a well-defined public API. This makes them inherently testable without compromising encapsulation.
|
||||
|
||||
### Type narrowing `switch` clauses
|
||||
|
||||
Use the `checkExhaustive` helper in the default clause of a switch statement.
|
||||
This will ensure that all of the possible options within the value or
|
||||
enumeration are used.
|
||||
|
||||
This helper method can be found in `packages/cli/src/utils/checks.ts`
|
||||
|
||||
### Embracing JavaScript's Array Operators
|
||||
|
||||
To further enhance code cleanliness and promote safe functional programming practices, leverage JavaScript's rich set of array operators as much as possible. Methods like `.map()`, `.filter()`, `.reduce()`, `.slice()`, `.sort()`, and others are incredibly powerful for transforming and manipulating data collections in an immutable and declarative way.
|
||||
|
||||
Using these operators:
|
||||
|
||||
- Promotes Immutability: Most array operators return new arrays, leaving the original array untouched. This functional approach helps prevent unintended side effects and makes your code more predictable.
|
||||
- Improves Readability: Chaining array operators often lead to more concise and expressive code than traditional for loops or imperative logic. The intent of the operation is clear at a glance.
|
||||
- Facilitates Functional Programming: These operators are cornerstones of functional programming, encouraging the creation of pure functions that take inputs and produce outputs without causing side effects. This paradigm is highly beneficial for writing robust and testable code that pairs well with React.
|
||||
|
||||
By consistently applying these principles, we can maintain a codebase that is not only efficient and performant but also a joy to work with, both now and in the future.
|
||||
|
||||
## React (mirrored and adjusted from [react-mcp-server](https://github.com/facebook/react/blob/4448b18760d867f9e009e810571e7a3b8930bb19/compiler/packages/react-mcp-server/src/index.ts#L376C1-L441C94))
|
||||
|
||||
### Role
|
||||
|
||||
You are a React assistant that helps users write more efficient and optimizable React code. You specialize in identifying patterns that enable React Compiler to automatically apply optimizations, reducing unnecessary re-renders and improving application performance.
|
||||
|
||||
### Follow these guidelines in all code you produce and suggest
|
||||
|
||||
Use functional components with Hooks: Do not generate class components or use old lifecycle methods. Manage state with useState or useReducer, and side effects with useEffect (or related Hooks). Always prefer functions and Hooks for any new component logic.
|
||||
|
||||
Keep components pure and side-effect-free during rendering: Do not produce code that performs side effects (like subscriptions, network requests, or modifying external variables) directly inside the component's function body. Such actions should be wrapped in useEffect or performed in event handlers. Ensure your render logic is a pure function of props and state.
|
||||
|
||||
Respect one-way data flow: Pass data down through props and avoid any global mutations. If two components need to share data, lift that state up to a common parent or use React Context, rather than trying to sync local state or use external variables.
|
||||
|
||||
Never mutate state directly: Always generate code that updates state immutably. For example, use spread syntax or other methods to create new objects/arrays when updating state. Do not use assignments like state.someValue = ... or array mutations like array.push() on state variables. Use the state setter (setState from useState, etc.) to update state.
|
||||
|
||||
Accurately use useEffect and other effect Hooks: whenever you think you could useEffect, think and reason harder to avoid it. useEffect is primarily only used for synchronization, for example synchronizing React with some external state. IMPORTANT - Don't setState (the 2nd value returned by useState) within a useEffect as that will degrade performance. When writing effects, include all necessary dependencies in the dependency array. Do not suppress ESLint rules or omit dependencies that the effect's code uses. Structure the effect callbacks to handle changing values properly (e.g., update subscriptions on prop changes, clean up on unmount or dependency change). If a piece of logic should only run in response to a user action (like a form submission or button click), put that logic in an event handler, not in a useEffect. Where possible, useEffects should return a cleanup function.
|
||||
|
||||
Follow the Rules of Hooks: Ensure that any Hooks (useState, useEffect, useContext, custom Hooks, etc.) are called unconditionally at the top level of React function components or other Hooks. Do not generate code that calls Hooks inside loops, conditional statements, or nested helper functions. Do not call Hooks in non-component functions or outside the React component rendering context.
|
||||
|
||||
Use refs only when necessary: Avoid using useRef unless the task genuinely requires it (such as focusing a control, managing an animation, or integrating with a non-React library). Do not use refs to store application state that should be reactive. If you do use refs, never write to or read from ref.current during the rendering of a component (except for initial setup like lazy initialization). Any ref usage should not affect the rendered output directly.
|
||||
|
||||
Prefer composition and small components: Break down UI into small, reusable components rather than writing large monolithic components. The code you generate should promote clarity and reusability by composing components together. Similarly, abstract repetitive logic into custom Hooks when appropriate to avoid duplicating code.
|
||||
|
||||
Optimize for concurrency: Assume React may render your components multiple times for scheduling purposes (especially in development with Strict Mode). Write code that remains correct even if the component function runs more than once. For instance, avoid side effects in the component body and use functional state updates (e.g., setCount(c => c + 1)) when updating state based on previous state to prevent race conditions. Always include cleanup functions in effects that subscribe to external resources. Don't write useEffects for "do this when this changes" side effects. This ensures your generated code will work with React's concurrent rendering features without issues.
|
||||
|
||||
Optimize to reduce network waterfalls - Use parallel data fetching wherever possible (e.g., start multiple requests at once rather than one after another). Leverage Suspense for data loading and keep requests co-located with the component that needs the data. In a server-centric approach, fetch related data together in a single request on the server side (using Server Components, for example) to reduce round trips. Also, consider using caching layers or global fetch management to avoid repeating identical requests.
|
||||
|
||||
Rely on React Compiler - useMemo, useCallback, and React.memo can be omitted if React Compiler is enabled. Avoid premature optimization with manual memoization. Instead, focus on writing clear, simple components with direct data flow and side-effect-free render functions. Let the React Compiler handle tree-shaking, inlining, and other performance enhancements to keep your code base simpler and more maintainable.
|
||||
|
||||
Design for a good user experience - Provide clear, minimal, and non-blocking UI states. When data is loading, show lightweight placeholders (e.g., skeleton screens) rather than intrusive spinners everywhere. Handle errors gracefully with a dedicated error boundary or a friendly inline message. Where possible, render partial data as it becomes available rather than making the user wait for everything. Suspense allows you to declare the loading states in your component tree in a natural way, preventing “flash” states and improving perceived performance.
|
||||
|
||||
### Process
|
||||
|
||||
1. Analyze the user's code for optimization opportunities:
|
||||
- Check for React anti-patterns that prevent compiler optimization
|
||||
- Look for component structure issues that limit compiler effectiveness
|
||||
- Think about each suggestion you are making and consult React docs for best practices
|
||||
|
||||
2. Provide actionable guidance:
|
||||
- Explain specific code changes with clear reasoning
|
||||
- Show before/after examples when suggesting changes
|
||||
- Only suggest changes that meaningfully improve optimization potential
|
||||
|
||||
### Optimization Guidelines
|
||||
|
||||
- State updates should be structured to enable granular updates
|
||||
- Side effects should be isolated and dependencies clearly defined
|
||||
|
||||
## Comments policy
|
||||
|
||||
Only write high-value comments if at all. Avoid talking to the user through comments.
|
||||
|
||||
## General style requirements
|
||||
|
||||
Use hyphens instead of underscores in flag names (e.g. `my-flag` instead of `my_flag`).
|
||||
@@ -4,7 +4,7 @@ Your uninstall method depends on how you ran the CLI. Follow the instructions fo
|
||||
|
||||
## Method 1: Using npx
|
||||
|
||||
npx runs packages from a temporary cache without a permanent installation. To "uninstall" the CLI, you must clear this cache, which will remove qwen-code and any other packages previously executed with npx.
|
||||
npx runs packages from a temporary cache without a permanent installation. To "uninstall" the CLI, you must clear this cache, which will remove gemini-cli and any other packages previously executed with npx.
|
||||
|
||||
The npx cache is a directory named `_npx` inside your main npm cache folder. You can find your npm cache path by running `npm config get cache`.
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Description:** Saves the current conversation history. You must add a `<tag>` for identifying the conversation state.
|
||||
- **Usage:** `/chat save <tag>`
|
||||
- **Details on Checkpoint Location:** The default locations for saved chat checkpoints are:
|
||||
- Linux/macOS: `~/.config/qwen-code/checkpoints/`
|
||||
- Windows: `C:\Users\<YourUsername>\AppData\Roaming\qwen-code\checkpoints\`
|
||||
- Linux/macOS: `~/.config/google-generative-ai/checkpoints/`
|
||||
- Windows: `C:\Users\<YourUsername>\AppData\Roaming\google-generative-ai\checkpoints\`
|
||||
- When you run `/chat list`, the CLI only scans these specific directories to find available checkpoints.
|
||||
- **Note:** These checkpoints are for manually saving and resuming conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../checkpointing.md).
|
||||
- **`resume`**
|
||||
|
||||
@@ -23,9 +23,8 @@ Qwen Code uses `settings.json` files for persistent configuration. There are thr
|
||||
- **Project settings file:**
|
||||
- **Location:** `.qwen/settings.json` within your project's root directory.
|
||||
- **Scope:** Applies only when running Qwen Code from that specific project. Project settings override user settings.
|
||||
|
||||
- **System settings file:**
|
||||
- **Location:** `/etc/qwen-code/settings.json` (Linux), `C:\ProgramData\qwen-code\settings.json` (Windows) or `/Library/Application Support/QwenCode/settings.json` (macOS). The path can be overridden using the `QWEN_CODE_SYSTEM_SETTINGS_PATH` environment variable.
|
||||
- **Location:** `/etc/gemini-cli/settings.json` (Linux), `C:\ProgramData\gemini-cli\settings.json` (Windows) or `/Library/Application Support/GeminiCli/settings.json` (macOS). The path can be overridden using the `GEMINI_CLI_SYSTEM_SETTINGS_PATH` environment variable.
|
||||
- **Scope:** Applies to all Qwen Code sessions on the system, for all users. System settings override user and project settings. May be useful for system administrators at enterprises to have controls over users' Qwen Code setups.
|
||||
|
||||
**Note on environment variables in settings:** String values within your `settings.json` files can reference environment variables using either `$VAR_NAME` or `${VAR_NAME}` syntax. These variables will be automatically resolved when the settings are loaded. For example, if you have an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`.
|
||||
@@ -370,16 +369,35 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
|
||||
|
||||
**Environment Variable Exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Variables from `.qwen/.env` files are never excluded. You can customize this behavior using the `excludedProjectEnvVars` setting in your `settings.json` file.
|
||||
|
||||
- **`OPENAI_API_KEY`**:
|
||||
- **`GEMINI_API_KEY`**:
|
||||
- Your API key for the Gemini API.
|
||||
- One of several available [authentication methods](./authentication.md).
|
||||
- Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` file.
|
||||
- **`OPENAI_BASE_URL`**:
|
||||
- One of several available [authentication methods](./authentication.md).
|
||||
- Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` file.
|
||||
- **`OPENAI_MODEL`**:
|
||||
- Specifies the default OPENAI model to use.
|
||||
- **`GEMINI_MODEL`**:
|
||||
- Specifies the default Gemini model to use.
|
||||
- Overrides the hardcoded default
|
||||
- Example: `export OPENAI_MODEL="qwen3-coder-plus"`
|
||||
- Example: `export GEMINI_MODEL="gemini-2.5-flash"`
|
||||
- **`GOOGLE_API_KEY`**:
|
||||
- Your Google Cloud API key.
|
||||
- Required for using Vertex AI in express mode.
|
||||
- Ensure you have the necessary permissions.
|
||||
- Example: `export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"`.
|
||||
- **`GOOGLE_CLOUD_PROJECT`**:
|
||||
- Your Google Cloud Project ID.
|
||||
- Required for using Code Assist or Vertex AI.
|
||||
- If using Vertex AI, ensure you have the necessary permissions in this project.
|
||||
- **Cloud Shell Note:** When running in a Cloud Shell environment, this variable defaults to a special project allocated for Cloud Shell users. If you have `GOOGLE_CLOUD_PROJECT` set in your global environment in Cloud Shell, it will be overridden by this default. To use a different project in Cloud Shell, you must define `GOOGLE_CLOUD_PROJECT` in a `.env` file.
|
||||
- Example: `export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`.
|
||||
- **`GOOGLE_APPLICATION_CREDENTIALS`** (string):
|
||||
- **Description:** The path to your Google Application Credentials JSON file.
|
||||
- **Example:** `export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/credentials.json"`
|
||||
- **`OTLP_GOOGLE_CLOUD_PROJECT`**:
|
||||
- Your Google Cloud Project ID for Telemetry in Google Cloud
|
||||
- Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`.
|
||||
- **`GOOGLE_CLOUD_LOCATION`**:
|
||||
- Your Google Cloud Project Location (e.g., us-central1).
|
||||
- Required for using Vertex AI in non express mode.
|
||||
- Example: `export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"`.
|
||||
- **`GEMINI_SANDBOX`**:
|
||||
- Alternative to the `sandbox` setting in `settings.json`.
|
||||
- Accepts `true`, `false`, `docker`, `podman`, or a custom command string.
|
||||
@@ -409,8 +427,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
|
||||
Arguments passed directly when running the CLI can override other configurations for that specific session.
|
||||
|
||||
- **`--model <model_name>`** (**`-m <model_name>`**):
|
||||
- Specifies the Qwen model to use for this session.
|
||||
- Example: `npm start -- --model qwen3-coder-plus`
|
||||
- Specifies the Gemini model to use for this session.
|
||||
- Example: `npm start -- --model gemini-1.5-pro-latest`
|
||||
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
||||
- Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode.
|
||||
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
||||
@@ -477,7 +495,7 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
|
||||
While not strictly configuration for the CLI's _behavior_, context files (defaulting to `QWEN.md` but configurable via the `contextFileName` setting) are crucial for configuring the _instructional context_ (also referred to as "memory"). This powerful feature allows you to give project-specific instructions, coding style guides, or any relevant background information to the AI, making its responses more tailored and accurate to your needs. The CLI includes UI elements, such as an indicator in the footer showing the number of loaded context files, to keep you informed about the active context.
|
||||
|
||||
- **Purpose:** These Markdown files contain instructions, guidelines, or context that you want the Qwen model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically.
|
||||
- **Purpose:** These Markdown files contain instructions, guidelines, or context that you want the Gemini model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically.
|
||||
|
||||
### Example Context File Content (e.g., `QWEN.md`)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Qwen Code automatically optimizes API costs through token caching when using API
|
||||
|
||||
**Token caching is available for:**
|
||||
|
||||
- API key users (Qwen API key)
|
||||
- API key users (Gemini API key)
|
||||
- Vertex AI users (with project and location setup)
|
||||
|
||||
**Token caching is not available for:**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# IDE Integration
|
||||
|
||||
Qwen Code can integrate with your IDE to provide a more seamless and context-aware experience. This integration allows the CLI to understand your workspace better and enables powerful features like native in-editor diffing.
|
||||
Gemini CLI can integrate with your IDE to provide a more seamless and context-aware experience. This integration allows the CLI to understand your workspace better and enables powerful features like native in-editor diffing.
|
||||
|
||||
Currently, the only supported IDE is [Visual Studio Code](https://code.visualstudio.com/) and other editors that support VS Code extensions.
|
||||
|
||||
@@ -11,13 +11,13 @@ Currently, the only supported IDE is [Visual Studio Code](https://code.visualstu
|
||||
- Your active cursor position.
|
||||
- Any text you have selected (up to a 16KB limit; longer selections will be truncated).
|
||||
|
||||
- **Native Diffing:** When Qwen suggests code modifications, you can view the changes directly within your IDE's native diff viewer. This allows you to review, edit, and accept or reject the suggested changes seamlessly.
|
||||
- **Native Diffing:** When Gemini suggests code modifications, you can view the changes directly within your IDE's native diff viewer. This allows you to review, edit, and accept or reject the suggested changes seamlessly.
|
||||
|
||||
- **VS Code Commands:** You can access Qwen Code features directly from the VS Code Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`):
|
||||
- `Qwen Code: Run`: Starts a new Qwen Code session in the integrated terminal.
|
||||
- `Qwen Code: Accept Diff`: Accepts the changes in the active diff editor.
|
||||
- `Qwen Code: Close Diff Editor`: Rejects the changes and closes the active diff editor.
|
||||
- `Qwen Code: View Third-Party Notices`: Displays the third-party notices for the extension.
|
||||
- **VS Code Commands:** You can access Gemini CLI features directly from the VS Code Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`):
|
||||
- `Gemini CLI: Run`: Starts a new Gemini CLI session in the integrated terminal.
|
||||
- `Gemini CLI: Accept Diff`: Accepts the changes in the active diff editor.
|
||||
- `Gemini CLI: Close Diff Editor`: Rejects the changes and closes the active diff editor.
|
||||
- `Gemini CLI: View Third-Party Notices`: Displays the third-party notices for the extension.
|
||||
|
||||
## Installation and Setup
|
||||
|
||||
@@ -25,11 +25,11 @@ There are three ways to set up the IDE integration:
|
||||
|
||||
### 1. Automatic Nudge (Recommended)
|
||||
|
||||
When you run Qwen Code inside a supported editor, it will automatically detect your environment and prompt you to connect. Answering "Yes" will automatically run the necessary setup, which includes installing the companion extension and enabling the connection.
|
||||
When you run Gemini CLI inside a supported editor, it will automatically detect your environment and prompt you to connect. Answering "Yes" will automatically run the necessary setup, which includes installing the companion extension and enabling the connection.
|
||||
|
||||
### 2. Manual Installation from CLI
|
||||
|
||||
If you previously dismissed the prompt or want to install the extension manually, you can run the following command inside Qwen Code:
|
||||
If you previously dismissed the prompt or want to install the extension manually, you can run the following command inside Gemini CLI:
|
||||
|
||||
```
|
||||
/ide install
|
||||
@@ -41,8 +41,8 @@ This will find the correct extension for your IDE and install it.
|
||||
|
||||
You can also install the extension directly from a marketplace.
|
||||
|
||||
- **For Visual Studio Code:** Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion).
|
||||
- **For VS Code Forks:** To support forks of VS Code, the extension is also published on the [Open VSX Registry](https://open-vsx.org/extension/qwenlm/qwen-code-vscode-ide-companion). Follow your editor's instructions for installing extensions from this registry.
|
||||
- **For Visual Studio Code:** Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=google.gemini-cli-vscode-ide-companion).
|
||||
- **For VS Code Forks:** To support forks of VS Code, the extension is also published on the [Open VSX Registry](https://open-vsx.org/extension/google/gemini-cli-vscode-ide-companion). Follow your editor's instructions for installing extensions from this registry.
|
||||
|
||||
After any installation method, it's recommended to open a new terminal window to ensure the integration is activated correctly. Once installed, you can use `/ide enable` to connect.
|
||||
|
||||
@@ -61,7 +61,7 @@ You can control the IDE integration from within the CLI:
|
||||
/ide disable
|
||||
```
|
||||
|
||||
When enabled, Qwen Code will automatically attempt to connect to the IDE companion extension.
|
||||
When enabled, Gemini CLI will automatically attempt to connect to the IDE companion extension.
|
||||
|
||||
### Checking the Status
|
||||
|
||||
@@ -83,14 +83,14 @@ When you ask Gemini to modify a file, it can open a diff view directly in your e
|
||||
|
||||
- Click the **checkmark icon** in the diff editor's title bar.
|
||||
- Save the file (e.g., with `Cmd+S` or `Ctrl+S`).
|
||||
- Open the Command Palette and run **Qwen Code: Accept Diff**.
|
||||
- Open the Command Palette and run **Gemini CLI: Accept Diff**.
|
||||
- Respond with `yes` in the CLI when prompted.
|
||||
|
||||
**To reject a diff**, you can:
|
||||
|
||||
- Click the **'x' icon** in the diff editor's title bar.
|
||||
- Close the diff editor tab.
|
||||
- Open the Command Palette and run **Qwen Code: Close Diff Editor**.
|
||||
- Open the Command Palette and run **Gemini CLI: Close Diff Editor**.
|
||||
- Respond with `no` in the CLI when prompted.
|
||||
|
||||
You can also **modify the suggested changes** directly in the diff view before accepting them.
|
||||
@@ -99,10 +99,10 @@ If you select ‘Yes, allow always’ in the CLI, changes will no longer show up
|
||||
|
||||
## Using with Sandboxing
|
||||
|
||||
If you are using Qwen Code within a sandbox, please be aware of the following:
|
||||
If you are using Gemini CLI within a sandbox, please be aware of the following:
|
||||
|
||||
- **On macOS:** The IDE integration requires network access to communicate with the IDE companion extension. You must use a Seatbelt profile that allows network access.
|
||||
- **In a Docker Container:** If you run Qwen Code inside a Docker (or Podman) container, the IDE integration can still connect to the VS Code extension running on your host machine. The CLI is configured to automatically find the IDE server on `host.docker.internal`. No special configuration is usually required, but you may need to ensure your Docker networking setup allows connections from the container to the host.
|
||||
- **In a Docker Container:** If you run Gemini CLI inside a Docker (or Podman) container, the IDE integration can still connect to the VS Code extension running on your host machine. The CLI is configured to automatically find the IDE server on `host.docker.internal`. No special configuration is usually required, but you may need to ensure your Docker networking setup allows connections from the container to the host.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -111,9 +111,9 @@ If you encounter issues with IDE integration, here are some common error message
|
||||
### Connection Errors
|
||||
|
||||
- **Message:** `🔴 Disconnected: Failed to connect to IDE companion extension for [IDE Name]. Please ensure the extension is running and try restarting your terminal. To install the extension, run /ide install.`
|
||||
- **Cause:** Qwen Code could not find the necessary environment variables (`QWEN_CODE_IDE_WORKSPACE_PATH` or `QWEN_CODE_IDE_SERVER_PORT`) to connect to the IDE. This usually means the IDE companion extension is not running or did not initialize correctly.
|
||||
- **Cause:** Gemini CLI could not find the necessary environment variables (`GEMINI_CLI_IDE_WORKSPACE_PATH` or `GEMINI_CLI_IDE_SERVER_PORT`) to connect to the IDE. This usually means the IDE companion extension is not running or did not initialize correctly.
|
||||
- **Solution:**
|
||||
1. Make sure you have installed the **Qwen Code Companion** extension in your IDE and that it is enabled.
|
||||
1. Make sure you have installed the **Gemini CLI Companion** extension in your IDE and that it is enabled.
|
||||
2. Open a new terminal window in your IDE to ensure it picks up the correct environment.
|
||||
|
||||
- **Message:** `🔴 Disconnected: IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`
|
||||
@@ -122,7 +122,7 @@ If you encounter issues with IDE integration, here are some common error message
|
||||
|
||||
### Configuration Errors
|
||||
|
||||
- **Message:** `🔴 Disconnected: Directory mismatch. Qwen Code is running in a different location than the open workspace in [IDE Name]. Please run the CLI from the same directory as your project's root folder.`
|
||||
- **Message:** `🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from the same directory as your project's root folder.`
|
||||
- **Cause:** The CLI's current working directory is outside the folder or workspace you have open in your IDE.
|
||||
- **Solution:** `cd` into the same directory that is open in your IDE and restart the CLI.
|
||||
|
||||
@@ -132,10 +132,10 @@ If you encounter issues with IDE integration, here are some common error message
|
||||
|
||||
### General Errors
|
||||
|
||||
- **Message:** `IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: [List of IDEs]`
|
||||
- **Cause:** You are running Qwen Code in a terminal or environment that is not a supported IDE.
|
||||
- **Solution:** Run Qwen Code from the integrated terminal of a supported IDE, like VS Code.
|
||||
- **Message:** `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: [List of IDEs]`
|
||||
- **Cause:** You are running Gemini CLI in a terminal or environment that is not a supported IDE.
|
||||
- **Solution:** Run Gemini CLI from the integrated terminal of a supported IDE, like VS Code.
|
||||
|
||||
- **Message:** `No installer is available for [IDE Name]. Please install the IDE companion manually from its marketplace.`
|
||||
- **Cause:** You ran `/ide install`, but the CLI does not have an automated installer for your specific IDE.
|
||||
- **Solution:** Open your IDE's extension marketplace, search for "Qwen Code Companion", and install it manually.
|
||||
- **Solution:** Open your IDE's extension marketplace, search for "Gemini CLI Companion", and install it manually.
|
||||
|
||||
@@ -148,7 +148,7 @@ This command will do the following:
|
||||
3. Create the package tarballs that would be published to npm.
|
||||
4. Print a summary of the packages that would be published.
|
||||
|
||||
You can then inspect the generated tarballs to ensure that they contain the correct files and that the `package.json` files have been updated correctly. The tarballs will be created in the root of each package's directory (e.g., `packages/cli/qwen-code-0.1.6.tgz`).
|
||||
You can then inspect the generated tarballs to ensure that they contain the correct files and that the `package.json` files have been updated correctly. The tarballs will be created in the root of each package's directory (e.g., `packages/cli/google-gemini-cli-0.1.6.tgz`).
|
||||
|
||||
By performing a dry run, you can be confident that your changes to the packaging process are correct and that the packages will be published successfully.
|
||||
|
||||
@@ -187,7 +187,7 @@ This is the most critical stage where files are moved and transformed into their
|
||||
- File movement: packages/cli/package.json -> (in-memory transformation) -> `bundle`/package.json
|
||||
- Why: The final package.json must be different from the one used in development. Key changes include:
|
||||
- Removing devDependencies.
|
||||
- Removing workspace-specific "dependencies": { "@qwen-code/core": "workspace:\*" } and ensuring the core code is
|
||||
- Removing workspace-specific "dependencies": { "@gemini-cli/core": "workspace:\*" } and ensuring the core code is
|
||||
bundled directly into the final JavaScript file.
|
||||
- Ensuring the bin, main, and files fields point to the correct locations within the final package structure.
|
||||
|
||||
@@ -277,4 +277,4 @@ This tells NPM that any folder inside the `packages` directory is a separate pac
|
||||
|
||||
- **Simplified Dependency Management**: Running `npm install` from the root of the project will install all dependencies for all packages in the workspace and link them together. This means you don't need to run `npm install` in each package's directory.
|
||||
- **Automatic Linking**: Packages within the workspace can depend on each other. When you run `npm install`, NPM will automatically create symlinks between the packages. This means that when you make changes to one package, the changes are immediately available to other packages that depend on it.
|
||||
- **Simplified Script Execution**: You can run scripts in any package from the root of the project using the `--workspace` flag. For example, to run the `build` script in the `cli` package, you can run `npm run build --workspace @qwen-code/qwen-code`.
|
||||
- **Simplified Script Execution**: You can run scripts in any package from the root of the project using the `--workspace` flag. For example, to run the `build` script in the `cli` package, you can run `npm run build --workspace @google/gemini-cli`.
|
||||
|
||||
@@ -192,7 +192,7 @@ Logs are timestamped records of specific events. The following events are logged
|
||||
- `error_type` (if applicable)
|
||||
- `metadata` (if applicable, dictionary of string -> any)
|
||||
|
||||
- `qwen-code.api_request`: This event occurs when making a request to Qwen API.
|
||||
- `qwen-code.api_request`: This event occurs when making a request to Gemini API.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `request_text` (if applicable)
|
||||
@@ -206,7 +206,7 @@ Logs are timestamped records of specific events. The following events are logged
|
||||
- `duration_ms`
|
||||
- `auth_type`
|
||||
|
||||
- `qwen-code.api_response`: This event occurs upon receiving a response from Qwen API.
|
||||
- `qwen-code.api_response`: This event occurs upon receiving a response from Gemini API.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `status_code`
|
||||
@@ -273,7 +273,7 @@ Metrics are numerical measurements of behavior over time. The following metrics
|
||||
- `user_added_lines` (Int, if applicable): Number of lines added/changed by user in AI proposed changes.
|
||||
- `user_removed_lines` (Int, if applicable): Number of lines removed/changed by user in AI proposed changes.
|
||||
|
||||
- `qwen-code.chat_compression` (Counter, Int): Counts chat compression operations
|
||||
- `gemini_cli.chat_compression` (Counter, Int): Counts chat compression operations
|
||||
- **Attributes**:
|
||||
- `tokens_before`: (Int): Number of tokens in context prior to compression
|
||||
- `tokens_after`: (Int): Number of tokens in context after compression
|
||||
|
||||
@@ -157,7 +157,7 @@ search_file_content(pattern="function", include="*.js", maxResults=10)
|
||||
- If `old_string` is provided, it reads the `file_path` and attempts to find exactly one occurrence of `old_string`.
|
||||
- If one occurrence is found, it replaces it with `new_string`.
|
||||
- **Enhanced Reliability (Multi-Stage Edit Correction):** To significantly improve the success rate of edits, especially when the model-provided `old_string` might not be perfectly precise, the tool incorporates a multi-stage edit correction mechanism.
|
||||
- If the initial `old_string` isn't found or matches multiple locations, the tool can leverage the Qwen model to iteratively refine `old_string` (and potentially `new_string`).
|
||||
- If the initial `old_string` isn't found or matches multiple locations, the tool can leverage the Gemini model to iteratively refine `old_string` (and potentially `new_string`).
|
||||
- This self-correction process attempts to identify the unique segment the model intended to modify, making the `edit` operation more robust even with slightly imperfect initial context.
|
||||
- **Failure conditions:** Despite the correction mechanism, the tool will fail if:
|
||||
- `file_path` is not absolute or is outside the root directory.
|
||||
|
||||
@@ -25,7 +25,7 @@ The discovery process is orchestrated by `discoverMcpTools()`, which:
|
||||
1. **Iterates through configured servers** from your `settings.json` `mcpServers` configuration
|
||||
2. **Establishes connections** using appropriate transport mechanisms (Stdio, SSE, or Streamable HTTP)
|
||||
3. **Fetches tool definitions** from each server using the MCP protocol
|
||||
4. **Sanitizes and validates** tool schemas for compatibility with the Qwen API
|
||||
4. **Sanitizes and validates** tool schemas for compatibility with the Gemini API
|
||||
5. **Registers tools** in the global tool registry with conflict resolution
|
||||
|
||||
### Execution Layer (`mcp-tool.ts`)
|
||||
@@ -333,7 +333,7 @@ Upon successful connection:
|
||||
1. **Tool listing:** The client calls the MCP server's tool listing endpoint
|
||||
2. **Schema validation:** Each tool's function declaration is validated
|
||||
3. **Tool filtering:** Tools are filtered based on `includeTools` and `excludeTools` configuration
|
||||
4. **Name sanitization:** Tool names are cleaned to meet Qwen API requirements:
|
||||
4. **Name sanitization:** Tool names are cleaned to meet Gemini API requirements:
|
||||
- Invalid characters (non-alphanumeric, underscore, dot, hyphen) are replaced with underscores
|
||||
- Names longer than 63 characters are truncated with middle replacement (`___`)
|
||||
|
||||
@@ -468,7 +468,7 @@ Discovery State: COMPLETED
|
||||
|
||||
### Tool Usage
|
||||
|
||||
Once discovered, MCP tools are available to the Qwen model like built-in tools. The model will automatically:
|
||||
Once discovered, MCP tools are available to the Gemini model like built-in tools. The model will automatically:
|
||||
|
||||
1. **Select appropriate tools** based on your requests
|
||||
2. **Present confirmation dialogs** (unless the server is trusted)
|
||||
@@ -566,7 +566,7 @@ The MCP integration tracks several states:
|
||||
|
||||
### Schema Compatibility
|
||||
|
||||
- **Property stripping:** The system automatically removes certain schema properties (`$schema`, `additionalProperties`) for Qwen API compatibility
|
||||
- **Property stripping:** The system automatically removes certain schema properties (`$schema`, `additionalProperties`) for Gemini API compatibility
|
||||
- **Name sanitization:** Tool names are automatically sanitized to meet API requirements
|
||||
- **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing
|
||||
|
||||
@@ -620,7 +620,7 @@ When Qwen Code receives this response, it will:
|
||||
2. Present the image data as a separate `inlineData` part.
|
||||
3. Provide a clean, user-friendly summary in the CLI, indicating that both text and an image were received.
|
||||
|
||||
This enables you to build sophisticated tools that can provide rich, multi-modal context to the Qwen model.
|
||||
This enables you to build sophisticated tools that can provide rich, multi-modal context to the Gemini model.
|
||||
|
||||
## MCP Prompts as Slash Commands
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ save_memory(fact="My preferred programming language is Python.")
|
||||
Store a project-specific detail:
|
||||
|
||||
```
|
||||
save_memory(fact="The project I'm currently working on is called 'qwen-code'.")
|
||||
save_memory(fact="The project I'm currently working on is called 'gemini-cli'.")
|
||||
```
|
||||
|
||||
## Important notes
|
||||
|
||||
@@ -42,7 +42,7 @@ web_fetch(url="https://arxiv.org/abs/2401.0001", prompt="What are the key findin
|
||||
Analyze GitHub documentation:
|
||||
|
||||
```
|
||||
web_fetch(url="https://github.com/QwenLM/Qwen/blob/main/README.md", prompt="What are the installation steps and main features?")
|
||||
web_fetch(url="https://github.com/google/gemini-react/blob/main/README.md", prompt="What are the installation steps and main features?")
|
||||
```
|
||||
|
||||
## Important notes
|
||||
|
||||
@@ -9,6 +9,16 @@ This guide provides solutions to common issues and debugging tips, including top
|
||||
|
||||
## Authentication or login errors
|
||||
|
||||
- **Error: `Failed to login. Message: Request contains an invalid argument`**
|
||||
- Users with Google Workspace accounts or Google Cloud accounts
|
||||
associated with their Gmail accounts may not be able to activate the free
|
||||
tier of the Google Code Assist plan.
|
||||
- For Google Cloud accounts, you can work around this by setting
|
||||
`GOOGLE_CLOUD_PROJECT` to your project ID.
|
||||
- Alternatively, you can obtain the Gemini API key from
|
||||
[Google AI Studio](http://aistudio.google.com/app/apikey), which also includes a
|
||||
separate free tier.
|
||||
|
||||
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` or `unable to get local issuer certificate`**
|
||||
- **Cause:** You may be on a corporate network with a firewall that intercepts and inspects SSL/TLS traffic. This often requires a custom root CA certificate to be trusted by Node.js.
|
||||
- **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file.
|
||||
@@ -27,7 +37,7 @@ This guide provides solutions to common issues and debugging tips, including top
|
||||
Refer to [Qwen Code Configuration](./cli/configuration.md) for more details.
|
||||
|
||||
- **Q: Why don't I see cached token counts in my stats output?**
|
||||
- A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Qwen API key or Google Cloud Vertex AI) but not for OAuth users (such as Google Personal/Enterprise accounts like Google Gmail or Google Workspace, respectively). This is because the Qwen Code Assist API does not support cached content creation. You can still view your total token usage using the `/stats` command.
|
||||
- A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Gemini API key or Google Cloud Vertex AI) but not for OAuth users (such as Google Personal/Enterprise accounts like Google Gmail or Google Workspace, respectively). This is because the Gemini Code Assist API does not support cached content creation. You can still view your total token usage using the `/stats` command.
|
||||
|
||||
## Common error messages and solutions
|
||||
|
||||
|
||||
@@ -1198,16 +1198,16 @@ describe('Settings Loading and Merging', () => {
|
||||
delete process.env['TEST_PORT'];
|
||||
});
|
||||
|
||||
describe('when QWEN_CODE_SYSTEM_SETTINGS_PATH is set', () => {
|
||||
describe('when GEMINI_CLI_SYSTEM_SETTINGS_PATH is set', () => {
|
||||
const MOCK_ENV_SYSTEM_SETTINGS_PATH = '/mock/env/system/settings.json';
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'] =
|
||||
process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] =
|
||||
MOCK_ENV_SYSTEM_SETTINGS_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
|
||||
delete process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
|
||||
});
|
||||
|
||||
it('should load system settings from the path specified in the environment variable', () => {
|
||||
|
||||
@@ -25,8 +25,8 @@ export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
|
||||
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
||||
|
||||
export function getSystemSettingsPath(): string {
|
||||
if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) {
|
||||
return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
|
||||
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
|
||||
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
|
||||
}
|
||||
if (platform() === 'darwin') {
|
||||
return '/Library/Application Support/QwenCode/settings.json';
|
||||
|
||||
@@ -41,8 +41,8 @@ export function IdeIntegrationNudge({
|
||||
const { displayName: ideName } = getIdeInfo(ide);
|
||||
// Assume extension is already installed if the env variables are set.
|
||||
const isExtensionPreInstalled =
|
||||
!!process.env['QWEN_CODE_IDE_SERVER_PORT'] &&
|
||||
!!process.env['QWEN_CODE_IDE_WORKSPACE_PATH'];
|
||||
!!process.env['GEMINI_CLI_IDE_SERVER_PORT'] &&
|
||||
!!process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
|
||||
|
||||
const OPTIONS: Array<RadioSelectItem<IdeIntegrationNudgeResult>> = [
|
||||
{
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('initCommand', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it(`should ask for confirmation if ${DEFAULT_CONTEXT_FILENAME} already exists and is non-empty`, async () => {
|
||||
it(`should inform the user if ${DEFAULT_CONTEXT_FILENAME} already exists and is non-empty`, async () => {
|
||||
// Arrange: Simulate that the file exists
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('# Existing content');
|
||||
@@ -61,15 +61,13 @@ describe('initCommand', () => {
|
||||
// Act: Run the command's action
|
||||
const result = await initCommand.action!(mockContext, '');
|
||||
|
||||
// Assert: Check for the correct confirmation request
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'confirm_action',
|
||||
prompt: expect.anything(), // React element, not a string
|
||||
originalInvocation: expect.anything(),
|
||||
}),
|
||||
);
|
||||
// Assert: Ensure no file was written yet
|
||||
// Assert: Check for the correct informational message
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `A ${DEFAULT_CONTEXT_FILENAME} file already exists in this directory. No changes were made.`,
|
||||
});
|
||||
// Assert: Ensure no file was written
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -93,13 +91,9 @@ describe('initCommand', () => {
|
||||
);
|
||||
|
||||
// Assert: Check that the correct prompt is submitted
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'submit_prompt',
|
||||
content: expect.stringContaining(
|
||||
'You are Qwen Code, an interactive CLI agent',
|
||||
),
|
||||
}),
|
||||
expect(result.type).toBe('submit_prompt');
|
||||
expect(result.content).toContain(
|
||||
'You are Qwen Code, an interactive CLI agent',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -110,43 +104,7 @@ describe('initCommand', () => {
|
||||
const result = await initCommand.action!(mockContext, '');
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8');
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'submit_prompt',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should regenerate ${DEFAULT_CONTEXT_FILENAME} when overwrite is confirmed`, async () => {
|
||||
// Arrange: Simulate that the file exists and overwrite is confirmed
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('# Existing content');
|
||||
mockContext.overwriteConfirmed = true;
|
||||
|
||||
// Act: Run the command's action
|
||||
const result = await initCommand.action!(mockContext, '');
|
||||
|
||||
// Assert: Check that writeFileSync was called correctly
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8');
|
||||
|
||||
// Assert: Check that an informational message was added to the UI
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'info',
|
||||
text: `Empty ${DEFAULT_CONTEXT_FILENAME} created. Now analyzing the project to populate it.`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
// Assert: Check that the correct prompt is submitted
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'submit_prompt',
|
||||
content: expect.stringContaining(
|
||||
'You are Qwen Code, an interactive CLI agent',
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(result.type).toBe('submit_prompt');
|
||||
});
|
||||
|
||||
it('should return an error if config is not available', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core';
|
||||
import { Text } from 'ink';
|
||||
import React from 'react';
|
||||
|
||||
export const initCommand: SlashCommand = {
|
||||
name: 'init',
|
||||
@@ -37,27 +35,15 @@ export const initCommand: SlashCommand = {
|
||||
|
||||
try {
|
||||
if (fs.existsSync(contextFilePath)) {
|
||||
// If file exists but is empty (or whitespace), continue to initialize
|
||||
// If file exists but is empty (or whitespace), continue to initialize; otherwise, bail out
|
||||
try {
|
||||
const existing = fs.readFileSync(contextFilePath, 'utf8');
|
||||
if (existing && existing.trim().length > 0) {
|
||||
// File exists and has content - ask for confirmation to overwrite
|
||||
if (!context.overwriteConfirmed) {
|
||||
return {
|
||||
type: 'confirm_action',
|
||||
// TODO: Move to .tsx file to use JSX syntax instead of React.createElement
|
||||
// For now, using React.createElement to maintain .ts compatibility for PR review
|
||||
prompt: React.createElement(
|
||||
Text,
|
||||
null,
|
||||
`A ${contextFileName} file already exists in this directory. Do you want to regenerate it?`,
|
||||
),
|
||||
originalInvocation: {
|
||||
raw: context.invocation?.raw || '/init',
|
||||
},
|
||||
};
|
||||
}
|
||||
// User confirmed overwrite, continue with regeneration
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `A ${contextFileName} file already exists in this directory. No changes were made.`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// If we fail to read, conservatively proceed to (re)create the file
|
||||
|
||||
@@ -204,7 +204,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
<StatRow title="Tool Calls:">
|
||||
<Text>
|
||||
{tools.totalCalls} ({' '}
|
||||
<Text color={theme.status.success}>✓ {tools.totalSuccess}</Text>{' '}
|
||||
<Text color={theme.status.success}>✔ {tools.totalSuccess}</Text>{' '}
|
||||
<Text color={theme.status.error}>✖ {tools.totalFail}</Text> )
|
||||
</Text>
|
||||
</StatRow>
|
||||
|
||||
@@ -7,7 +7,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: │
|
||||
│ Tool Calls: 0 ( ✓ 0 ✖ 0 ) │
|
||||
│ Tool Calls: 0 ( ✔ 0 ✖ 0 ) │
|
||||
│ Success Rate: 0.0% │
|
||||
│ Code Changes: +42 -15 │
|
||||
│ │
|
||||
|
||||
@@ -7,7 +7,7 @@ exports[`<StatsDisplay /> > Code Changes Display > displays Code Changes when li
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: test-session-id │
|
||||
│ Tool Calls: 1 ( ✓ 1 ✖ 0 ) │
|
||||
│ Tool Calls: 1 ( ✔ 1 ✖ 0 ) │
|
||||
│ Success Rate: 100.0% │
|
||||
│ Code Changes: +42 -18 │
|
||||
│ │
|
||||
@@ -28,7 +28,7 @@ exports[`<StatsDisplay /> > Code Changes Display > hides Code Changes when no li
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: test-session-id │
|
||||
│ Tool Calls: 1 ( ✓ 1 ✖ 0 ) │
|
||||
│ Tool Calls: 1 ( ✔ 1 ✖ 0 ) │
|
||||
│ Success Rate: 100.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
@@ -48,7 +48,7 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in gr
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: test-session-id │
|
||||
│ Tool Calls: 10 ( ✓ 10 ✖ 0 ) │
|
||||
│ Tool Calls: 10 ( ✔ 10 ✖ 0 ) │
|
||||
│ Success Rate: 100.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
@@ -68,7 +68,7 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in re
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: test-session-id │
|
||||
│ Tool Calls: 10 ( ✓ 5 ✖ 5 ) │
|
||||
│ Tool Calls: 10 ( ✔ 5 ✖ 5 ) │
|
||||
│ Success Rate: 50.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
@@ -88,7 +88,7 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in ye
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: test-session-id │
|
||||
│ Tool Calls: 10 ( ✓ 9 ✖ 1 ) │
|
||||
│ Tool Calls: 10 ( ✔ 9 ✖ 1 ) │
|
||||
│ Success Rate: 90.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
@@ -108,7 +108,7 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: test-session-id │
|
||||
│ Tool Calls: 0 ( ✓ 0 ✖ 0 ) │
|
||||
│ Tool Calls: 0 ( ✔ 0 ✖ 0 ) │
|
||||
│ Success Rate: 0.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
@@ -132,7 +132,7 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides User Agreement w
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: test-session-id │
|
||||
│ Tool Calls: 2 ( ✓ 1 ✖ 1 ) │
|
||||
│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │
|
||||
│ Success Rate: 50.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
@@ -152,7 +152,7 @@ exports[`<StatsDisplay /> > Title Rendering > renders the custom title when a ti
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: test-session-id │
|
||||
│ Tool Calls: 0 ( ✓ 0 ✖ 0 ) │
|
||||
│ Tool Calls: 0 ( ✔ 0 ✖ 0 ) │
|
||||
│ Success Rate: 0.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
@@ -172,7 +172,7 @@ exports[`<StatsDisplay /> > Title Rendering > renders the default title when no
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: test-session-id │
|
||||
│ Tool Calls: 0 ( ✓ 0 ✖ 0 ) │
|
||||
│ Tool Calls: 0 ( ✔ 0 ✖ 0 ) │
|
||||
│ Success Rate: 0.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
@@ -192,7 +192,7 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: test-session-id │
|
||||
│ Tool Calls: 0 ( ✓ 0 ✖ 0 ) │
|
||||
│ Tool Calls: 0 ( ✔ 0 ✖ 0 ) │
|
||||
│ Success Rate: 0.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
@@ -221,7 +221,7 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: test-session-id │
|
||||
│ Tool Calls: 2 ( ✓ 1 ✖ 1 ) │
|
||||
│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │
|
||||
│ Success Rate: 50.0% │
|
||||
│ User Agreement: 100.0% (1 reviewed) │
|
||||
│ │
|
||||
@@ -250,7 +250,7 @@ exports[`<StatsDisplay /> > renders only the Performance section in its zero sta
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: test-session-id │
|
||||
│ Tool Calls: 0 ( ✓ 0 ✖ 0 ) │
|
||||
│ Tool Calls: 0 ( ✔ 0 ✖ 0 ) │
|
||||
│ Success Rate: 0.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
|
||||
@@ -80,7 +80,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
marginLeft={1}
|
||||
borderDimColor={hasPending}
|
||||
borderColor={borderColor}
|
||||
gap={1}
|
||||
>
|
||||
{toolCalls.map((tool) => {
|
||||
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
|
||||
|
||||
@@ -84,19 +84,19 @@ describe('<ToolMessage />', () => {
|
||||
StreamingState.Idle,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('✓'); // Success indicator
|
||||
expect(output).toContain('✔'); // Success indicator
|
||||
expect(output).toContain('test-tool');
|
||||
expect(output).toContain('A tool for testing');
|
||||
expect(output).toContain('MockMarkdown:Test result');
|
||||
});
|
||||
|
||||
describe('ToolStatusIndicator rendering', () => {
|
||||
it('shows ✓ for Success status', () => {
|
||||
it('shows ✔ for Success status', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} status={ToolCallStatus.Success} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toContain('✓');
|
||||
expect(lastFrame()).toContain('✔');
|
||||
});
|
||||
|
||||
it('shows o for Pending status', () => {
|
||||
@@ -138,7 +138,7 @@ describe('<ToolMessage />', () => {
|
||||
);
|
||||
expect(lastFrame()).toContain('⊷');
|
||||
expect(lastFrame()).not.toContain('MockRespondingSpinner');
|
||||
expect(lastFrame()).not.toContain('✓');
|
||||
expect(lastFrame()).not.toContain('✔');
|
||||
});
|
||||
|
||||
it('shows paused spinner for Executing status when streamingState is WaitingForConfirmation', () => {
|
||||
@@ -148,7 +148,7 @@ describe('<ToolMessage />', () => {
|
||||
);
|
||||
expect(lastFrame()).toContain('⊷');
|
||||
expect(lastFrame()).not.toContain('MockRespondingSpinner');
|
||||
expect(lastFrame()).not.toContain('✓');
|
||||
expect(lastFrame()).not.toContain('✔');
|
||||
});
|
||||
|
||||
it('shows MockRespondingSpinner for Executing status when streamingState is Responding', () => {
|
||||
@@ -157,7 +157,7 @@ describe('<ToolMessage />', () => {
|
||||
StreamingState.Responding, // Simulate app still responding
|
||||
);
|
||||
expect(lastFrame()).toContain('MockRespondingSpinner');
|
||||
expect(lastFrame()).not.toContain('✓');
|
||||
expect(lastFrame()).not.toContain('✔');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -269,7 +269,7 @@ const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
|
||||
/>
|
||||
)}
|
||||
{status === ToolCallStatus.Success && (
|
||||
<Text color={Colors.AccentGreen}>✓</Text>
|
||||
<Text color={Colors.AccentGreen}>✔</Text>
|
||||
)}
|
||||
{status === ToolCallStatus.Confirming && (
|
||||
<Text color={Colors.AccentYellow}>?</Text>
|
||||
|
||||
@@ -288,7 +288,7 @@ const ToolCallItem: React.FC<{
|
||||
case 'awaiting_approval':
|
||||
return <Text color={theme.status.warning}>?</Text>;
|
||||
case 'success':
|
||||
return <Text color={color}>✓</Text>;
|
||||
return <Text color={color}>✔</Text>;
|
||||
case 'failed':
|
||||
return (
|
||||
<Text color={color} bold>
|
||||
|
||||
@@ -503,7 +503,7 @@ export const useSlashCommandProcessor = (
|
||||
setTimeout(async () => {
|
||||
await runExitCleanup();
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
}, 1000);
|
||||
return { type: 'handled' };
|
||||
|
||||
case 'submit_prompt':
|
||||
|
||||
@@ -50,13 +50,9 @@ const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = {
|
||||
};
|
||||
/**
|
||||
* Returns true if the response is valid, false otherwise.
|
||||
*
|
||||
* The DashScope provider may return the last 2 chunks as:
|
||||
* 1. A choice(candidate) with finishReason and empty content
|
||||
* 2. Empty choices with usage metadata
|
||||
* We'll check separately for both of these cases.
|
||||
*/
|
||||
function isValidResponse(response: GenerateContentResponse): boolean {
|
||||
// The Dashscope provider returns empty content with usage metadata at the end of the stream
|
||||
if (response.usageMetadata) {
|
||||
return true;
|
||||
}
|
||||
@@ -65,10 +61,6 @@ function isValidResponse(response: GenerateContentResponse): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.candidates.some((candidate) => candidate.finishReason)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const content = response.candidates[0]?.content;
|
||||
return content !== undefined && isValidContent(content);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
ContentListUnion,
|
||||
ContentUnion,
|
||||
PartUnion,
|
||||
Candidate,
|
||||
} from '@google/genai';
|
||||
import OpenAI from 'openai';
|
||||
import { safeJsonParse } from '../../utils/safeJsonParse.js';
|
||||
@@ -653,21 +652,19 @@ export class OpenAIContentConverter {
|
||||
this.streamingToolCallParser.reset();
|
||||
}
|
||||
|
||||
// Only include finishReason key if finish_reason is present
|
||||
const candidate: Candidate = {
|
||||
content: {
|
||||
parts,
|
||||
role: 'model' as const,
|
||||
response.candidates = [
|
||||
{
|
||||
content: {
|
||||
parts,
|
||||
role: 'model' as const,
|
||||
},
|
||||
finishReason: choice.finish_reason
|
||||
? this.mapOpenAIFinishReasonToGemini(choice.finish_reason)
|
||||
: FinishReason.FINISH_REASON_UNSPECIFIED,
|
||||
index: 0,
|
||||
safetyRatings: [],
|
||||
},
|
||||
index: 0,
|
||||
safetyRatings: [],
|
||||
};
|
||||
if (choice.finish_reason) {
|
||||
candidate.finishReason = this.mapOpenAIFinishReasonToGemini(
|
||||
choice.finish_reason,
|
||||
);
|
||||
}
|
||||
response.candidates = [candidate];
|
||||
];
|
||||
} else {
|
||||
response.candidates = [];
|
||||
}
|
||||
|
||||
@@ -4,21 +4,20 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||
import OpenAI from 'openai';
|
||||
import {
|
||||
type GenerateContentParameters,
|
||||
GenerateContentParameters,
|
||||
GenerateContentResponse,
|
||||
Type,
|
||||
FinishReason,
|
||||
} from '@google/genai';
|
||||
import { ContentGenerationPipeline, type PipelineConfig } from './pipeline.js';
|
||||
import { ContentGenerationPipeline, PipelineConfig } from './pipeline.js';
|
||||
import { OpenAIContentConverter } from './converter.js';
|
||||
import { Config } from '../../config/config.js';
|
||||
import { type ContentGeneratorConfig, AuthType } from '../contentGenerator.js';
|
||||
import { type OpenAICompatibleProvider } from './provider/index.js';
|
||||
import { type TelemetryService } from './telemetryService.js';
|
||||
import { type ErrorHandler } from './errorHandler.js';
|
||||
import { ContentGeneratorConfig, AuthType } from '../contentGenerator.js';
|
||||
import { OpenAICompatibleProvider } from './provider/index.js';
|
||||
import { TelemetryService } from './telemetryService.js';
|
||||
import { ErrorHandler } from './errorHandler.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./converter.js');
|
||||
@@ -471,418 +470,6 @@ describe('ContentGenerationPipeline', () => {
|
||||
request,
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge finishReason and usageMetadata from separate chunks', async () => {
|
||||
// Arrange
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'test-model',
|
||||
contents: [{ parts: [{ text: 'Hello' }], role: 'user' }],
|
||||
};
|
||||
const userPromptId = 'test-prompt-id';
|
||||
|
||||
// Content chunk
|
||||
const mockChunk1 = {
|
||||
id: 'chunk-1',
|
||||
choices: [
|
||||
{ delta: { content: 'Hello response' }, finish_reason: null },
|
||||
],
|
||||
} as OpenAI.Chat.ChatCompletionChunk;
|
||||
|
||||
// Finish reason chunk (empty content, has finish_reason)
|
||||
const mockChunk2 = {
|
||||
id: 'chunk-2',
|
||||
choices: [{ delta: { content: '' }, finish_reason: 'stop' }],
|
||||
} as OpenAI.Chat.ChatCompletionChunk;
|
||||
|
||||
// Usage metadata chunk (empty candidates, has usage)
|
||||
const mockChunk3 = {
|
||||
id: 'chunk-3',
|
||||
object: 'chat.completion.chunk',
|
||||
created: Date.now(),
|
||||
model: 'test-model',
|
||||
choices: [],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
} as OpenAI.Chat.ChatCompletionChunk;
|
||||
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield mockChunk1;
|
||||
yield mockChunk2;
|
||||
yield mockChunk3;
|
||||
},
|
||||
};
|
||||
|
||||
// Mock converter responses
|
||||
const mockContentResponse = new GenerateContentResponse();
|
||||
mockContentResponse.candidates = [
|
||||
{ content: { parts: [{ text: 'Hello response' }], role: 'model' } },
|
||||
];
|
||||
|
||||
const mockFinishResponse = new GenerateContentResponse();
|
||||
mockFinishResponse.candidates = [
|
||||
{
|
||||
content: { parts: [], role: 'model' },
|
||||
finishReason: FinishReason.STOP,
|
||||
},
|
||||
];
|
||||
|
||||
const mockUsageResponse = new GenerateContentResponse();
|
||||
mockUsageResponse.candidates = [];
|
||||
mockUsageResponse.usageMetadata = {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 30,
|
||||
};
|
||||
|
||||
// Expected merged response (finishReason + usageMetadata combined)
|
||||
const mockMergedResponse = new GenerateContentResponse();
|
||||
mockMergedResponse.candidates = [
|
||||
{
|
||||
content: { parts: [], role: 'model' },
|
||||
finishReason: FinishReason.STOP,
|
||||
},
|
||||
];
|
||||
mockMergedResponse.usageMetadata = {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 30,
|
||||
};
|
||||
|
||||
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]);
|
||||
(mockConverter.convertOpenAIChunkToGemini as Mock)
|
||||
.mockReturnValueOnce(mockContentResponse)
|
||||
.mockReturnValueOnce(mockFinishResponse)
|
||||
.mockReturnValueOnce(mockUsageResponse);
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue(
|
||||
mockStream,
|
||||
);
|
||||
|
||||
// Act
|
||||
const resultGenerator = await pipeline.executeStream(
|
||||
request,
|
||||
userPromptId,
|
||||
);
|
||||
const results = [];
|
||||
for await (const result of resultGenerator) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Assert
|
||||
expect(results).toHaveLength(2); // Content chunk + merged finish/usage chunk
|
||||
expect(results[0]).toBe(mockContentResponse);
|
||||
|
||||
// The last result should have both finishReason and usageMetadata
|
||||
const lastResult = results[1];
|
||||
expect(lastResult.candidates?.[0]?.finishReason).toBe(FinishReason.STOP);
|
||||
expect(lastResult.usageMetadata).toEqual({
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 30,
|
||||
});
|
||||
|
||||
expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userPromptId,
|
||||
model: 'test-model',
|
||||
authType: 'openai',
|
||||
isStreaming: true,
|
||||
}),
|
||||
results,
|
||||
expect.any(Object),
|
||||
[mockChunk1, mockChunk2, mockChunk3],
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle ideal case where last chunk has both finishReason and usageMetadata', async () => {
|
||||
// Arrange
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'test-model',
|
||||
contents: [{ parts: [{ text: 'Hello' }], role: 'user' }],
|
||||
};
|
||||
const userPromptId = 'test-prompt-id';
|
||||
|
||||
// Content chunk
|
||||
const mockChunk1 = {
|
||||
id: 'chunk-1',
|
||||
choices: [
|
||||
{ delta: { content: 'Hello response' }, finish_reason: null },
|
||||
],
|
||||
} as OpenAI.Chat.ChatCompletionChunk;
|
||||
|
||||
// Final chunk with both finish_reason and usage (ideal case)
|
||||
const mockChunk2 = {
|
||||
id: 'chunk-2',
|
||||
choices: [{ delta: { content: '' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
} as OpenAI.Chat.ChatCompletionChunk;
|
||||
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield mockChunk1;
|
||||
yield mockChunk2;
|
||||
},
|
||||
};
|
||||
|
||||
// Mock converter responses
|
||||
const mockContentResponse = new GenerateContentResponse();
|
||||
mockContentResponse.candidates = [
|
||||
{ content: { parts: [{ text: 'Hello response' }], role: 'model' } },
|
||||
];
|
||||
|
||||
const mockFinalResponse = new GenerateContentResponse();
|
||||
mockFinalResponse.candidates = [
|
||||
{
|
||||
content: { parts: [], role: 'model' },
|
||||
finishReason: FinishReason.STOP,
|
||||
},
|
||||
];
|
||||
mockFinalResponse.usageMetadata = {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 30,
|
||||
};
|
||||
|
||||
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]);
|
||||
(mockConverter.convertOpenAIChunkToGemini as Mock)
|
||||
.mockReturnValueOnce(mockContentResponse)
|
||||
.mockReturnValueOnce(mockFinalResponse);
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue(
|
||||
mockStream,
|
||||
);
|
||||
|
||||
// Act
|
||||
const resultGenerator = await pipeline.executeStream(
|
||||
request,
|
||||
userPromptId,
|
||||
);
|
||||
const results = [];
|
||||
for await (const result of resultGenerator) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Assert
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toBe(mockContentResponse);
|
||||
expect(results[1]).toBe(mockFinalResponse);
|
||||
|
||||
// The last result should have both finishReason and usageMetadata
|
||||
const lastResult = results[1];
|
||||
expect(lastResult.candidates?.[0]?.finishReason).toBe(FinishReason.STOP);
|
||||
expect(lastResult.usageMetadata).toEqual({
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle providers that send zero usage in finish chunk (like modelscope)', async () => {
|
||||
// Arrange
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'test-model',
|
||||
contents: [{ parts: [{ text: 'Hello' }], role: 'user' }],
|
||||
};
|
||||
const userPromptId = 'test-prompt-id';
|
||||
|
||||
// Content chunk with zero usage (typical for modelscope)
|
||||
const mockChunk1 = {
|
||||
id: 'chunk-1',
|
||||
choices: [
|
||||
{ delta: { content: 'Hello response' }, finish_reason: null },
|
||||
],
|
||||
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
} as OpenAI.Chat.ChatCompletionChunk;
|
||||
|
||||
// Finish chunk with zero usage (has finishReason but usage is all zeros)
|
||||
const mockChunk2 = {
|
||||
id: 'chunk-2',
|
||||
choices: [{ delta: { content: '' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
} as OpenAI.Chat.ChatCompletionChunk;
|
||||
|
||||
// Final usage chunk with actual usage data
|
||||
const mockChunk3 = {
|
||||
id: 'chunk-3',
|
||||
object: 'chat.completion.chunk',
|
||||
created: Date.now(),
|
||||
model: 'test-model',
|
||||
choices: [],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
} as OpenAI.Chat.ChatCompletionChunk;
|
||||
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield mockChunk1;
|
||||
yield mockChunk2;
|
||||
yield mockChunk3;
|
||||
},
|
||||
};
|
||||
|
||||
// Mock converter responses
|
||||
const mockContentResponse = new GenerateContentResponse();
|
||||
mockContentResponse.candidates = [
|
||||
{ content: { parts: [{ text: 'Hello response' }], role: 'model' } },
|
||||
];
|
||||
// Content chunk has zero usage metadata (should be filtered or ignored)
|
||||
mockContentResponse.usageMetadata = {
|
||||
promptTokenCount: 0,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: 0,
|
||||
};
|
||||
|
||||
const mockFinishResponseWithZeroUsage = new GenerateContentResponse();
|
||||
mockFinishResponseWithZeroUsage.candidates = [
|
||||
{
|
||||
content: { parts: [], role: 'model' },
|
||||
finishReason: FinishReason.STOP,
|
||||
},
|
||||
];
|
||||
// Finish chunk has zero usage metadata (should be treated as no usage)
|
||||
mockFinishResponseWithZeroUsage.usageMetadata = {
|
||||
promptTokenCount: 0,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: 0,
|
||||
};
|
||||
|
||||
const mockUsageResponse = new GenerateContentResponse();
|
||||
mockUsageResponse.candidates = [];
|
||||
mockUsageResponse.usageMetadata = {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 30,
|
||||
};
|
||||
|
||||
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]);
|
||||
(mockConverter.convertOpenAIChunkToGemini as Mock)
|
||||
.mockReturnValueOnce(mockContentResponse)
|
||||
.mockReturnValueOnce(mockFinishResponseWithZeroUsage)
|
||||
.mockReturnValueOnce(mockUsageResponse);
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue(
|
||||
mockStream,
|
||||
);
|
||||
|
||||
// Act
|
||||
const resultGenerator = await pipeline.executeStream(
|
||||
request,
|
||||
userPromptId,
|
||||
);
|
||||
const results = [];
|
||||
for await (const result of resultGenerator) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Assert
|
||||
expect(results).toHaveLength(2); // Content chunk + merged finish/usage chunk
|
||||
expect(results[0]).toBe(mockContentResponse);
|
||||
|
||||
// The last result should have both finishReason and valid usageMetadata
|
||||
const lastResult = results[1];
|
||||
expect(lastResult.candidates?.[0]?.finishReason).toBe(FinishReason.STOP);
|
||||
expect(lastResult.usageMetadata).toEqual({
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 30,
|
||||
});
|
||||
|
||||
expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userPromptId,
|
||||
model: 'test-model',
|
||||
authType: 'openai',
|
||||
isStreaming: true,
|
||||
}),
|
||||
results,
|
||||
expect.any(Object),
|
||||
[mockChunk1, mockChunk2, mockChunk3],
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle providers that send finishReason and valid usage in same chunk', async () => {
|
||||
// Arrange
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'test-model',
|
||||
contents: [{ parts: [{ text: 'Hello' }], role: 'user' }],
|
||||
};
|
||||
const userPromptId = 'test-prompt-id';
|
||||
|
||||
// Content chunk with zero usage
|
||||
const mockChunk1 = {
|
||||
id: 'chunk-1',
|
||||
choices: [
|
||||
{ delta: { content: 'Hello response' }, finish_reason: null },
|
||||
],
|
||||
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
} as OpenAI.Chat.ChatCompletionChunk;
|
||||
|
||||
// Finish chunk with both finishReason and valid usage in same chunk
|
||||
const mockChunk2 = {
|
||||
id: 'chunk-2',
|
||||
choices: [{ delta: { content: '' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
} as OpenAI.Chat.ChatCompletionChunk;
|
||||
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield mockChunk1;
|
||||
yield mockChunk2;
|
||||
},
|
||||
};
|
||||
|
||||
// Mock converter responses
|
||||
const mockContentResponse = new GenerateContentResponse();
|
||||
mockContentResponse.candidates = [
|
||||
{ content: { parts: [{ text: 'Hello response' }], role: 'model' } },
|
||||
];
|
||||
mockContentResponse.usageMetadata = {
|
||||
promptTokenCount: 0,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: 0,
|
||||
};
|
||||
|
||||
const mockFinalResponse = new GenerateContentResponse();
|
||||
mockFinalResponse.candidates = [
|
||||
{
|
||||
content: { parts: [], role: 'model' },
|
||||
finishReason: FinishReason.STOP,
|
||||
},
|
||||
];
|
||||
mockFinalResponse.usageMetadata = {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 30,
|
||||
};
|
||||
|
||||
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]);
|
||||
(mockConverter.convertOpenAIChunkToGemini as Mock)
|
||||
.mockReturnValueOnce(mockContentResponse)
|
||||
.mockReturnValueOnce(mockFinalResponse);
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue(
|
||||
mockStream,
|
||||
);
|
||||
|
||||
// Act
|
||||
const resultGenerator = await pipeline.executeStream(
|
||||
request,
|
||||
userPromptId,
|
||||
);
|
||||
const results = [];
|
||||
for await (const result of resultGenerator) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Assert
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toBe(mockContentResponse);
|
||||
expect(results[1]).toBe(mockFinalResponse);
|
||||
|
||||
// The last result should have both finishReason and valid usageMetadata
|
||||
const lastResult = results[1];
|
||||
expect(lastResult.candidates?.[0]?.finishReason).toBe(FinishReason.STOP);
|
||||
expect(lastResult.usageMetadata).toEqual({
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
totalTokenCount: 30,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRequest', () => {
|
||||
|
||||
@@ -6,18 +6,15 @@
|
||||
|
||||
import OpenAI from 'openai';
|
||||
import {
|
||||
type GenerateContentParameters,
|
||||
GenerateContentParameters,
|
||||
GenerateContentResponse,
|
||||
} from '@google/genai';
|
||||
import { Config } from '../../config/config.js';
|
||||
import { type ContentGeneratorConfig } from '../contentGenerator.js';
|
||||
import { ContentGeneratorConfig } from '../contentGenerator.js';
|
||||
import { type OpenAICompatibleProvider } from './provider/index.js';
|
||||
import { OpenAIContentConverter } from './converter.js';
|
||||
import {
|
||||
type TelemetryService,
|
||||
type RequestContext,
|
||||
} from './telemetryService.js';
|
||||
import { type ErrorHandler } from './errorHandler.js';
|
||||
import { TelemetryService, RequestContext } from './telemetryService.js';
|
||||
import { ErrorHandler } from './errorHandler.js';
|
||||
|
||||
export interface PipelineConfig {
|
||||
cliConfig: Config;
|
||||
@@ -99,9 +96,8 @@ export class ContentGenerationPipeline {
|
||||
* This method handles the complete stream processing pipeline:
|
||||
* 1. Convert OpenAI chunks to Gemini format while preserving original chunks
|
||||
* 2. Filter empty responses
|
||||
* 3. Handle chunk merging for providers that send finishReason and usageMetadata separately
|
||||
* 4. Collect both formats for logging
|
||||
* 5. Handle success/error logging with original OpenAI format
|
||||
* 3. Collect both formats for logging
|
||||
* 4. Handle success/error logging with original OpenAI format
|
||||
*/
|
||||
private async *processStreamWithLogging(
|
||||
stream: AsyncIterable<OpenAI.Chat.ChatCompletionChunk>,
|
||||
@@ -115,9 +111,6 @@ export class ContentGenerationPipeline {
|
||||
// Reset streaming tool calls to prevent data pollution from previous streams
|
||||
this.converter.resetStreamingToolCalls();
|
||||
|
||||
// State for handling chunk merging
|
||||
let pendingFinishResponse: GenerateContentResponse | null = null;
|
||||
|
||||
try {
|
||||
// Stage 2a: Convert and yield each chunk while preserving original
|
||||
for await (const chunk of stream) {
|
||||
@@ -126,40 +119,18 @@ export class ContentGenerationPipeline {
|
||||
// Stage 2b: Filter empty responses to avoid downstream issues
|
||||
if (
|
||||
response.candidates?.[0]?.content?.parts?.length === 0 &&
|
||||
!response.candidates?.[0]?.finishReason &&
|
||||
!response.usageMetadata
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stage 2c: Handle chunk merging for providers that send finishReason and usageMetadata separately
|
||||
const shouldYield = this.handleChunkMerging(
|
||||
response,
|
||||
chunk,
|
||||
collectedGeminiResponses,
|
||||
collectedOpenAIChunks,
|
||||
(mergedResponse) => {
|
||||
pendingFinishResponse = mergedResponse;
|
||||
},
|
||||
);
|
||||
|
||||
if (shouldYield) {
|
||||
// If we have a pending finish response, yield it instead
|
||||
if (pendingFinishResponse) {
|
||||
yield pendingFinishResponse;
|
||||
pendingFinishResponse = null;
|
||||
} else {
|
||||
yield response;
|
||||
}
|
||||
}
|
||||
// Stage 2c: Collect both formats and yield Gemini format to consumer
|
||||
collectedGeminiResponses.push(response);
|
||||
collectedOpenAIChunks.push(chunk);
|
||||
yield response;
|
||||
}
|
||||
|
||||
// Stage 2d: If there's still a pending finish response at the end, yield it
|
||||
if (pendingFinishResponse) {
|
||||
yield pendingFinishResponse;
|
||||
}
|
||||
|
||||
// Stage 2e: Stream completed successfully - perform logging with original OpenAI chunks
|
||||
// Stage 2d: Stream completed successfully - perform logging with original OpenAI chunks
|
||||
context.duration = Date.now() - context.startTime;
|
||||
|
||||
await this.config.telemetryService.logStreamingSuccess(
|
||||
@@ -185,72 +156,6 @@ export class ContentGenerationPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle chunk merging for providers that send finishReason and usageMetadata separately.
|
||||
*
|
||||
* Strategy: When we encounter a finishReason chunk, we hold it and merge all subsequent
|
||||
* chunks into it until the stream ends. This ensures the final chunk contains both
|
||||
* finishReason and the most up-to-date usage information from any provider pattern.
|
||||
*
|
||||
* @param response Current Gemini response
|
||||
* @param chunk Current OpenAI chunk
|
||||
* @param collectedGeminiResponses Array to collect responses for logging
|
||||
* @param collectedOpenAIChunks Array to collect chunks for logging
|
||||
* @param setPendingFinish Callback to set pending finish response
|
||||
* @returns true if the response should be yielded, false if it should be held for merging
|
||||
*/
|
||||
private handleChunkMerging(
|
||||
response: GenerateContentResponse,
|
||||
chunk: OpenAI.Chat.ChatCompletionChunk,
|
||||
collectedGeminiResponses: GenerateContentResponse[],
|
||||
collectedOpenAIChunks: OpenAI.Chat.ChatCompletionChunk[],
|
||||
setPendingFinish: (response: GenerateContentResponse) => void,
|
||||
): boolean {
|
||||
const isFinishChunk = response.candidates?.[0]?.finishReason;
|
||||
|
||||
// Check if we have a pending finish response from previous chunks
|
||||
const hasPendingFinish =
|
||||
collectedGeminiResponses.length > 0 &&
|
||||
collectedGeminiResponses[collectedGeminiResponses.length - 1]
|
||||
.candidates?.[0]?.finishReason;
|
||||
|
||||
if (isFinishChunk) {
|
||||
// This is a finish reason chunk
|
||||
collectedGeminiResponses.push(response);
|
||||
collectedOpenAIChunks.push(chunk);
|
||||
setPendingFinish(response);
|
||||
return false; // Don't yield yet, wait for potential subsequent chunks to merge
|
||||
} else if (hasPendingFinish) {
|
||||
// We have a pending finish chunk, merge this chunk's data into it
|
||||
const lastResponse =
|
||||
collectedGeminiResponses[collectedGeminiResponses.length - 1];
|
||||
const mergedResponse = new GenerateContentResponse();
|
||||
|
||||
// Keep the finish reason from the previous chunk
|
||||
mergedResponse.candidates = lastResponse.candidates;
|
||||
|
||||
// Merge usage metadata if this chunk has it
|
||||
if (response.usageMetadata) {
|
||||
mergedResponse.usageMetadata = response.usageMetadata;
|
||||
} else {
|
||||
mergedResponse.usageMetadata = lastResponse.usageMetadata;
|
||||
}
|
||||
|
||||
// Update the collected responses with the merged response
|
||||
collectedGeminiResponses[collectedGeminiResponses.length - 1] =
|
||||
mergedResponse;
|
||||
collectedOpenAIChunks.push(chunk);
|
||||
|
||||
setPendingFinish(mergedResponse);
|
||||
return true; // Yield the merged response
|
||||
}
|
||||
|
||||
// Normal chunk - collect and yield
|
||||
collectedGeminiResponses.push(response);
|
||||
collectedOpenAIChunks.push(chunk);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async buildRequest(
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('IdeClient.validateWorkspacePath', () => {
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should return invalid if QWEN_CODE_IDE_WORKSPACE_PATH is undefined', () => {
|
||||
it('should return invalid if GEMINI_CLI_IDE_WORKSPACE_PATH is undefined', () => {
|
||||
const result = IdeClient.validateWorkspacePath(
|
||||
undefined,
|
||||
'VS Code',
|
||||
@@ -28,7 +28,7 @@ describe('IdeClient.validateWorkspacePath', () => {
|
||||
expect(result.error).toContain('Failed to connect');
|
||||
});
|
||||
|
||||
it('should return invalid if QWEN_CODE_IDE_WORKSPACE_PATH is empty', () => {
|
||||
it('should return invalid if GEMINI_CLI_IDE_WORKSPACE_PATH is empty', () => {
|
||||
const result = IdeClient.validateWorkspacePath(
|
||||
'',
|
||||
'VS Code',
|
||||
|
||||
@@ -26,20 +26,17 @@ const QWEN_LOCK_FILENAME = 'oauth_creds.lock';
|
||||
// Token and Cache Configuration
|
||||
const TOKEN_REFRESH_BUFFER_MS = 30 * 1000; // 30 seconds
|
||||
const LOCK_TIMEOUT_MS = 10000; // 10 seconds lock timeout
|
||||
const CACHE_CHECK_INTERVAL_MS = 5000; // 5 seconds cache check interval (increased from 1 second)
|
||||
const CACHE_CHECK_INTERVAL_MS = 1000; // 1 second cache check interval
|
||||
|
||||
// Lock acquisition configuration (can be overridden for testing)
|
||||
interface LockConfig {
|
||||
maxAttempts: number;
|
||||
attemptInterval: number;
|
||||
// Add exponential backoff parameters
|
||||
maxInterval: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LOCK_CONFIG: LockConfig = {
|
||||
maxAttempts: 20, // Reduced from 50 to prevent excessive waiting
|
||||
attemptInterval: 100, // Reduced from 200ms to check more frequently
|
||||
maxInterval: 2000, // Maximum interval for exponential backoff
|
||||
maxAttempts: 50,
|
||||
attemptInterval: 200,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -303,25 +300,7 @@ export class SharedTokenManager {
|
||||
|
||||
try {
|
||||
const filePath = this.getCredentialFilePath();
|
||||
// Add timeout to file stat operation
|
||||
const withTimeout = async <T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
): Promise<T> =>
|
||||
Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(`File operation timed out after ${timeoutMs}ms`),
|
||||
),
|
||||
timeoutMs,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const stats = await withTimeout(fs.stat(filePath), 3000);
|
||||
const stats = await fs.stat(filePath);
|
||||
const fileModTime = stats.mtimeMs;
|
||||
|
||||
// Reload credentials if file has been modified since last cache
|
||||
@@ -444,7 +423,6 @@ export class SharedTokenManager {
|
||||
qwenClient: IQwenOAuth2Client,
|
||||
forceRefresh = false,
|
||||
): Promise<QwenCredentials> {
|
||||
const startTime = Date.now();
|
||||
const lockPath = this.getLockFilePath();
|
||||
|
||||
try {
|
||||
@@ -461,15 +439,6 @@ export class SharedTokenManager {
|
||||
// Acquire distributed file lock
|
||||
await this.acquireLock(lockPath);
|
||||
|
||||
// Check if the operation is taking too long
|
||||
const lockAcquisitionTime = Date.now() - startTime;
|
||||
if (lockAcquisitionTime > 5000) {
|
||||
// 5 seconds warning threshold
|
||||
console.warn(
|
||||
`Token refresh lock acquisition took ${lockAcquisitionTime}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
// Double-check if another process already refreshed the token (unless force refresh is requested)
|
||||
// Skip the time-based throttling since we're already in a locked refresh operation
|
||||
await this.forceFileCheck(qwenClient);
|
||||
@@ -487,13 +456,6 @@ export class SharedTokenManager {
|
||||
// Perform the actual token refresh
|
||||
const response = await qwenClient.refreshAccessToken();
|
||||
|
||||
// Check if the token refresh is taking too long
|
||||
const totalOperationTime = Date.now() - startTime;
|
||||
if (totalOperationTime > 10000) {
|
||||
// 10 seconds warning threshold
|
||||
console.warn(`Token refresh operation took ${totalOperationTime}ms`);
|
||||
}
|
||||
|
||||
if (!response || isErrorResponse(response)) {
|
||||
const errorData = response as ErrorData;
|
||||
throw new TokenManagerError(
|
||||
@@ -589,27 +551,9 @@ export class SharedTokenManager {
|
||||
const dirPath = path.dirname(filePath);
|
||||
const tempPath = `${filePath}.tmp.${randomUUID()}`;
|
||||
|
||||
// Add timeout wrapper for file operations
|
||||
const withTimeout = async <T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
): Promise<T> =>
|
||||
Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`Operation timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
// Create directory with restricted permissions
|
||||
try {
|
||||
await withTimeout(
|
||||
fs.mkdir(dirPath, { recursive: true, mode: 0o700 }),
|
||||
5000,
|
||||
);
|
||||
await fs.mkdir(dirPath, { recursive: true, mode: 0o700 });
|
||||
} catch (error) {
|
||||
throw new TokenManagerError(
|
||||
TokenError.FILE_ACCESS_ERROR,
|
||||
@@ -622,21 +566,18 @@ export class SharedTokenManager {
|
||||
|
||||
try {
|
||||
// Write to temporary file first with restricted permissions
|
||||
await withTimeout(
|
||||
fs.writeFile(tempPath, credString, { mode: 0o600 }),
|
||||
5000,
|
||||
);
|
||||
await fs.writeFile(tempPath, credString, { mode: 0o600 });
|
||||
|
||||
// Atomic move to final location
|
||||
await withTimeout(fs.rename(tempPath, filePath), 5000);
|
||||
await fs.rename(tempPath, filePath);
|
||||
|
||||
// Update cached file modification time atomically after successful write
|
||||
const stats = await withTimeout(fs.stat(filePath), 5000);
|
||||
const stats = await fs.stat(filePath);
|
||||
this.memoryCache.fileModTime = stats.mtimeMs;
|
||||
} catch (error) {
|
||||
// Clean up temp file if it exists
|
||||
try {
|
||||
await withTimeout(fs.unlink(tempPath), 1000);
|
||||
await fs.unlink(tempPath);
|
||||
} catch (_cleanupError) {
|
||||
// Ignore cleanup errors - temp file might not exist
|
||||
}
|
||||
@@ -687,11 +628,9 @@ export class SharedTokenManager {
|
||||
* @throws TokenManagerError if lock cannot be acquired within timeout period
|
||||
*/
|
||||
private async acquireLock(lockPath: string): Promise<void> {
|
||||
const { maxAttempts, attemptInterval, maxInterval } = this.lockConfig;
|
||||
const { maxAttempts, attemptInterval } = this.lockConfig;
|
||||
const lockId = randomUUID(); // Use random UUID instead of PID for security
|
||||
|
||||
let currentInterval = attemptInterval;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
// Attempt to create lock file atomically (exclusive mode)
|
||||
@@ -732,10 +671,8 @@ export class SharedTokenManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Wait before retrying with exponential backoff
|
||||
await new Promise((resolve) => setTimeout(resolve, currentInterval));
|
||||
// Increase interval for next attempt (exponential backoff), but cap at maxInterval
|
||||
currentInterval = Math.min(currentInterval * 1.5, maxInterval);
|
||||
// Wait before retrying
|
||||
await new Promise((resolve) => setTimeout(resolve, attemptInterval));
|
||||
} else {
|
||||
throw new TokenManagerError(
|
||||
TokenError.FILE_ACCESS_ERROR,
|
||||
|
||||
Reference in New Issue
Block a user