mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-26 11:39:22 +00:00
Compare commits
3 Commits
update-sec
...
fix/trimen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
172c05be26 | ||
|
|
04415bd19d | ||
|
|
f9d3fe6fad |
@@ -24,7 +24,7 @@ jobs:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
with:
|
||||
version: 0.0.7
|
||||
version: 0.0.6
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
settings_json: |
|
||||
{
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
ISSUES_TO_TRIAGE: ${{ steps.find_issues.outputs.issues_to_triage }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
with:
|
||||
version: 0.0.7
|
||||
version: 0.0.6
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
|
||||
|
||||
10
.github/workflows/qwen-code-pr-review.yml
vendored
10
.github/workflows/qwen-code-pr-review.yml
vendored
@@ -18,11 +18,7 @@ jobs:
|
||||
review-pr:
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'opened' &&
|
||||
(github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR')) ||
|
||||
(github.event_name == 'pull_request' && github.event.action == 'opened') ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@qwen /review') &&
|
||||
@@ -53,9 +49,9 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get PR details (pull_request_target & workflow_dispatch)
|
||||
- name: Get PR details (pull_request & workflow_dispatch)
|
||||
id: get_pr
|
||||
if: github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch'
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -1,26 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## 0.0.7
|
||||
|
||||
- Fix MCP tools
|
||||
- Fix Web Fetch tool
|
||||
- Fix Web Search tool, by replacing web search from Google/Gemini to Tavily API
|
||||
- Fix: Compatible with occasional tool call parameters returned by LLM that are invalid JSON
|
||||
- Fix: prevent concurrent query submissions on some rare cases
|
||||
- Fix: incorrect qwen logger exit handler setup
|
||||
- Fix: seperate static QR code and dynamic spin components
|
||||
- Sync gemini-cli to v0.1.18
|
||||
|
||||
## 0.0.6
|
||||
|
||||
- Add usage statistics logging for Qwen integration
|
||||
- Make `/init` command respect configured context filename and align docs with QWEN.md
|
||||
- Fix EPERM error when run `qwen --sandbox` in macOS
|
||||
- Fix terminal flicker when waiting for login
|
||||
- Fix `glm-4.5` model request error
|
||||
|
||||
## 0.0.5
|
||||
|
||||
- Support Qwen OAuth login and provide up to 2000 free requests per day
|
||||
- Sync gemini-cli to v0.1.17
|
||||
- Add systemPromptMappings Configuration Feature
|
||||
14
README.md
14
README.md
@@ -15,20 +15,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<!-- Keep these links. Translations will automatically update with the README. -->
|
||||
<a href="https://readme-i18n.com/de/QwenLM/qwen-code">Deutsch</a> |
|
||||
<a href="https://readme-i18n.com/es/QwenLM/qwen-code">Español</a> |
|
||||
<a href="https://readme-i18n.com/fr/QwenLM/qwen-code">français</a> |
|
||||
<a href="https://readme-i18n.com/ja/QwenLM/qwen-code">日本語</a> |
|
||||
<a href="https://readme-i18n.com/ko/QwenLM/qwen-code">한국어</a> |
|
||||
<a href="https://readme-i18n.com/pt/QwenLM/qwen-code">Português</a> |
|
||||
<a href="https://readme-i18n.com/ru/QwenLM/qwen-code">Русский</a> |
|
||||
<a href="https://readme-i18n.com/zh/QwenLM/qwen-code">中文</a>
|
||||
|
||||
</div>
|
||||
|
||||
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
|
||||
|
||||
## 💡 Free Options Available
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Reporting Security Issues
|
||||
|
||||
Please report any security issue or Higress crash report to [ASRC](https://security.alibaba.com/) (Alibaba Security Response Center) where the issue will be triaged appropriately.
|
||||
To report a security issue, please use [https://g.co/vulnz](https://g.co/vulnz).
|
||||
We use g.co/vulnz for our intake, and do coordination and disclosure here on
|
||||
GitHub (including using GitHub Security Advisory). The Google Security Team will
|
||||
respond within 5 working days of your report on g.co/vulnz.
|
||||
|
||||
Thank you for helping keep our project secure.
|
||||
[GitHub Security Advisory]: https://github.com/google-gemini/gemini-cli/security/advisories
|
||||
|
||||
@@ -268,11 +268,6 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
||||
"loadMemoryFromIncludeDirectories": true
|
||||
```
|
||||
|
||||
- **`tavilyApiKey`** (string):
|
||||
- **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped.
|
||||
- **Default:** `undefined` (web search disabled)
|
||||
- **Example:** `"tavilyApiKey": "tvly-your-api-key-here"`
|
||||
|
||||
### Example `settings.json`:
|
||||
|
||||
```json
|
||||
@@ -281,7 +276,6 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
||||
"sandbox": "docker",
|
||||
"toolDiscoveryCommand": "bin/get_tools",
|
||||
"toolCallCommand": "bin/call_tool",
|
||||
"tavilyApiKey": "$TAVILY_API_KEY",
|
||||
"mcpServers": {
|
||||
"mainServer": {
|
||||
"command": "bin/mcp_server.py"
|
||||
@@ -379,11 +373,6 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
|
||||
- **`CODE_ASSIST_ENDPOINT`**:
|
||||
- Specifies the endpoint for the code assist server.
|
||||
- This is useful for development and testing.
|
||||
- **`TAVILY_API_KEY`**:
|
||||
- Your API key for the Tavily web search service.
|
||||
- Required to enable the `web_search` tool functionality.
|
||||
- If not configured, the web search tool will be disabled and skipped.
|
||||
- Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
|
||||
|
||||
## Command-Line Arguments
|
||||
|
||||
@@ -441,9 +430,6 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
- Displays the version of the CLI.
|
||||
- **`--openai-logging`**:
|
||||
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
|
||||
- **`--tavily-api-key <api_key>`**:
|
||||
- Sets the Tavily API key for web search functionality for this session.
|
||||
- Example: `gemini --tavily-api-key tvly-your-api-key-here`
|
||||
|
||||
## Context Files (Hierarchical Instructional Context)
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ This documentation is organized into the following sections:
|
||||
- **[Multi-File Read Tool](./tools/multi-file.md):** Documentation for the `read_many_files` tool.
|
||||
- **[Shell Tool](./tools/shell.md):** Documentation for the `run_shell_command` tool.
|
||||
- **[Web Fetch Tool](./tools/web-fetch.md):** Documentation for the `web_fetch` tool.
|
||||
- **[Web Search Tool](./tools/web-search.md):** Documentation for the `web_search` tool.
|
||||
- **[Web Search Tool](./tools/web-search.md):** Documentation for the `google_web_search` tool.
|
||||
- **[Memory Tool](./tools/memory.md):** Documentation for the `save_memory` tool.
|
||||
- **[Contributing & Development Guide](../CONTRIBUTING.md):** Information for contributors and developers, including setup, building, testing, and coding conventions.
|
||||
- **[NPM Workspaces and Publishing](./npm.md):** Details on how the project's packages are managed and published.
|
||||
|
||||
@@ -4,25 +4,24 @@ This document describes the `web_fetch` tool for the Gemini CLI.
|
||||
|
||||
## Description
|
||||
|
||||
Use `web_fetch` to fetch content from a specified URL and process it using an AI model. The tool takes a URL and a prompt as input, fetches the URL content, converts HTML to markdown, and processes the content with the prompt using a small, fast model.
|
||||
Use `web_fetch` to summarize, compare, or extract information from web pages. The `web_fetch` tool processes content from one or more URLs (up to 20) embedded in a prompt. `web_fetch` takes a natural language prompt and returns a generated response.
|
||||
|
||||
### Arguments
|
||||
|
||||
`web_fetch` takes two arguments:
|
||||
`web_fetch` takes one argument:
|
||||
|
||||
- `url` (string, required): The URL to fetch content from. Must be a fully-formed valid URL starting with `http://` or `https://`.
|
||||
- `prompt` (string, required): The prompt describing what information you want to extract from the page content.
|
||||
- `prompt` (string, required): A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content. For example: `"Summarize https://example.com/article and extract key points from https://another.com/data"`. The prompt must contain at least one URL starting with `http://` or `https://`.
|
||||
|
||||
## How to use `web_fetch` with the Gemini CLI
|
||||
|
||||
To use `web_fetch` with the Gemini CLI, provide a URL and a prompt describing what you want to extract from that URL. The tool will ask for confirmation before fetching the URL. Once confirmed, the tool will fetch the content directly and process it using an AI model.
|
||||
To use `web_fetch` with the Gemini CLI, provide a natural language prompt that contains URLs. The tool will ask for confirmation before fetching any URLs. Once confirmed, the tool will process URLs through Gemini API's `urlContext`.
|
||||
|
||||
The tool automatically converts HTML to text, handles GitHub blob URLs (converting them to raw URLs), and upgrades HTTP URLs to HTTPS for security.
|
||||
If the Gemini API cannot access the URL, the tool will fall back to fetching content directly from the local machine. The tool will format the response, including source attribution and citations where possible. The tool will then provide the response to the user.
|
||||
|
||||
Usage:
|
||||
|
||||
```
|
||||
web_fetch(url="https://example.com", prompt="Summarize the main points of this article")
|
||||
web_fetch(prompt="Your prompt, including a URL such as https://google.com.")
|
||||
```
|
||||
|
||||
## `web_fetch` examples
|
||||
@@ -30,25 +29,16 @@ web_fetch(url="https://example.com", prompt="Summarize the main points of this a
|
||||
Summarize a single article:
|
||||
|
||||
```
|
||||
web_fetch(url="https://example.com/news/latest", prompt="Can you summarize the main points of this article?")
|
||||
web_fetch(prompt="Can you summarize the main points of https://example.com/news/latest")
|
||||
```
|
||||
|
||||
Extract specific information:
|
||||
Compare two articles:
|
||||
|
||||
```
|
||||
web_fetch(url="https://arxiv.org/abs/2401.0001", prompt="What are the key findings and methodology described in this paper?")
|
||||
```
|
||||
|
||||
Analyze GitHub documentation:
|
||||
|
||||
```
|
||||
web_fetch(url="https://github.com/google/gemini-react/blob/main/README.md", prompt="What are the installation steps and main features?")
|
||||
web_fetch(prompt="What are the differences in the conclusions of these two papers: https://arxiv.org/abs/2401.0001 and https://arxiv.org/abs/2401.0002?")
|
||||
```
|
||||
|
||||
## Important notes
|
||||
|
||||
- **Single URL processing:** `web_fetch` processes one URL at a time. To analyze multiple URLs, make separate calls to the tool.
|
||||
- **URL format:** The tool automatically upgrades HTTP URLs to HTTPS and converts GitHub blob URLs to raw format for better content access.
|
||||
- **Content processing:** The tool fetches content directly and processes it using an AI model, converting HTML to readable text format.
|
||||
- **URL processing:** `web_fetch` relies on the Gemini API's ability to access and process the given URLs.
|
||||
- **Output quality:** The quality of the output will depend on the clarity of the instructions in the prompt.
|
||||
- **MCP tools:** If an MCP-provided web fetch tool is available (starting with "mcp\_\_"), prefer using that tool as it may have fewer restrictions.
|
||||
|
||||
@@ -1,43 +1,36 @@
|
||||
# Web Search Tool (`web_search`)
|
||||
# Web Search Tool (`google_web_search`)
|
||||
|
||||
This document describes the `web_search` tool.
|
||||
This document describes the `google_web_search` tool.
|
||||
|
||||
## Description
|
||||
|
||||
Use `web_search` to perform a web search using the Tavily API. The tool returns a concise answer with sources when possible.
|
||||
Use `google_web_search` to perform a web search using Google Search via the Gemini API. The `google_web_search` tool returns a summary of web results with sources.
|
||||
|
||||
### Arguments
|
||||
|
||||
`web_search` takes one argument:
|
||||
`google_web_search` takes one argument:
|
||||
|
||||
- `query` (string, required): The search query.
|
||||
|
||||
## How to use `web_search`
|
||||
## How to use `google_web_search` with the Gemini CLI
|
||||
|
||||
`web_search` calls the Tavily API directly. You must configure the `TAVILY_API_KEY` through one of the following methods:
|
||||
|
||||
1. **Settings file**: Add `"tavilyApiKey": "your-key-here"` to your `settings.json`
|
||||
2. **Environment variable**: Set `TAVILY_API_KEY` in your environment or `.env` file
|
||||
3. **Command line**: Use `--tavily-api-key your-key-here` when running the CLI
|
||||
|
||||
If the key is not configured, the tool will be disabled and skipped.
|
||||
The `google_web_search` tool sends a query to the Gemini API, which then performs a web search. `google_web_search` will return a generated response based on the search results, including citations and sources.
|
||||
|
||||
Usage:
|
||||
|
||||
```
|
||||
web_search(query="Your query goes here.")
|
||||
google_web_search(query="Your query goes here.")
|
||||
```
|
||||
|
||||
## `web_search` examples
|
||||
## `google_web_search` examples
|
||||
|
||||
Get information on a topic:
|
||||
|
||||
```
|
||||
web_search(query="latest advancements in AI-powered code generation")
|
||||
google_web_search(query="latest advancements in AI-powered code generation")
|
||||
```
|
||||
|
||||
## Important notes
|
||||
|
||||
- **Response returned:** The `web_search` tool returns a concise answer when available, with a list of source links.
|
||||
- **Citations:** Source links are appended as a numbered list.
|
||||
- **API key:** Configure `TAVILY_API_KEY` via settings.json, environment variables, .env files, or command line arguments. If not configured, the tool is not registered.
|
||||
- **Response returned:** The `google_web_search` tool returns a processed summary, not a raw list of search results.
|
||||
- **Citations:** The response includes citations to the sources used to generate the summary.
|
||||
|
||||
@@ -9,11 +9,6 @@ import { strict as assert } from 'assert';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
test('should be able to search the web', async () => {
|
||||
// Skip if Tavily key is not configured
|
||||
if (!process.env.TAVILY_API_KEY) {
|
||||
console.warn('Skipping web search test: TAVILY_API_KEY not set');
|
||||
return;
|
||||
}
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should be able to search the web');
|
||||
|
||||
@@ -32,7 +27,7 @@ test('should be able to search the web', async () => {
|
||||
throw error; // Re-throw if not a network error
|
||||
}
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('web_search');
|
||||
const foundToolCall = await rig.waitForToolCall('google_web_search');
|
||||
|
||||
// Add debugging information
|
||||
if (!foundToolCall) {
|
||||
@@ -40,11 +35,12 @@ test('should be able to search the web', async () => {
|
||||
|
||||
// Check if the tool call failed due to network issues
|
||||
const failedSearchCalls = allTools.filter(
|
||||
(t) => t.toolRequest.name === 'web_search' && !t.toolRequest.success,
|
||||
(t) =>
|
||||
t.toolRequest.name === 'google_web_search' && !t.toolRequest.success,
|
||||
);
|
||||
if (failedSearchCalls.length > 0) {
|
||||
console.warn(
|
||||
'web_search tool was called but failed, possibly due to network issues',
|
||||
'google_web_search tool was called but failed, possibly due to network issues',
|
||||
);
|
||||
console.warn(
|
||||
'Failed calls:',
|
||||
@@ -54,20 +50,20 @@ test('should be able to search the web', async () => {
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(foundToolCall, 'Expected to find a call to web_search');
|
||||
assert.ok(foundToolCall, 'Expected to find a call to google_web_search');
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
const hasExpectedContent = validateModelOutput(
|
||||
result,
|
||||
['weather', 'london'],
|
||||
'Web search test',
|
||||
'Google web search test',
|
||||
);
|
||||
|
||||
// If content was missing, log the search queries used
|
||||
if (!hasExpectedContent) {
|
||||
const searchCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((t) => t.toolRequest.name === 'web_search');
|
||||
.filter((t) => t.toolRequest.name === 'google_web_search');
|
||||
if (searchCalls.length > 0) {
|
||||
console.warn(
|
||||
'Search queries used:',
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.6",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -7421,15 +7421,6 @@
|
||||
"json5": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonrepair": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.0.tgz",
|
||||
"integrity": "sha512-5YRzlAQ7tuzV1nAJu3LvDlrKtBFIALHN2+a+I1MGJCt3ldRDBF/bZuvIPzae8Epot6KBXd0awRZZcuoeAsZ/mw==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"jsonrepair": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
@@ -11700,7 +11691,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.6",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.9.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -11904,7 +11895,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.6",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.9.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
@@ -11925,7 +11916,6 @@
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ignore": "^7.0.0",
|
||||
"jsonrepair": "^3.13.0",
|
||||
"marked": "^15.0.12",
|
||||
"micromatch": "^4.0.8",
|
||||
"open": "^10.1.2",
|
||||
@@ -12063,7 +12053,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.6",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.6",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.7"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.6"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.6",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.7"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.9.0",
|
||||
|
||||
@@ -69,7 +69,6 @@ export interface CliArgs {
|
||||
proxy: string | undefined;
|
||||
includeDirectories: string[] | undefined;
|
||||
loadMemoryFromIncludeDirectories: boolean | undefined;
|
||||
tavilyApiKey: string | undefined;
|
||||
}
|
||||
|
||||
export async function parseArguments(): Promise<CliArgs> {
|
||||
@@ -216,10 +215,6 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
type: 'string',
|
||||
description: 'OpenAI base URL (for custom endpoints)',
|
||||
})
|
||||
.option('tavily-api-key', {
|
||||
type: 'string',
|
||||
description: 'Tavily API key for web search functionality',
|
||||
})
|
||||
.option('proxy', {
|
||||
type: 'string',
|
||||
description:
|
||||
@@ -339,11 +334,6 @@ export async function loadCliConfig(
|
||||
process.env.OPENAI_BASE_URL = argv.openaiBaseUrl;
|
||||
}
|
||||
|
||||
// Handle Tavily API key from command line
|
||||
if (argv.tavilyApiKey) {
|
||||
process.env.TAVILY_API_KEY = argv.tavilyApiKey;
|
||||
}
|
||||
|
||||
// Set the context filename in the server's memoryTool module BEFORE loading memory
|
||||
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
|
||||
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
|
||||
@@ -523,8 +513,6 @@ export async function loadCliConfig(
|
||||
],
|
||||
contentGenerator: settings.contentGenerator,
|
||||
cliVersion,
|
||||
tavilyApiKey:
|
||||
argv.tavilyApiKey || settings.tavilyApiKey || process.env.TAVILY_API_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -147,9 +147,6 @@ export interface Settings {
|
||||
includeDirectories?: string[];
|
||||
|
||||
loadMemoryFromIncludeDirectories?: boolean;
|
||||
|
||||
// Web search API keys
|
||||
tavilyApiKey?: string;
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Text, useInput, Static } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import Link from 'ink-link';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
@@ -26,93 +26,17 @@ interface QwenOAuthProgressProps {
|
||||
authMessage?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static QR Code Display Component
|
||||
* Renders the QR code and URL once and doesn't re-render unless the URL changes
|
||||
*/
|
||||
function QrCodeDisplay({
|
||||
verificationUrl,
|
||||
qrCodeData,
|
||||
}: {
|
||||
verificationUrl: string;
|
||||
qrCodeData: string | null;
|
||||
}): React.JSX.Element | null {
|
||||
if (!qrCodeData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
Qwen OAuth Authentication
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>Please visit this URL to authorize:</Text>
|
||||
</Box>
|
||||
|
||||
<Link url={verificationUrl} fallback={false}>
|
||||
<Text color={Colors.AccentGreen} bold>
|
||||
{verificationUrl}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>Or scan the QR code below:</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{qrCodeData}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic Status Display Component
|
||||
* Shows the loading spinner, timer, and status messages
|
||||
*/
|
||||
function StatusDisplay({
|
||||
timeRemaining,
|
||||
dots,
|
||||
}: {
|
||||
timeRemaining: number;
|
||||
dots: string;
|
||||
}): React.JSX.Element {
|
||||
const formatTime = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Spinner type="dots" /> Waiting for authorization{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text color={Colors.Gray}>
|
||||
Time remaining: {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
interface StaticItem {
|
||||
key: string;
|
||||
type:
|
||||
| 'title'
|
||||
| 'instructions'
|
||||
| 'url'
|
||||
| 'qr-instructions'
|
||||
| 'qr-code'
|
||||
| 'auth-content';
|
||||
url?: string;
|
||||
qrCode?: string;
|
||||
}
|
||||
|
||||
export function QwenOAuthProgress({
|
||||
@@ -136,29 +60,33 @@ export function QwenOAuthProgress({
|
||||
}
|
||||
});
|
||||
|
||||
// Generate QR code once when device auth is available
|
||||
// Generate QR code when device auth is available
|
||||
useEffect(() => {
|
||||
if (!deviceAuth?.verification_uri_complete) {
|
||||
if (!deviceAuth) {
|
||||
setQrCodeData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const generateQR = () => {
|
||||
try {
|
||||
qrcode.generate(
|
||||
deviceAuth.verification_uri_complete,
|
||||
{ small: true },
|
||||
(qrcode: string) => {
|
||||
setQrCodeData(qrcode);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error);
|
||||
setQrCodeData(null);
|
||||
}
|
||||
};
|
||||
// Only generate QR code if we don't have one yet for this URL
|
||||
if (qrCodeData === null) {
|
||||
const generateQR = () => {
|
||||
try {
|
||||
qrcode.generate(
|
||||
deviceAuth.verification_uri_complete,
|
||||
{ small: true },
|
||||
(qrcode: string) => {
|
||||
setQrCodeData(qrcode);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error);
|
||||
setQrCodeData(null);
|
||||
}
|
||||
};
|
||||
|
||||
generateQR();
|
||||
}, [deviceAuth?.verification_uri_complete]);
|
||||
generateQR();
|
||||
}
|
||||
}, [deviceAuth, qrCodeData]);
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
@@ -187,17 +115,11 @@ export function QwenOAuthProgress({
|
||||
return () => clearInterval(dotsTimer);
|
||||
}, []);
|
||||
|
||||
// Memoize the QR code display to prevent unnecessary re-renders
|
||||
const qrCodeDisplay = useMemo(() => {
|
||||
if (!deviceAuth?.verification_uri_complete) return null;
|
||||
|
||||
return (
|
||||
<QrCodeDisplay
|
||||
verificationUrl={deviceAuth.verification_uri_complete}
|
||||
qrCodeData={qrCodeData}
|
||||
/>
|
||||
);
|
||||
}, [deviceAuth?.verification_uri_complete, qrCodeData]);
|
||||
const formatTime = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Handle timeout state
|
||||
if (authStatus === 'timeout') {
|
||||
@@ -229,7 +151,6 @@ export function QwenOAuthProgress({
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading state when no device auth is available yet
|
||||
if (!deviceAuth) {
|
||||
return (
|
||||
<Box
|
||||
@@ -246,8 +167,7 @@ export function QwenOAuthProgress({
|
||||
</Box>
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text color={Colors.Gray}>
|
||||
Time remaining: {Math.floor(timeRemaining / 60)}:
|
||||
{(timeRemaining % 60).toString().padStart(2, '0')}
|
||||
Time remaining: {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
|
||||
</Box>
|
||||
@@ -256,12 +176,77 @@ export function QwenOAuthProgress({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Static QR Code Display */}
|
||||
{qrCodeDisplay}
|
||||
<>
|
||||
{qrCodeData && (
|
||||
<Static
|
||||
items={
|
||||
[
|
||||
{
|
||||
key: 'auth-content',
|
||||
type: 'auth-content' as const,
|
||||
url: deviceAuth.verification_uri_complete,
|
||||
qrCode: qrCodeData,
|
||||
},
|
||||
] as StaticItem[]
|
||||
}
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{(item: StaticItem) => (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
key={item.key}
|
||||
>
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
Qwen OAuth Authentication
|
||||
</Text>
|
||||
|
||||
{/* Dynamic Status Display */}
|
||||
<StatusDisplay timeRemaining={timeRemaining} dots={dots} />
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>Please visit this URL to authorize:</Text>
|
||||
</Box>
|
||||
|
||||
<Link url={item.url || ''} fallback={false}>
|
||||
<Text color={Colors.AccentGreen} bold>
|
||||
{item.url || ''}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>Or scan the QR code below:</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{item.qrCode || ''}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Static>
|
||||
)}
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Spinner type="dots" /> Waiting for authorization{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text color={Colors.Gray}>
|
||||
Time remaining: {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -562,10 +562,6 @@ export async function start_sandbox(
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
args.push('--env', `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`);
|
||||
}
|
||||
// copy TAVILY_API_KEY for web search tool
|
||||
if (process.env.TAVILY_API_KEY) {
|
||||
args.push('--env', `TAVILY_API_KEY=${process.env.TAVILY_API_KEY}`);
|
||||
}
|
||||
if (process.env.OPENAI_BASE_URL) {
|
||||
args.push('--env', `OPENAI_BASE_URL=${process.env.OPENAI_BASE_URL}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.6",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -39,7 +39,6 @@
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ignore": "^7.0.0",
|
||||
"jsonrepair": "^3.13.0",
|
||||
"marked": "^15.0.12",
|
||||
"micromatch": "^4.0.8",
|
||||
"open": "^10.1.2",
|
||||
|
||||
@@ -211,8 +211,6 @@ export interface ConfigParameters {
|
||||
};
|
||||
cliVersion?: string;
|
||||
loadMemoryFromIncludeDirectories?: boolean;
|
||||
// Web search providers
|
||||
tavilyApiKey?: string;
|
||||
}
|
||||
|
||||
export class Config {
|
||||
@@ -288,7 +286,6 @@ export class Config {
|
||||
};
|
||||
private readonly cliVersion?: string;
|
||||
private readonly loadMemoryFromIncludeDirectories: boolean = false;
|
||||
private readonly tavilyApiKey?: string;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this.sessionId = params.sessionId;
|
||||
@@ -366,9 +363,6 @@ export class Config {
|
||||
this.loadMemoryFromIncludeDirectories =
|
||||
params.loadMemoryFromIncludeDirectories ?? false;
|
||||
|
||||
// Web search
|
||||
this.tavilyApiKey = params.tavilyApiKey;
|
||||
|
||||
if (params.contextFileName) {
|
||||
setGeminiMdFilename(params.contextFileName);
|
||||
}
|
||||
@@ -701,11 +695,6 @@ export class Config {
|
||||
return this.summarizeToolOutput;
|
||||
}
|
||||
|
||||
// Web search provider configuration
|
||||
getTavilyApiKey(): string | undefined {
|
||||
return this.tavilyApiKey;
|
||||
}
|
||||
|
||||
getIdeModeFeature(): boolean {
|
||||
return this.ideModeFeature;
|
||||
}
|
||||
@@ -816,10 +805,7 @@ export class Config {
|
||||
registerCoreTool(ReadManyFilesTool, this);
|
||||
registerCoreTool(ShellTool, this);
|
||||
registerCoreTool(MemoryTool);
|
||||
// Conditionally register web search tool only if Tavily API key is set
|
||||
if (this.getTavilyApiKey()) {
|
||||
registerCoreTool(WebSearchTool, this);
|
||||
}
|
||||
registerCoreTool(WebSearchTool, this);
|
||||
|
||||
await registry.discoverAllTools();
|
||||
return registry;
|
||||
|
||||
@@ -1160,90 +1160,6 @@ describe('OpenAIContentGenerator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle MCP tools with parametersJsonSchema', async () => {
|
||||
const mockResponse = {
|
||||
id: 'chatcmpl-123',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Response' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
created: 1677652288,
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Test' }] }],
|
||||
model: 'gpt-4',
|
||||
config: {
|
||||
tools: [
|
||||
{
|
||||
callTool: vi.fn(),
|
||||
tool: () =>
|
||||
Promise.resolve({
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'list-items',
|
||||
description: 'Get a list of items',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
page_number: {
|
||||
type: 'number',
|
||||
description: 'Page number',
|
||||
},
|
||||
page_size: {
|
||||
type: 'number',
|
||||
description: 'Number of items per page',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as CallableTool,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await generator.generateContent(request, 'test-prompt-id');
|
||||
|
||||
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list-items',
|
||||
description: 'Get a list of items',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
page_number: {
|
||||
type: 'number',
|
||||
description: 'Page number',
|
||||
},
|
||||
page_size: {
|
||||
type: 'number',
|
||||
description: 'Number of items per page',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle nested parameter objects', async () => {
|
||||
const mockResponse = {
|
||||
id: 'chatcmpl-123',
|
||||
|
||||
@@ -26,7 +26,6 @@ import { logApiError, logApiResponse } from '../telemetry/loggers.js';
|
||||
import { ApiErrorEvent, ApiResponseEvent } from '../telemetry/types.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { openaiLogger } from '../utils/openaiLogger.js';
|
||||
import { safeJsonParse } from '../utils/safeJsonParse.js';
|
||||
|
||||
// OpenAI API type definitions for logging
|
||||
interface OpenAIToolCall {
|
||||
@@ -366,6 +365,8 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
);
|
||||
}
|
||||
|
||||
// console.log('createParams', createParams);
|
||||
|
||||
const stream = (await this.client.chat.completions.create(
|
||||
createParams,
|
||||
)) as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>;
|
||||
@@ -740,16 +741,6 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
return convertTypes(converted) as Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Gemini tools to OpenAI format for API compatibility.
|
||||
* Handles both Gemini tools (using 'parameters' field) and MCP tools (using 'parametersJsonSchema' field).
|
||||
*
|
||||
* Gemini tools use a custom parameter format that needs conversion to OpenAI JSON Schema format.
|
||||
* MCP tools already use JSON Schema format in the parametersJsonSchema field and can be used directly.
|
||||
*
|
||||
* @param geminiTools - Array of Gemini tools to convert
|
||||
* @returns Promise resolving to array of OpenAI-compatible tools
|
||||
*/
|
||||
private async convertGeminiToolsToOpenAI(
|
||||
geminiTools: ToolListUnion,
|
||||
): Promise<OpenAI.Chat.ChatCompletionTool[]> {
|
||||
@@ -770,31 +761,14 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
if (actualTool.functionDeclarations) {
|
||||
for (const func of actualTool.functionDeclarations) {
|
||||
if (func.name && func.description) {
|
||||
let parameters: Record<string, unknown> | undefined;
|
||||
|
||||
// Handle both Gemini tools (parameters) and MCP tools (parametersJsonSchema)
|
||||
if (func.parametersJsonSchema) {
|
||||
// MCP tool format - use parametersJsonSchema directly
|
||||
if (func.parametersJsonSchema) {
|
||||
// Create a shallow copy to avoid mutating the original object
|
||||
const paramsCopy = {
|
||||
...(func.parametersJsonSchema as Record<string, unknown>),
|
||||
};
|
||||
parameters = paramsCopy;
|
||||
}
|
||||
} else if (func.parameters) {
|
||||
// Gemini tool format - convert parameters to OpenAI format
|
||||
parameters = this.convertGeminiParametersToOpenAI(
|
||||
func.parameters as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
openAITools.push({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: func.name,
|
||||
description: func.description,
|
||||
parameters,
|
||||
parameters: this.convertGeminiParametersToOpenAI(
|
||||
(func.parameters || {}) as Record<string, unknown>,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1177,7 +1151,12 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
if (toolCall.function) {
|
||||
let args: Record<string, unknown> = {};
|
||||
if (toolCall.function.arguments) {
|
||||
args = safeJsonParse(toolCall.function.arguments, {});
|
||||
try {
|
||||
args = JSON.parse(toolCall.function.arguments);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse function arguments:', error);
|
||||
args = {};
|
||||
}
|
||||
}
|
||||
|
||||
parts.push({
|
||||
@@ -1293,7 +1272,14 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
if (accumulatedCall.name) {
|
||||
let args: Record<string, unknown> = {};
|
||||
if (accumulatedCall.arguments) {
|
||||
args = safeJsonParse(accumulatedCall.arguments, {});
|
||||
try {
|
||||
args = JSON.parse(accumulatedCall.arguments);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to parse final tool call arguments:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
parts.push({
|
||||
|
||||
@@ -82,12 +82,10 @@ export class QwenLogger {
|
||||
return undefined;
|
||||
if (!QwenLogger.instance) {
|
||||
QwenLogger.instance = new QwenLogger(config);
|
||||
process.on(
|
||||
'exit',
|
||||
QwenLogger.instance.shutdown.bind(QwenLogger.instance),
|
||||
);
|
||||
}
|
||||
|
||||
process.on('exit', QwenLogger.instance.shutdown.bind(QwenLogger.instance));
|
||||
|
||||
return QwenLogger.instance;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import { Config } from '../config/config.js';
|
||||
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
|
||||
// Mock the child_process module to control grep/git grep behavior
|
||||
vi.mock('child_process', () => ({
|
||||
@@ -33,14 +32,9 @@ describe('GrepTool', () => {
|
||||
let grepTool: GrepTool;
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
const mockFileService = {
|
||||
getGeminiIgnorePatterns: () => [],
|
||||
} as unknown as FileDiscoveryService;
|
||||
|
||||
const mockConfig = {
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
|
||||
getFileService: () => mockFileService,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -226,43 +220,6 @@ describe('GrepTool', () => {
|
||||
"Model provided invalid parameters. Error: params must have required property 'pattern'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude files matching geminiIgnorePatterns', async () => {
|
||||
// Create a file that should be ignored
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, 'ignored-file.txt'),
|
||||
'this file should be ignored\nit contains the word world',
|
||||
);
|
||||
|
||||
// Update the mock file service to return ignore patterns
|
||||
mockFileService.getGeminiIgnorePatterns = () => ['ignored-file.txt'];
|
||||
|
||||
// Re-create the grep tool with the updated mock
|
||||
const grepToolWithIgnore = new GrepTool(mockConfig);
|
||||
|
||||
// Search for 'world' which exists in both the regular file and the ignored file
|
||||
const params: GrepToolParams = { pattern: 'world' };
|
||||
const result = await grepToolWithIgnore.execute(params, abortSignal);
|
||||
|
||||
// Should only find matches in the non-ignored files (3 matches)
|
||||
expect(result.llmContent).toContain(
|
||||
'Found 3 matches for pattern "world" in the workspace directory',
|
||||
);
|
||||
|
||||
// Should find matches in the regular files
|
||||
expect(result.llmContent).toContain('File: fileA.txt');
|
||||
expect(result.llmContent).toContain('L1: hello world');
|
||||
expect(result.llmContent).toContain('L2: second line with world');
|
||||
expect(result.llmContent).toContain(
|
||||
`File: ${path.join('sub', 'fileC.txt')}`,
|
||||
);
|
||||
expect(result.llmContent).toContain('L1: another world in sub dir');
|
||||
|
||||
// Should NOT find matches in the ignored file
|
||||
expect(result.llmContent).not.toContain('ignored-file.txt');
|
||||
|
||||
expect(result.returnDisplay).toBe('Found 3 matches');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-directory workspace', () => {
|
||||
@@ -281,15 +238,10 @@ describe('GrepTool', () => {
|
||||
);
|
||||
|
||||
// Create a mock config with multiple directories
|
||||
const multiDirFileService = {
|
||||
getGeminiIgnorePatterns: () => [],
|
||||
};
|
||||
|
||||
const multiDirConfig = {
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext(tempRootDir, [secondDir]),
|
||||
getFileService: () => multiDirFileService,
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGrepTool = new GrepTool(multiDirConfig);
|
||||
@@ -335,15 +287,10 @@ describe('GrepTool', () => {
|
||||
);
|
||||
|
||||
// Create a mock config with multiple directories
|
||||
const multiDirFileService = {
|
||||
getGeminiIgnorePatterns: () => [],
|
||||
};
|
||||
|
||||
const multiDirConfig = {
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext(tempRootDir, [secondDir]),
|
||||
getFileService: () => multiDirFileService,
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGrepTool = new GrepTool(multiDirConfig);
|
||||
|
||||
@@ -536,18 +536,12 @@ export class GrepTool extends BaseTool<GrepToolParams, ToolResult> {
|
||||
);
|
||||
strategyUsed = 'javascript fallback';
|
||||
const globPattern = include ? include : '**/*';
|
||||
|
||||
// Get the file discovery service to check ignore patterns
|
||||
const fileDiscovery = this.config.getFileService();
|
||||
|
||||
// Basic ignore patterns
|
||||
const ignorePatterns = [
|
||||
'.git/**',
|
||||
'node_modules/**',
|
||||
'bower_components/**',
|
||||
'.svn/**',
|
||||
'.hg/**',
|
||||
...fileDiscovery.getGeminiIgnorePatterns(),
|
||||
]; // Use glob patterns for ignores here
|
||||
|
||||
const filesIterator = globIterate(globPattern, {
|
||||
|
||||
@@ -203,7 +203,7 @@ describe('MemoryTool', () => {
|
||||
});
|
||||
|
||||
it('should call performAddMemoryEntry with correct parameters and return success', async () => {
|
||||
const params = { fact: 'The sky is blue', scope: 'global' as const };
|
||||
const params = { fact: 'The sky is blue' };
|
||||
const result = await memoryTool.execute(params, mockAbortSignal);
|
||||
// Use getCurrentGeminiMdFilename for the default expectation before any setGeminiMdFilename calls in a test
|
||||
const expectedFilePath = path.join(
|
||||
@@ -224,7 +224,7 @@ describe('MemoryTool', () => {
|
||||
expectedFilePath,
|
||||
expectedFsArgument,
|
||||
);
|
||||
const successMessage = `Okay, I've remembered that in global memory: "${params.fact}"`;
|
||||
const successMessage = `Okay, I've remembered that: "${params.fact}"`;
|
||||
expect(result.llmContent).toBe(
|
||||
JSON.stringify({ success: true, message: successMessage }),
|
||||
);
|
||||
@@ -244,7 +244,7 @@ describe('MemoryTool', () => {
|
||||
});
|
||||
|
||||
it('should handle errors from performAddMemoryEntry', async () => {
|
||||
const params = { fact: 'This will fail', scope: 'global' as const };
|
||||
const params = { fact: 'This will fail' };
|
||||
const underlyingError = new Error(
|
||||
'[MemoryTool] Failed to add memory entry: Disk full',
|
||||
);
|
||||
@@ -276,7 +276,7 @@ describe('MemoryTool', () => {
|
||||
});
|
||||
|
||||
it('should return confirmation details when memory file is not allowlisted', async () => {
|
||||
const params = { fact: 'Test fact', scope: 'global' as const };
|
||||
const params = { fact: 'Test fact' };
|
||||
const result = await memoryTool.shouldConfirmExecute(
|
||||
params,
|
||||
mockAbortSignal,
|
||||
@@ -287,9 +287,7 @@ describe('MemoryTool', () => {
|
||||
|
||||
if (result && result.type === 'edit') {
|
||||
const expectedPath = path.join('~', '.qwen', 'QWEN.md');
|
||||
expect(result.title).toBe(
|
||||
`Confirm Memory Save: ${expectedPath} (global)`,
|
||||
);
|
||||
expect(result.title).toBe(`Confirm Memory Save: ${expectedPath}`);
|
||||
expect(result.fileName).toContain(path.join('mock', 'home', '.qwen'));
|
||||
expect(result.fileName).toContain('QWEN.md');
|
||||
expect(result.fileDiff).toContain('Index: QWEN.md');
|
||||
@@ -302,16 +300,16 @@ describe('MemoryTool', () => {
|
||||
});
|
||||
|
||||
it('should return false when memory file is already allowlisted', async () => {
|
||||
const params = { fact: 'Test fact', scope: 'global' as const };
|
||||
const params = { fact: 'Test fact' };
|
||||
const memoryFilePath = path.join(
|
||||
os.homedir(),
|
||||
'.qwen',
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
|
||||
// Add the memory file to the allowlist with the new key format
|
||||
// Add the memory file to the allowlist
|
||||
(MemoryTool as unknown as { allowlist: Set<string> }).allowlist.add(
|
||||
`${memoryFilePath}_global`,
|
||||
memoryFilePath,
|
||||
);
|
||||
|
||||
const result = await memoryTool.shouldConfirmExecute(
|
||||
@@ -323,7 +321,7 @@ describe('MemoryTool', () => {
|
||||
});
|
||||
|
||||
it('should add memory file to allowlist when ProceedAlways is confirmed', async () => {
|
||||
const params = { fact: 'Test fact', scope: 'global' as const };
|
||||
const params = { fact: 'Test fact' };
|
||||
const memoryFilePath = path.join(
|
||||
os.homedir(),
|
||||
'.qwen',
|
||||
@@ -342,10 +340,10 @@ describe('MemoryTool', () => {
|
||||
// Simulate the onConfirm callback
|
||||
await result.onConfirm(ToolConfirmationOutcome.ProceedAlways);
|
||||
|
||||
// Check that the memory file was added to the allowlist with the new key format
|
||||
// Check that the memory file was added to the allowlist
|
||||
expect(
|
||||
(MemoryTool as unknown as { allowlist: Set<string> }).allowlist.has(
|
||||
`${memoryFilePath}_global`,
|
||||
memoryFilePath,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
@@ -386,7 +384,7 @@ describe('MemoryTool', () => {
|
||||
});
|
||||
|
||||
it('should handle existing memory file with content', async () => {
|
||||
const params = { fact: 'New fact', scope: 'global' as const };
|
||||
const params = { fact: 'New fact' };
|
||||
const existingContent =
|
||||
'Some existing content.\n\n## Qwen Added Memories\n- Old fact\n';
|
||||
|
||||
@@ -403,9 +401,7 @@ describe('MemoryTool', () => {
|
||||
|
||||
if (result && result.type === 'edit') {
|
||||
const expectedPath = path.join('~', '.qwen', 'QWEN.md');
|
||||
expect(result.title).toBe(
|
||||
`Confirm Memory Save: ${expectedPath} (global)`,
|
||||
);
|
||||
expect(result.title).toBe(`Confirm Memory Save: ${expectedPath}`);
|
||||
expect(result.fileDiff).toContain('Index: QWEN.md');
|
||||
expect(result.fileDiff).toContain('+- New fact');
|
||||
expect(result.originalContent).toBe(existingContent);
|
||||
@@ -413,37 +409,5 @@ describe('MemoryTool', () => {
|
||||
expect(result.newContent).toContain('- New fact');
|
||||
}
|
||||
});
|
||||
|
||||
it('should prompt for scope selection when scope is not specified', async () => {
|
||||
const params = { fact: 'Test fact' };
|
||||
const result = await memoryTool.shouldConfirmExecute(
|
||||
params,
|
||||
mockAbortSignal,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toBe(false);
|
||||
|
||||
if (result && result.type === 'edit') {
|
||||
expect(result.title).toBe('Choose Memory Storage Location');
|
||||
expect(result.fileName).toBe('Memory Storage Options');
|
||||
expect(result.fileDiff).toContain('Choose where to save this memory');
|
||||
expect(result.fileDiff).toContain('Test fact');
|
||||
expect(result.fileDiff).toContain('Global:');
|
||||
expect(result.fileDiff).toContain('Project:');
|
||||
expect(result.originalContent).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when executing without scope parameter', async () => {
|
||||
const params = { fact: 'Test fact' };
|
||||
const result = await memoryTool.execute(params, mockAbortSignal);
|
||||
|
||||
expect(result.llmContent).toContain(
|
||||
'Please specify where to save this memory',
|
||||
);
|
||||
expect(result.returnDisplay).toContain('Global:');
|
||||
expect(result.returnDisplay).toContain('Project:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,12 +32,6 @@ const memoryToolSchemaData: FunctionDeclaration = {
|
||||
description:
|
||||
'The specific fact or piece of information to remember. Should be a clear, self-contained statement.',
|
||||
},
|
||||
scope: {
|
||||
type: Type.STRING,
|
||||
description:
|
||||
'Where to save the memory: "global" saves to user-level ~/.qwen/QWEN.md (shared across all projects), "project" saves to current project\'s QWEN.md (project-specific). If not specified, will prompt user to choose.',
|
||||
enum: ['global', 'project'],
|
||||
},
|
||||
},
|
||||
required: ['fact'],
|
||||
},
|
||||
@@ -60,10 +54,6 @@ Do NOT use this tool:
|
||||
## Parameters
|
||||
|
||||
- \`fact\` (string, required): The specific fact or piece of information to remember. This should be a clear, self-contained statement. For example, if the user says "My favorite color is blue", the fact would be "My favorite color is blue".
|
||||
- \`scope\` (string, optional): Where to save the memory:
|
||||
- "global": Saves to user-level ~/.qwen/QWEN.md (shared across all projects)
|
||||
- "project": Saves to current project's QWEN.md (project-specific)
|
||||
- If not specified, the tool will ask the user where they want to save the memory.
|
||||
`;
|
||||
|
||||
export const GEMINI_CONFIG_DIR = '.qwen';
|
||||
@@ -102,23 +92,12 @@ interface SaveMemoryParams {
|
||||
fact: string;
|
||||
modified_by_user?: boolean;
|
||||
modified_content?: string;
|
||||
scope?: 'global' | 'project';
|
||||
}
|
||||
|
||||
function getGlobalMemoryFilePath(): string {
|
||||
return path.join(homedir(), GEMINI_CONFIG_DIR, getCurrentGeminiMdFilename());
|
||||
}
|
||||
|
||||
function getProjectMemoryFilePath(): string {
|
||||
return path.join(process.cwd(), getCurrentGeminiMdFilename());
|
||||
}
|
||||
|
||||
function getMemoryFilePath(scope: 'global' | 'project' = 'global'): string {
|
||||
return scope === 'project'
|
||||
? getProjectMemoryFilePath()
|
||||
: getGlobalMemoryFilePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures proper newline separation before appending content.
|
||||
*/
|
||||
@@ -148,20 +127,17 @@ export class MemoryTool
|
||||
);
|
||||
}
|
||||
|
||||
getDescription(params: SaveMemoryParams): string {
|
||||
const scope = params.scope || 'global';
|
||||
const memoryFilePath = getMemoryFilePath(scope);
|
||||
return `in ${tildeifyPath(memoryFilePath)} (${scope})`;
|
||||
getDescription(_params: SaveMemoryParams): string {
|
||||
const memoryFilePath = getGlobalMemoryFilePath();
|
||||
return `in ${tildeifyPath(memoryFilePath)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current content of the memory file
|
||||
*/
|
||||
private async readMemoryFileContent(
|
||||
scope: 'global' | 'project' = 'global',
|
||||
): Promise<string> {
|
||||
private async readMemoryFileContent(): Promise<string> {
|
||||
try {
|
||||
return await fs.readFile(getMemoryFilePath(scope), 'utf-8');
|
||||
return await fs.readFile(getGlobalMemoryFilePath(), 'utf-8');
|
||||
} catch (err) {
|
||||
const error = err as Error & { code?: string };
|
||||
if (!(error instanceof Error) || error.code !== 'ENOENT') throw err;
|
||||
@@ -217,35 +193,15 @@ export class MemoryTool
|
||||
params: SaveMemoryParams,
|
||||
_abortSignal: AbortSignal,
|
||||
): Promise<ToolEditConfirmationDetails | false> {
|
||||
// If scope is not specified, prompt the user to choose
|
||||
if (!params.scope) {
|
||||
const globalPath = tildeifyPath(getMemoryFilePath('global'));
|
||||
const projectPath = tildeifyPath(getMemoryFilePath('project'));
|
||||
|
||||
const confirmationDetails: ToolEditConfirmationDetails = {
|
||||
type: 'edit',
|
||||
title: `Choose Memory Storage Location`,
|
||||
fileName: 'Memory Storage Options',
|
||||
fileDiff: `Choose where to save this memory:\n\n"${params.fact}"\n\nOptions:\n- Global: ${globalPath} (shared across all projects)\n- Project: ${projectPath} (current project only)\n\nPlease specify the scope parameter: "global" or "project"`,
|
||||
originalContent: '',
|
||||
newContent: `Memory to save: ${params.fact}\n\nScope options:\n- global: ${globalPath}\n- project: ${projectPath}`,
|
||||
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
|
||||
// This will be handled by the execution flow
|
||||
},
|
||||
};
|
||||
return confirmationDetails;
|
||||
}
|
||||
|
||||
const scope = params.scope;
|
||||
const memoryFilePath = getMemoryFilePath(scope);
|
||||
const allowlistKey = `${memoryFilePath}_${scope}`;
|
||||
const memoryFilePath = getGlobalMemoryFilePath();
|
||||
const allowlistKey = memoryFilePath;
|
||||
|
||||
if (MemoryTool.allowlist.has(allowlistKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read current content of the memory file
|
||||
const currentContent = await this.readMemoryFileContent(scope);
|
||||
const currentContent = await this.readMemoryFileContent();
|
||||
|
||||
// Calculate the new content that will be written to the memory file
|
||||
const newContent = this.computeNewContent(currentContent, params.fact);
|
||||
@@ -262,7 +218,7 @@ export class MemoryTool
|
||||
|
||||
const confirmationDetails: ToolEditConfirmationDetails = {
|
||||
type: 'edit',
|
||||
title: `Confirm Memory Save: ${tildeifyPath(memoryFilePath)} (${scope})`,
|
||||
title: `Confirm Memory Save: ${tildeifyPath(memoryFilePath)}`,
|
||||
fileName: memoryFilePath,
|
||||
fileDiff,
|
||||
originalContent: currentContent,
|
||||
@@ -360,27 +316,18 @@ export class MemoryTool
|
||||
};
|
||||
}
|
||||
|
||||
// If scope is not specified, prompt the user to choose
|
||||
if (!params.scope) {
|
||||
const errorMessage =
|
||||
'Please specify where to save this memory. Use scope parameter: "global" for user-level (~/.qwen/QWEN.md) or "project" for current project (./QWEN.md).';
|
||||
return {
|
||||
llmContent: JSON.stringify({ success: false, error: errorMessage }),
|
||||
returnDisplay: `${errorMessage}\n\nGlobal: ${tildeifyPath(getMemoryFilePath('global'))}\nProject: ${tildeifyPath(getMemoryFilePath('project'))}`,
|
||||
};
|
||||
}
|
||||
|
||||
const scope = params.scope;
|
||||
const memoryFilePath = getMemoryFilePath(scope);
|
||||
|
||||
try {
|
||||
if (modified_by_user && modified_content !== undefined) {
|
||||
// User modified the content in external editor, write it directly
|
||||
await fs.mkdir(path.dirname(memoryFilePath), {
|
||||
await fs.mkdir(path.dirname(getGlobalMemoryFilePath()), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(memoryFilePath, modified_content, 'utf-8');
|
||||
const successMessage = `Okay, I've updated the ${scope} memory file with your modifications.`;
|
||||
await fs.writeFile(
|
||||
getGlobalMemoryFilePath(),
|
||||
modified_content,
|
||||
'utf-8',
|
||||
);
|
||||
const successMessage = `Okay, I've updated the memory file with your modifications.`;
|
||||
return {
|
||||
llmContent: JSON.stringify({
|
||||
success: true,
|
||||
@@ -390,12 +337,16 @@ export class MemoryTool
|
||||
};
|
||||
} else {
|
||||
// Use the normal memory entry logic
|
||||
await MemoryTool.performAddMemoryEntry(fact, memoryFilePath, {
|
||||
readFile: fs.readFile,
|
||||
writeFile: fs.writeFile,
|
||||
mkdir: fs.mkdir,
|
||||
});
|
||||
const successMessage = `Okay, I've remembered that in ${scope} memory: "${fact}"`;
|
||||
await MemoryTool.performAddMemoryEntry(
|
||||
fact,
|
||||
getGlobalMemoryFilePath(),
|
||||
{
|
||||
readFile: fs.readFile,
|
||||
writeFile: fs.writeFile,
|
||||
mkdir: fs.mkdir,
|
||||
},
|
||||
);
|
||||
const successMessage = `Okay, I've remembered that: "${fact}"`;
|
||||
return {
|
||||
llmContent: JSON.stringify({
|
||||
success: true,
|
||||
@@ -408,7 +359,7 @@ export class MemoryTool
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`[MemoryTool] Error executing save_memory for fact "${fact}" in ${scope}: ${errorMessage}`,
|
||||
`[MemoryTool] Error executing save_memory for fact "${fact}": ${errorMessage}`,
|
||||
);
|
||||
return {
|
||||
llmContent: JSON.stringify({
|
||||
@@ -422,13 +373,11 @@ export class MemoryTool
|
||||
|
||||
getModifyContext(_abortSignal: AbortSignal): ModifyContext<SaveMemoryParams> {
|
||||
return {
|
||||
getFilePath: (params: SaveMemoryParams) =>
|
||||
getMemoryFilePath(params.scope || 'global'),
|
||||
getCurrentContent: async (params: SaveMemoryParams): Promise<string> =>
|
||||
this.readMemoryFileContent(params.scope || 'global'),
|
||||
getFilePath: (_params: SaveMemoryParams) => getGlobalMemoryFilePath(),
|
||||
getCurrentContent: async (_params: SaveMemoryParams): Promise<string> =>
|
||||
this.readMemoryFileContent(),
|
||||
getProposedContent: async (params: SaveMemoryParams): Promise<string> => {
|
||||
const scope = params.scope || 'global';
|
||||
const currentContent = await this.readMemoryFileContent(scope);
|
||||
const currentContent = await this.readMemoryFileContent();
|
||||
return this.computeNewContent(currentContent, params.fact);
|
||||
},
|
||||
createUpdatedParams: (
|
||||
|
||||
@@ -19,27 +19,23 @@ describe('WebFetchTool', () => {
|
||||
describe('shouldConfirmExecute', () => {
|
||||
it('should return confirmation details with the correct prompt and urls', async () => {
|
||||
const tool = new WebFetchTool(mockConfig);
|
||||
const params = {
|
||||
url: 'https://example.com',
|
||||
prompt: 'summarize this page',
|
||||
};
|
||||
const params = { prompt: 'fetch https://example.com' };
|
||||
const confirmationDetails = await tool.shouldConfirmExecute(params);
|
||||
|
||||
expect(confirmationDetails).toEqual({
|
||||
type: 'info',
|
||||
title: 'Confirm Web Fetch',
|
||||
prompt:
|
||||
'Fetch content from https://example.com and process with: summarize this page',
|
||||
prompt: 'fetch https://example.com',
|
||||
urls: ['https://example.com'],
|
||||
onConfirm: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return github urls as-is in confirmation details', async () => {
|
||||
it('should convert github urls to raw format', async () => {
|
||||
const tool = new WebFetchTool(mockConfig);
|
||||
const params = {
|
||||
url: 'https://github.com/google/gemini-react/blob/main/README.md',
|
||||
prompt: 'summarize the README',
|
||||
prompt:
|
||||
'fetch https://github.com/google/gemini-react/blob/main/README.md',
|
||||
};
|
||||
const confirmationDetails = await tool.shouldConfirmExecute(params);
|
||||
|
||||
@@ -47,8 +43,10 @@ describe('WebFetchTool', () => {
|
||||
type: 'info',
|
||||
title: 'Confirm Web Fetch',
|
||||
prompt:
|
||||
'Fetch content from https://github.com/google/gemini-react/blob/main/README.md and process with: summarize the README',
|
||||
urls: ['https://github.com/google/gemini-react/blob/main/README.md'],
|
||||
'fetch https://github.com/google/gemini-react/blob/main/README.md',
|
||||
urls: [
|
||||
'https://raw.githubusercontent.com/google/gemini-react/main/README.md',
|
||||
],
|
||||
onConfirm: expect.any(Function),
|
||||
});
|
||||
});
|
||||
@@ -58,10 +56,7 @@ describe('WebFetchTool', () => {
|
||||
...mockConfig,
|
||||
getApprovalMode: () => ApprovalMode.AUTO_EDIT,
|
||||
} as unknown as Config);
|
||||
const params = {
|
||||
url: 'https://example.com',
|
||||
prompt: 'summarize this page',
|
||||
};
|
||||
const params = { prompt: 'fetch https://example.com' };
|
||||
const confirmationDetails = await tool.shouldConfirmExecute(params);
|
||||
|
||||
expect(confirmationDetails).toBe(false);
|
||||
@@ -73,10 +68,7 @@ describe('WebFetchTool', () => {
|
||||
...mockConfig,
|
||||
setApprovalMode,
|
||||
} as unknown as Config);
|
||||
const params = {
|
||||
url: 'https://example.com',
|
||||
prompt: 'summarize this page',
|
||||
};
|
||||
const params = { prompt: 'fetch https://example.com' };
|
||||
const confirmationDetails = await tool.shouldConfirmExecute(params);
|
||||
|
||||
if (
|
||||
|
||||
@@ -13,25 +13,49 @@ import {
|
||||
Icon,
|
||||
} from './tools.js';
|
||||
import { Type } from '@google/genai';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { Config, ApprovalMode } from '../config/config.js';
|
||||
import { getResponseText } from '../utils/generateContentResponseUtilities.js';
|
||||
import { fetchWithTimeout } from '../utils/fetch.js';
|
||||
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
|
||||
import { convert } from 'html-to-text';
|
||||
import { ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||
|
||||
const URL_FETCH_TIMEOUT_MS = 10000;
|
||||
const MAX_CONTENT_LENGTH = 100000;
|
||||
|
||||
// Helper function to extract URLs from a string
|
||||
function extractUrls(text: string): string[] {
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
return text.match(urlRegex) || [];
|
||||
}
|
||||
|
||||
// Interfaces for grounding metadata (similar to web-search.ts)
|
||||
interface GroundingChunkWeb {
|
||||
uri?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface GroundingChunkItem {
|
||||
web?: GroundingChunkWeb;
|
||||
}
|
||||
|
||||
interface GroundingSupportSegment {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface GroundingSupportItem {
|
||||
segment?: GroundingSupportSegment;
|
||||
groundingChunkIndices?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the WebFetch tool
|
||||
*/
|
||||
export interface WebFetchToolParams {
|
||||
/**
|
||||
* The URL to fetch content from
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* The prompt to run on the fetched content
|
||||
* The prompt containing URL(s) (up to 20) and instructions for processing their content.
|
||||
*/
|
||||
prompt: string;
|
||||
}
|
||||
@@ -46,20 +70,17 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
|
||||
super(
|
||||
WebFetchTool.Name,
|
||||
'WebFetch',
|
||||
'Fetches content from a specified URL and processes it using an AI model\n- Takes a URL and a prompt as input\n- Fetches the URL content, converts HTML to markdown\n- Processes the content with the prompt using a small, fast model\n- Returns the model\'s response about the content\n- Use this tool when you need to retrieve and analyze web content\n\nUsage notes:\n - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp__".\n - The URL must be a fully-formed valid URL\n - The prompt should describe what information you want to extract from the page\n - This tool is read-only and does not modify any files\n - Results may be summarized if the content is very large',
|
||||
"Processes content from URL(s), including local and private network addresses (e.g., localhost), embedded in a prompt. Include up to 20 URLs and instructions (e.g., summarize, extract specific data) directly in the 'prompt' parameter.",
|
||||
Icon.Globe,
|
||||
{
|
||||
properties: {
|
||||
url: {
|
||||
description: 'The URL to fetch content from',
|
||||
type: Type.STRING,
|
||||
},
|
||||
prompt: {
|
||||
description: 'The prompt to run on the fetched content',
|
||||
description:
|
||||
'A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content (e.g., "Summarize https://example.com/article and extract key points from https://another.com/data"). Must contain as least one URL starting with http:// or https://.',
|
||||
type: Type.STRING,
|
||||
},
|
||||
},
|
||||
required: ['url', 'prompt'],
|
||||
required: ['prompt'],
|
||||
type: Type.OBJECT,
|
||||
},
|
||||
);
|
||||
@@ -69,11 +90,19 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
|
||||
}
|
||||
}
|
||||
|
||||
private async executeFetch(
|
||||
private async executeFallback(
|
||||
params: WebFetchToolParams,
|
||||
signal: AbortSignal,
|
||||
): Promise<ToolResult> {
|
||||
let url = params.url;
|
||||
const urls = extractUrls(params.prompt);
|
||||
if (urls.length === 0) {
|
||||
return {
|
||||
llmContent: 'Error: No URL found in the prompt for fallback.',
|
||||
returnDisplay: 'Error: No URL found in the prompt for fallback.',
|
||||
};
|
||||
}
|
||||
// For now, we only support one URL for fallback
|
||||
let url = urls[0];
|
||||
|
||||
// Convert GitHub blob URL to raw URL
|
||||
if (url.includes('github.com') && url.includes('/blob/')) {
|
||||
@@ -101,7 +130,7 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
|
||||
const geminiClient = this.config.getGeminiClient();
|
||||
const fallbackPrompt = `The user requested the following: "${params.prompt}".
|
||||
|
||||
I have fetched the content from ${params.url}. Please use the following content to answer the user's request.
|
||||
I was unable to access the URL directly. Instead, I have fetched the raw content of the page. Please use the following content to answer the user's request. Do not attempt to access the URL again.
|
||||
|
||||
---
|
||||
${textContent}
|
||||
@@ -114,11 +143,11 @@ ${textContent}
|
||||
const resultText = getResponseText(result) || '';
|
||||
return {
|
||||
llmContent: resultText,
|
||||
returnDisplay: `Content from ${params.url} processed successfully.`,
|
||||
returnDisplay: `Content for ${url} processed using fallback fetch.`,
|
||||
};
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
const errorMessage = `Error during fetch for ${url}: ${error.message}`;
|
||||
const errorMessage = `Error during fallback fetch for ${url}: ${error.message}`;
|
||||
return {
|
||||
llmContent: `Error: ${errorMessage}`,
|
||||
returnDisplay: `Error: ${errorMessage}`,
|
||||
@@ -131,17 +160,14 @@ ${textContent}
|
||||
if (errors) {
|
||||
return errors;
|
||||
}
|
||||
if (!params.url || params.url.trim() === '') {
|
||||
return "The 'url' parameter cannot be empty.";
|
||||
if (!params.prompt || params.prompt.trim() === '') {
|
||||
return "The 'prompt' parameter cannot be empty and must contain URL(s) and instructions.";
|
||||
}
|
||||
if (
|
||||
!params.url.startsWith('http://') &&
|
||||
!params.url.startsWith('https://')
|
||||
!params.prompt.includes('http://') &&
|
||||
!params.prompt.includes('https://')
|
||||
) {
|
||||
return "The 'url' must be a valid URL starting with http:// or https://.";
|
||||
}
|
||||
if (!params.prompt || params.prompt.trim() === '') {
|
||||
return "The 'prompt' parameter cannot be empty.";
|
||||
return "The 'prompt' must contain at least one valid URL (starting with http:// or https://).";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -151,7 +177,7 @@ ${textContent}
|
||||
params.prompt.length > 100
|
||||
? params.prompt.substring(0, 97) + '...'
|
||||
: params.prompt;
|
||||
return `Fetching content from ${params.url} and processing with prompt: "${displayPrompt}"`;
|
||||
return `Processing URLs and instructions from prompt: "${displayPrompt}"`;
|
||||
}
|
||||
|
||||
async shouldConfirmExecute(
|
||||
@@ -166,11 +192,22 @@ ${textContent}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Perform GitHub URL conversion here to differentiate between user-provided
|
||||
// URL and the actual URL to be fetched.
|
||||
const urls = extractUrls(params.prompt).map((url) => {
|
||||
if (url.includes('github.com') && url.includes('/blob/')) {
|
||||
return url
|
||||
.replace('github.com', 'raw.githubusercontent.com')
|
||||
.replace('/blob/', '/');
|
||||
}
|
||||
return url;
|
||||
});
|
||||
|
||||
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'info',
|
||||
title: `Confirm Web Fetch`,
|
||||
prompt: `Fetch content from ${params.url} and process with: ${params.prompt}`,
|
||||
urls: [params.url],
|
||||
prompt: params.prompt,
|
||||
urls,
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
|
||||
@@ -192,6 +229,132 @@ ${textContent}
|
||||
};
|
||||
}
|
||||
|
||||
return this.executeFetch(params, signal);
|
||||
const userPrompt = params.prompt;
|
||||
const urls = extractUrls(userPrompt);
|
||||
const url = urls[0];
|
||||
const isPrivate = isPrivateIp(url);
|
||||
|
||||
if (isPrivate) {
|
||||
return this.executeFallback(params, signal);
|
||||
}
|
||||
|
||||
const geminiClient = this.config.getGeminiClient();
|
||||
|
||||
try {
|
||||
const response = await geminiClient.generateContent(
|
||||
[{ role: 'user', parts: [{ text: userPrompt }] }],
|
||||
{ tools: [{ urlContext: {} }] },
|
||||
signal, // Pass signal
|
||||
);
|
||||
|
||||
console.debug(
|
||||
`[WebFetchTool] Full response for prompt "${userPrompt.substring(
|
||||
0,
|
||||
50,
|
||||
)}...":`,
|
||||
JSON.stringify(response, null, 2),
|
||||
);
|
||||
|
||||
let responseText = getResponseText(response) || '';
|
||||
const urlContextMeta = response.candidates?.[0]?.urlContextMetadata;
|
||||
const groundingMetadata = response.candidates?.[0]?.groundingMetadata;
|
||||
const sources = groundingMetadata?.groundingChunks as
|
||||
| GroundingChunkItem[]
|
||||
| undefined;
|
||||
const groundingSupports = groundingMetadata?.groundingSupports as
|
||||
| GroundingSupportItem[]
|
||||
| undefined;
|
||||
|
||||
// Error Handling
|
||||
let processingError = false;
|
||||
|
||||
if (
|
||||
urlContextMeta?.urlMetadata &&
|
||||
urlContextMeta.urlMetadata.length > 0
|
||||
) {
|
||||
const allStatuses = urlContextMeta.urlMetadata.map(
|
||||
(m) => m.urlRetrievalStatus,
|
||||
);
|
||||
if (allStatuses.every((s) => s !== 'URL_RETRIEVAL_STATUS_SUCCESS')) {
|
||||
processingError = true;
|
||||
}
|
||||
} else if (!responseText.trim() && !sources?.length) {
|
||||
// No URL metadata and no content/sources
|
||||
processingError = true;
|
||||
}
|
||||
|
||||
if (
|
||||
!processingError &&
|
||||
!responseText.trim() &&
|
||||
(!sources || sources.length === 0)
|
||||
) {
|
||||
// Successfully retrieved some URL (or no specific error from urlContextMeta), but no usable text or grounding data.
|
||||
processingError = true;
|
||||
}
|
||||
|
||||
if (processingError) {
|
||||
return this.executeFallback(params, signal);
|
||||
}
|
||||
|
||||
const sourceListFormatted: string[] = [];
|
||||
if (sources && sources.length > 0) {
|
||||
sources.forEach((source: GroundingChunkItem, index: number) => {
|
||||
const title = source.web?.title || 'Untitled';
|
||||
const uri = source.web?.uri || 'Unknown URI'; // Fallback if URI is missing
|
||||
sourceListFormatted.push(`[${index + 1}] ${title} (${uri})`);
|
||||
});
|
||||
|
||||
if (groundingSupports && groundingSupports.length > 0) {
|
||||
const insertions: Array<{ index: number; marker: string }> = [];
|
||||
groundingSupports.forEach((support: GroundingSupportItem) => {
|
||||
if (support.segment && support.groundingChunkIndices) {
|
||||
const citationMarker = support.groundingChunkIndices
|
||||
.map((chunkIndex: number) => `[${chunkIndex + 1}]`)
|
||||
.join('');
|
||||
insertions.push({
|
||||
index: support.segment.endIndex,
|
||||
marker: citationMarker,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
insertions.sort((a, b) => b.index - a.index);
|
||||
const responseChars = responseText.split('');
|
||||
insertions.forEach((insertion) => {
|
||||
responseChars.splice(insertion.index, 0, insertion.marker);
|
||||
});
|
||||
responseText = responseChars.join('');
|
||||
}
|
||||
|
||||
if (sourceListFormatted.length > 0) {
|
||||
responseText += `
|
||||
|
||||
Sources:
|
||||
${sourceListFormatted.join('\n')}`;
|
||||
}
|
||||
}
|
||||
|
||||
const llmContent = responseText;
|
||||
|
||||
console.debug(
|
||||
`[WebFetchTool] Formatted tool response for prompt "${userPrompt}:\n\n":`,
|
||||
llmContent,
|
||||
);
|
||||
|
||||
return {
|
||||
llmContent,
|
||||
returnDisplay: `Content processed from prompt.`,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = `Error processing web content for prompt "${userPrompt.substring(
|
||||
0,
|
||||
50,
|
||||
)}...": ${getErrorMessage(error)}`;
|
||||
console.error(errorMessage, error);
|
||||
return {
|
||||
llmContent: `Error: ${errorMessage}`,
|
||||
returnDisplay: `Error: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,24 +4,35 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { GroundingMetadata } from '@google/genai';
|
||||
import { BaseTool, Icon, ToolResult } from './tools.js';
|
||||
import { Type } from '@google/genai';
|
||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { getResponseText } from '../utils/generateContentResponseUtilities.js';
|
||||
|
||||
interface TavilyResultItem {
|
||||
title: string;
|
||||
url: string;
|
||||
content?: string;
|
||||
score?: number;
|
||||
published_date?: string;
|
||||
interface GroundingChunkWeb {
|
||||
uri?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface TavilySearchResponse {
|
||||
query: string;
|
||||
answer?: string;
|
||||
results: TavilyResultItem[];
|
||||
interface GroundingChunkItem {
|
||||
web?: GroundingChunkWeb;
|
||||
// Other properties might exist if needed in the future
|
||||
}
|
||||
|
||||
interface GroundingSupportSegment {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
text?: string; // text is optional as per the example
|
||||
}
|
||||
|
||||
interface GroundingSupportItem {
|
||||
segment?: GroundingSupportSegment;
|
||||
groundingChunkIndices?: number[];
|
||||
confidenceScores?: number[]; // Optional as per example
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,6 +42,7 @@ export interface WebSearchToolParams {
|
||||
/**
|
||||
* The search query.
|
||||
*/
|
||||
|
||||
query: string;
|
||||
}
|
||||
|
||||
@@ -38,23 +50,25 @@ export interface WebSearchToolParams {
|
||||
* Extends ToolResult to include sources for web search.
|
||||
*/
|
||||
export interface WebSearchToolResult extends ToolResult {
|
||||
sources?: Array<{ title: string; url: string }>;
|
||||
sources?: GroundingMetadata extends { groundingChunks: GroundingChunkItem[] }
|
||||
? GroundingMetadata['groundingChunks']
|
||||
: GroundingChunkItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool to perform web searches using Tavily API.
|
||||
* A tool to perform web searches using Google Search via the Gemini API.
|
||||
*/
|
||||
export class WebSearchTool extends BaseTool<
|
||||
WebSearchToolParams,
|
||||
WebSearchToolResult
|
||||
> {
|
||||
static readonly Name: string = 'web_search';
|
||||
static readonly Name: string = 'google_web_search';
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
super(
|
||||
WebSearchTool.Name,
|
||||
'TavilySearch',
|
||||
'Performs a web search using the Tavily API and returns a concise answer with sources. Requires the TAVILY_API_KEY environment variable.',
|
||||
'GoogleSearch',
|
||||
'Performs a web search using Google Search (via the Gemini API) and returns the results. This tool is useful for finding information on the internet based on a query.',
|
||||
Icon.Globe,
|
||||
{
|
||||
type: Type.OBJECT,
|
||||
@@ -92,7 +106,7 @@ export class WebSearchTool extends BaseTool<
|
||||
|
||||
async execute(
|
||||
params: WebSearchToolParams,
|
||||
_signal: AbortSignal,
|
||||
signal: AbortSignal,
|
||||
): Promise<WebSearchToolResult> {
|
||||
const validationError = this.validateToolParams(params);
|
||||
if (validationError) {
|
||||
@@ -101,83 +115,79 @@ export class WebSearchTool extends BaseTool<
|
||||
returnDisplay: validationError,
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = this.config.getTavilyApiKey() || process.env.TAVILY_API_KEY;
|
||||
if (!apiKey) {
|
||||
return {
|
||||
llmContent:
|
||||
'Web search is disabled because TAVILY_API_KEY is not configured. Please set it in your settings.json, .env file, or via --tavily-api-key command line argument to enable web search.',
|
||||
returnDisplay:
|
||||
'Web search disabled. Configure TAVILY_API_KEY to enable Tavily search.',
|
||||
};
|
||||
}
|
||||
const geminiClient = this.config.getGeminiClient();
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||||
const response = await fetch('https://api.tavily.com/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api_key: apiKey,
|
||||
query: params.query,
|
||||
search_depth: 'advanced',
|
||||
max_results: 5,
|
||||
include_answer: true,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Tavily API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TavilySearchResponse;
|
||||
|
||||
const sources = (data.results || []).map((r) => ({
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
}));
|
||||
|
||||
const sourceListFormatted = sources.map(
|
||||
(s, i) => `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`,
|
||||
const response = await geminiClient.generateContent(
|
||||
[{ role: 'user', parts: [{ text: params.query }] }],
|
||||
{ tools: [{ googleSearch: {} }] },
|
||||
signal,
|
||||
);
|
||||
|
||||
let content = data.answer?.trim() || '';
|
||||
if (!content) {
|
||||
// Fallback: build a concise summary from top results
|
||||
content = sources
|
||||
.slice(0, 3)
|
||||
.map((s, i) => `${i + 1}. ${s.title} - ${s.url}`)
|
||||
.join('\n');
|
||||
}
|
||||
const responseText = getResponseText(response);
|
||||
const groundingMetadata = response.candidates?.[0]?.groundingMetadata;
|
||||
const sources = groundingMetadata?.groundingChunks as
|
||||
| GroundingChunkItem[]
|
||||
| undefined;
|
||||
const groundingSupports = groundingMetadata?.groundingSupports as
|
||||
| GroundingSupportItem[]
|
||||
| undefined;
|
||||
|
||||
if (sourceListFormatted.length > 0) {
|
||||
content += `\n\nSources:\n${sourceListFormatted.join('\n')}`;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
if (!responseText || !responseText.trim()) {
|
||||
return {
|
||||
llmContent: `No search results or information found for query: "${params.query}"`,
|
||||
returnDisplay: 'No information found.',
|
||||
};
|
||||
}
|
||||
|
||||
let modifiedResponseText = responseText;
|
||||
const sourceListFormatted: string[] = [];
|
||||
|
||||
if (sources && sources.length > 0) {
|
||||
sources.forEach((source: GroundingChunkItem, index: number) => {
|
||||
const title = source.web?.title || 'Untitled';
|
||||
const uri = source.web?.uri || 'No URI';
|
||||
sourceListFormatted.push(`[${index + 1}] ${title} (${uri})`);
|
||||
});
|
||||
|
||||
if (groundingSupports && groundingSupports.length > 0) {
|
||||
const insertions: Array<{ index: number; marker: string }> = [];
|
||||
groundingSupports.forEach((support: GroundingSupportItem) => {
|
||||
if (support.segment && support.groundingChunkIndices) {
|
||||
const citationMarker = support.groundingChunkIndices
|
||||
.map((chunkIndex: number) => `[${chunkIndex + 1}]`)
|
||||
.join('');
|
||||
insertions.push({
|
||||
index: support.segment.endIndex,
|
||||
marker: citationMarker,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort insertions by index in descending order to avoid shifting subsequent indices
|
||||
insertions.sort((a, b) => b.index - a.index);
|
||||
|
||||
const responseChars = modifiedResponseText.split(''); // Use new variable
|
||||
insertions.forEach((insertion) => {
|
||||
// Fixed arrow function syntax
|
||||
responseChars.splice(insertion.index, 0, insertion.marker);
|
||||
});
|
||||
modifiedResponseText = responseChars.join(''); // Assign back to modifiedResponseText
|
||||
}
|
||||
|
||||
if (sourceListFormatted.length > 0) {
|
||||
modifiedResponseText +=
|
||||
'\n\nSources:\n' + sourceListFormatted.join('\n'); // Fixed string concatenation
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: `Web search results for "${params.query}":\n\n${content}`,
|
||||
llmContent: `Web search results for "${params.query}":\n\n${modifiedResponseText}`,
|
||||
returnDisplay: `Search results for "${params.query}" returned.`,
|
||||
sources,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = `Error during web search for query "${params.query}": ${getErrorMessage(
|
||||
error,
|
||||
)}`;
|
||||
const errorMessage = `Error during web search for query "${params.query}": ${getErrorMessage(error)}`;
|
||||
console.error(errorMessage, error);
|
||||
return {
|
||||
llmContent: `Error: ${errorMessage}`,
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { safeJsonParse } from './safeJsonParse.js';
|
||||
|
||||
describe('safeJsonParse', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('valid JSON parsing', () => {
|
||||
it('should parse valid JSON correctly', () => {
|
||||
const validJson = '{"name": "test", "value": 123}';
|
||||
const result = safeJsonParse(validJson);
|
||||
|
||||
expect(result).toEqual({ name: 'test', value: 123 });
|
||||
});
|
||||
|
||||
it('should parse valid JSON arrays', () => {
|
||||
const validArray = '["item1", "item2", "item3"]';
|
||||
const result = safeJsonParse(validArray);
|
||||
|
||||
expect(result).toEqual(['item1', 'item2', 'item3']);
|
||||
});
|
||||
|
||||
it('should parse valid JSON with nested objects', () => {
|
||||
const validNested =
|
||||
'{"config": {"paths": ["testlogs/*.py"], "options": {"recursive": true}}}';
|
||||
const result = safeJsonParse(validNested);
|
||||
|
||||
expect(result).toEqual({
|
||||
config: {
|
||||
paths: ['testlogs/*.py'],
|
||||
options: { recursive: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed JSON with jsonrepair fallback', () => {
|
||||
it('should handle malformed JSON with single quotes', () => {
|
||||
const malformedJson = "{'name': 'test', 'value': 123}";
|
||||
const result = safeJsonParse(malformedJson);
|
||||
|
||||
expect(result).toEqual({ name: 'test', value: 123 });
|
||||
});
|
||||
|
||||
it('should handle malformed JSON with unquoted keys', () => {
|
||||
const malformedJson = '{name: "test", value: 123}';
|
||||
const result = safeJsonParse(malformedJson);
|
||||
|
||||
expect(result).toEqual({ name: 'test', value: 123 });
|
||||
});
|
||||
|
||||
it('should handle malformed JSON with trailing commas', () => {
|
||||
const malformedJson = '{"name": "test", "value": 123,}';
|
||||
const result = safeJsonParse(malformedJson);
|
||||
|
||||
expect(result).toEqual({ name: 'test', value: 123 });
|
||||
});
|
||||
|
||||
it('should handle malformed JSON with comments', () => {
|
||||
const malformedJson = '{"name": "test", // comment\n "value": 123}';
|
||||
const result = safeJsonParse(malformedJson);
|
||||
|
||||
expect(result).toEqual({ name: 'test', value: 123 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback behavior', () => {
|
||||
it('should return fallback value for empty string', () => {
|
||||
const emptyString = '';
|
||||
const fallback = { default: 'value' };
|
||||
|
||||
const result = safeJsonParse(emptyString, fallback);
|
||||
|
||||
expect(result).toEqual(fallback);
|
||||
});
|
||||
|
||||
it('should return fallback value for null input', () => {
|
||||
const nullInput = null as unknown as string;
|
||||
const fallback = { default: 'value' };
|
||||
|
||||
const result = safeJsonParse(nullInput, fallback);
|
||||
|
||||
expect(result).toEqual(fallback);
|
||||
});
|
||||
|
||||
it('should return fallback value for undefined input', () => {
|
||||
const undefinedInput = undefined as unknown as string;
|
||||
const fallback = { default: 'value' };
|
||||
|
||||
const result = safeJsonParse(undefinedInput, fallback);
|
||||
|
||||
expect(result).toEqual(fallback);
|
||||
});
|
||||
|
||||
it('should return empty object as default fallback', () => {
|
||||
const invalidJson = 'invalid json';
|
||||
|
||||
const result = safeJsonParse(invalidJson);
|
||||
|
||||
// jsonrepair returns the original string for completely invalid JSON
|
||||
expect(result).toEqual('invalid json');
|
||||
});
|
||||
|
||||
it('should return custom fallback when parsing fails', () => {
|
||||
const invalidJson = 'invalid json';
|
||||
const customFallback = { error: 'parsing failed', data: null };
|
||||
|
||||
const result = safeJsonParse(invalidJson, customFallback);
|
||||
|
||||
// jsonrepair returns the original string for completely invalid JSON
|
||||
expect(result).toEqual('invalid json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('type safety', () => {
|
||||
it('should preserve generic type when parsing valid JSON', () => {
|
||||
const validJson = '{"name": "test", "value": 123}';
|
||||
const result = safeJsonParse<{ name: string; value: number }>(validJson);
|
||||
|
||||
expect(result).toEqual({ name: 'test', value: 123 });
|
||||
// TypeScript should infer the correct type
|
||||
expect(typeof result.name).toBe('string');
|
||||
expect(typeof result.value).toBe('number');
|
||||
});
|
||||
|
||||
it('should return fallback type when parsing fails', () => {
|
||||
const invalidJson = 'invalid json';
|
||||
const fallback = { error: 'fallback' } as const;
|
||||
|
||||
const result = safeJsonParse(invalidJson, fallback);
|
||||
|
||||
// jsonrepair returns the original string for completely invalid JSON
|
||||
expect(result).toEqual('invalid json');
|
||||
// TypeScript should preserve the fallback type
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { jsonrepair } from 'jsonrepair';
|
||||
|
||||
/**
|
||||
* Safely parse JSON string with jsonrepair fallback for malformed JSON.
|
||||
* This function attempts to parse JSON normally first, and if that fails,
|
||||
* it uses jsonrepair to fix common JSON formatting issues before parsing.
|
||||
*
|
||||
* @param jsonString - The JSON string to parse
|
||||
* @param fallbackValue - The value to return if parsing fails completely
|
||||
* @returns The parsed object or the fallback value
|
||||
*/
|
||||
export function safeJsonParse<T = Record<string, unknown>>(
|
||||
jsonString: string,
|
||||
fallbackValue: T = {} as T,
|
||||
): T {
|
||||
if (!jsonString || typeof jsonString !== 'string') {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
try {
|
||||
// First attempt: try normal JSON.parse
|
||||
return JSON.parse(jsonString) as T;
|
||||
} catch (error) {
|
||||
try {
|
||||
// Second attempt: use jsonrepair to fix common JSON issues
|
||||
const repairedJson = jsonrepair(jsonString);
|
||||
|
||||
// jsonrepair always returns a string, so we need to parse it
|
||||
return JSON.parse(repairedJson) as T;
|
||||
} catch (repairError) {
|
||||
console.error('Failed to parse JSON even with jsonrepair:', {
|
||||
originalError: error,
|
||||
repairError,
|
||||
jsonString,
|
||||
});
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"displayName": "Qwen Code Companion",
|
||||
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.6",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user